feat(workspace): unify projects and conversations in one menu
Replace separate project and activity navigation with a fixed-height two-line button and a shared workspace menu. Browse local, remote and channel projects, preview sessions on hover, restore the last opened conversation, and show ten recent conversations alongside active work. Preserve project settings, recovery actions, draft leave guards and completion reminders. Reuse conversation summaries and windowed lists without hover fetches or polling. Validation: focused component and App tests, real Electron layout and interaction checks, typecheck and lint passed. The earlier full suite timed out and reported Supervisor settings/layout failures. Release note: 左上角统一项目与活动入口,支持分类查找、悬停预览和最近 10 个会话;双行按钮整体点击,保留项目设置与跨项目会话切换。
This commit is contained in:
+1
-1
@@ -307,7 +307,7 @@
|
||||
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
|
||||
- 项目选择器与下方默认的新建对话按钮等宽,两行右侧图标按钮均宽 `36px`,与左侧控件间距均为 `8px`;搜索展开时项目选择器宽度保持不变。
|
||||
- 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。
|
||||
- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守
|
||||
- 项目选择器使用固定双行单按钮,第一行显示项目名,第二行显示跨项目活动汇总或“暂无活动”;点击任一行打开统一菜单并默认预览当前项目,全部项目范围在菜单内切换。尺寸、焦点、项目计数与菜单遵守
|
||||
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
|
||||
活动数量不改变项目菜单的稳定分组和顺序。
|
||||
- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整,
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
相关验收见 [User Stories](./user-stories.md#7-后续需求场景)。
|
||||
|
||||
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与级联菜单见
|
||||
生产项目入口是一个固定双行按钮:第一行显示项目名,第二行显示全局活动或“暂无活动”,点击任一行都打开统一工作空间菜单并默认预览当前项目。项目分类依次为“全部项目、本地、远程、消息通道”,点击“全部项目”切换全局范围,悬停预览会话,点击项目直接进入。生产最近会话上限为 10 条,验证状态见实施进度。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,统一菜单见
|
||||
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
|
||||
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”包括当前运行、待处理及本次客户端
|
||||
会话内已完成但未查看的会话;完成提示在实际进入聊天后清除,不累计历史完成总数。
|
||||
@@ -40,8 +41,24 @@
|
||||
| [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 |
|
||||
| [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 |
|
||||
| [实施进度](./progress.md) | 应用中心、设置及项目活动级联的实现与验证记录 |
|
||||
| [实施进度](./progress.md) | 应用中心、设置及统一工作空间菜单的实现与验证记录 |
|
||||
| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 |
|
||||
| [工作空间交互 Demo](./workspace-demo.html) | 左上角开始菜单式项目与并发会话候选方案;配套 `workspace-demo.css` 与 `workspace-demo.js` 仅服务原型 |
|
||||
|
||||
以下保留工作空间 Demo 的历史原型说明,包括最近 5 条的旧上限。生产实现已接入独立的 `WorkspaceMenu`,以功能设计和实施进度为准;Demo 的分类布局、排序、快捷键及模拟设置不作为生产契约。
|
||||
|
||||
工作空间 Demo 可直接打开,展示项目工作空间、会话窗口、全局活动、状态筛选、待办处理与完成待查看。
|
||||
左上角项目和活动摘要打开同一个锚定下拉菜单,没有独立总览页面或全屏遮罩。左栏筛选项目,右栏按状态分组显示紧凑会话列表,列表在菜单内滚动。
|
||||
菜单桌面宽度扩至 920px、高度至 640px,并受视口约束;左栏按本地项目、远程项目、消息通道分组。远程项目按主机归属展示,主机连接状态与会话活动计数分开;分组、主机和通道均使用演示数据。
|
||||
左侧通过本地、远程、消息通道 Tab 点击切换分类,项目悬停预览;演示包含 20 个本地项目、20 个远程项目和 3 个消息通道。搜索跨分类匹配项目名、主机及类型,远程主机分组支持折叠。“全部项目”、搜索和分类固定,项目列表独立滚动。
|
||||
项目行最小高度 52px,无活动时显示名称、工作目录两行;有活动时显示名称、活动状态、工作目录三行,高度随内容增长。当前项目以勾选区分于悬停预览。设置按钮独立,悬停或聚焦显示,触屏常显。设置弹窗可修改演示名称、说明、目录、默认模式及 Runtime;通道名称只读,执行空间不可修改。所有目录均为示例路径,不访问文件系统;原型未模拟真实目录选择、归档删除或远程恢复流程。
|
||||
点击项目入口默认筛选当前项目,点击活动摘要查看全部项目;悬停左侧项目只预览右侧会话。点击项目直接进入并恢复上次打开的会话,尚无会话则创建新会话;点击右侧会话直接进入指定会话。设置按钮独立,不触发项目切换,会话草稿保留。
|
||||
支持浅深主题、窄屏、点击外部关闭、Escape 关闭及 `Ctrl/Command+Shift+K` 切换菜单。
|
||||
菜单底部“新建项目”展开名称表单,创建后进入新项目的首个会话;“新建会话”与状态筛选同排,在所选项目创建会话,全部项目视图下使用当前项目。窄屏状态筛选横向滚动,创建按钮保持可见。均只修改演示数据,重置后清除。
|
||||
页面顶部可模拟后台完成,进入会话后清除该会话的完成提醒;已查看会话仍保留在最近会话中,不额外标记状态。
|
||||
状态沿用正式活动入口:待处理、运行中、已完成;待处理会话区分“等待你的回答”和“等待审批”。不提供“可继续”状态或筛选。“已完成”仍表示完成未查看提醒,不是全部历史完成会话。
|
||||
默认列表将待处理、运行中置顶,其余会话归入“最近会话”,按最近进入、发送或状态更新排序,最多显示 5 条;完成未查看在会话行标注。待处理和运行中不受此上限限制,也不重复列入最近会话。“已完成”筛选展示全部未查看结果,不受最近 5 条限制。没有活动时仍显示最近会话。
|
||||
所有执行、审批和消息均为内存中的模拟,不访问真实项目、不调用 AI;该候选方案尚未接入正式产品。
|
||||
|
||||
Demo 可直接在支持原生 dialog、popover 的现代浏览器中打开,无需依赖或构建。
|
||||
六个方案共用本次页面会话内的配置,刷新后全部恢复默认,不持久化或修改真实设置。
|
||||
@@ -58,7 +75,7 @@ Demo 的模型管理和 F 方案均使用模拟数据;正式本机推理监控
|
||||
列表,悬停或点击项目向右展开会话子菜单,点击会话才切换项目与对话。子菜单与项目列表
|
||||
相接,项目行保留子菜单箭头,会话行只显示名称与状态。支持上下方向键选择、右键进入、左键或 Esc 返回,顶层 Esc 关闭;窄屏在同一浮层内
|
||||
进入会话层,并提供返回按钮。页面上方可切换“有待处理、仅运行中、仅当前项目运行、全部
|
||||
空闲”四种场景。项目与会话均为演示数据;该级联交互已接入正式产品活动入口,范围与
|
||||
空闲”四种场景。项目与会话均为演示数据;该旧级联交互已由生产统一工作空间菜单替代,范围与
|
||||
验证记录见[实施进度](./progress.md)。
|
||||
Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
|
||||
工作栏 Tab 中演示,不列入应用中心。旧原型仍直接打开整窗 Modal,其中使用可搜索应用列表。
|
||||
|
||||
@@ -202,7 +202,7 @@ User Stories 已同步上述入口规则,工具实例行为沿用右侧工作
|
||||
首期覆盖应用中心访问、启用与常驻组合、顺序恢复、默认值与旧值迁移、共同设置、保存失败、
|
||||
知识库固定入口、心跳应用启停、可选应用已打开页面恢复,以及右侧实例选择、本机 Home、托管 SSH、无会话、
|
||||
目录失效、资源部分可用与采样停止。工具内部取消、超时、连接恢复及业务后台生命周期由
|
||||
相应功能负责,本功能不复制状态机。底部入口打开轻量菜单,“管理应用”才进入与设置相同的整窗 Modal。实现与验证的剩余范围见[实施进度](./progress.md)。
|
||||
相应功能负责,本功能不复制状态机。底部入口打开轻量菜单,“管理应用”才进入与设置相同的整窗 Modal。FR-14 覆盖统一菜单的入口与范围、预览与进入、最近条数、状态筛选、通道创建限制及会话期恢复,规则见第 9 节。实现与验证的剩余范围见[实施进度](./progress.md)。
|
||||
|
||||
## 8. 后续产品决策
|
||||
|
||||
@@ -257,14 +257,18 @@ ASR 按请求加载与释放,支持独立取消;TTS 尚无执行服务。具
|
||||
- 通知只保存在 Renderer 内存,不跨重载或重启恢复,也不从普通未读状态推导。
|
||||
- 仅当目标会话实际成为当前聊天、设置未打开且文档未隐藏时清除通知;普通侧栏导航和活动菜单
|
||||
导航采用同一查看条件。已在查看的会话完成后不保留通知,恢复可见聊天时也清除通知。
|
||||
- 展开活动项目、查看菜单、设置覆盖下保留挂载的聊天、隐藏文档以及取消设置离开都不算查看。
|
||||
- 预览项目、查看菜单、设置覆盖下保留挂载的聊天、隐藏文档以及取消设置离开都不算查看。
|
||||
|
||||
没有实时状态且没有已完成未查看通知时才移除活动行。
|
||||
|
||||
级联菜单先按项目归属聚合活动行,沿用本地、SSH Host、通道的稳定项目顺序;缺少项目
|
||||
元数据的归属组及“未归属项目”组放在已知项目之后。无归属组只用于访问这些会话,不增加
|
||||
项目计数。每个项目内依次列待处理、运行中、已完成会话,同类别保留来源顺序。
|
||||
统一菜单的分类、预览范围、状态筛选和项目搜索相互独立。项目名与活动摘要属于同一个按钮,点击任一行打开时分类为“全部项目”,预览范围为当前项目;点击菜单内“全部项目”分类将范围重置为全部。悬停或聚焦项目只更新预览范围,不导航、不读取会话详情。分类按全部项目、本地、远程、消息通道排列;搜索跨分类匹配名称、目录、主机名/地址及通道,搜索期间展开命中的主机组。远程能力关闭时隐藏 SSH 用户项目及其活动,消息通道仍可见。
|
||||
|
||||
展开项目只改变菜单层级,不导航。选中项目的活动行全部移除后返回项目列表;全局无活动行后
|
||||
关闭菜单并隐藏摘要入口。后台更新保留仍存在的焦点项,删除焦点项时将焦点
|
||||
移至当前层可用项。关闭菜单后,新的活动只恢复摘要,不会自动重新打开菜单。
|
||||
左栏沿用本地、SSH Host、消息通道的项目顺序,主机支持折叠,不按活动数量排序。右栏合并内存会话摘要与活动行,按 Conversation ID 去重;摘要尚未到达的活动仍可显示。无项目归属的会话只在全部项目范围显示,不增加项目计数;已不存在或不可见项目的会话排除,通道项目只显示通道会话。
|
||||
|
||||
默认筛选依次列待处理、运行中、最近会话。前两组不限条数且不重复列入最近;组内排序时间取最新消息创建时间与本次应用会话内访问/状态变化时间的最大值,无消息时用会话更新时间参与比较,同时间按 ID 排序,不按置顶优先排序。最近组最多 10 条,已完成未查看会话归入最近组并标注状态;“已完成”筛选列出全部完成未查看会话,不受 10 条限制。
|
||||
|
||||
首次活动状态快照只建立基线,不更新最近时间;后续状态变化更新时间,唯独从已完成变为无活动的提醒清除不更新时间。实际打开会话仍记录访问时间;悬停预览不记录。访问与状态时间仅保留在当前 Store 内存中,不跨重载或重启恢复。
|
||||
|
||||
点击项目经离开检查进入聊天,优先恢复本次应用会话内为该项目记住的有效 Conversation ID,否则按最新消息时间从候选会话中选最近一条,无消息时取会话更新时间。普通项目无会话时创建;通道项目只选通道会话,无会话时显示空态。记忆不跨重载或重启保存。点击会话仍按确切 ID 导航;设置按钮独立,不触发项目切换。“新建会话”使用预览项目,全部项目范围使用当前项目;目标缺失或为消息通道时禁用。
|
||||
|
||||
全局无活动时第二行显示“暂无活动”,按钮仍可打开菜单并预览当前项目的最近会话或空态,也可在菜单内切换全部项目范围。活动归零不改变控件高度,也不关闭已打开的菜单。点击外部、焦点离开、Escape 或隐藏侧栏关闭菜单,后台活动不自动打开。查看预览不清除完成通知,实际进入可见聊天才按上述规则清除。
|
||||
|
||||
@@ -146,10 +146,11 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
|
||||
### FR-14 项目活动汇总
|
||||
|
||||
用户在任一项目中都能查看跨项目的待处理、运行中与已完成未查看会话,并从活动列表直接进入所选会话。
|
||||
汇总入口仅在有上述活动行时显示在项目选择器下方,无活动行时整行隐藏。点击后先显示有活动会话的项目,
|
||||
悬停或点击项目展开会话子菜单,点击会话进入该条对话。项目菜单保留原有分组与顺序,并显示
|
||||
各项目的活动数量;级联菜单内的项目顺序不随各类活动数量变化。
|
||||
汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见
|
||||
左上角使用一个固定两行的按钮:第一行显示项目名,第二行显示全局活动摘要,无活动时显示“暂无活动”,按钮高度不随活动变化。点击任一行打开同一个锚定工作空间菜单,默认预览当前项目,全部项目范围从菜单内切换;无活动时仍可查看最近会话。
|
||||
左栏依次提供“全部项目、本地、远程、消息通道”分类,远程能力关闭时隐藏远程分类及其项目;支持跨分类搜索、远程主机折叠及独立项目设置。项目行按名称、活动数量、目录分行显示,无活动时省略数量行,项目顺序不随活动数量变化。
|
||||
悬停或键盘聚焦项目只预览右栏,点击项目直接进入,优先恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目无会话时创建,消息通道无会话时保持空态。点击右栏会话进入确切会话。
|
||||
右栏默认依次列待处理、运行中、最近会话,最近会话最多 10 条;已完成未查看在行内标注。“全部、待处理、运行中、已完成”筛选与“新建会话”同排,新建使用预览项目,全部项目范围使用当前项目,消息通道禁用手动新建。左栏底部“新建项目”进入真实项目创建流程。
|
||||
活动计数、待处理、运行中及已完成筛选不受最近 10 条限制;菜单不继承侧栏会话搜索条件。计数与排除规则见
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
验收覆盖跨项目后台运行、同会话多任务去重、提问与审批优先、较早的仍在运行或等待处理的
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# 应用导航实施进度
|
||||
|
||||
## 2026-10-04:双行入口合并为单个按钮
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。项目名和活动摘要现在属于同一个 `60px` 按钮,共用键盘焦点、展开状态和菜单关联。点击任一行默认预览当前项目,全部项目范围在菜单内切换;活动描述和按钮外的恢复反馈通过 `aria-describedby` 关联。移除了独立摘要触发引用与入口范围状态,保留最近 10 条及现有活动计数,没有新增轮询。
|
||||
|
||||
本次验证:`ProjectSwitcher`、`WorkspaceMenu`、`WorkspacePrimitives` 共 80 项通过;App 活动、审批、问题队列和流式侧栏相关测试 37 项通过;`npm run typecheck` 与 `npm run lint` 通过。真实 Electron overlay harness 在 `1280×800`、`1280×480`、`560×640` 的浅深主题下通过,验证单按钮、空闲与活动状态的 `60px` 高度、第二行点击、菜单布局和 Escape 焦点恢复,模型调用 0 次。
|
||||
|
||||
## 2026-10-04:双行入口与菜单布局调整
|
||||
|
||||
本节保留此次单按钮更正前的实现与验证记录;现行入口规则见上节。
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。已核对 `ProjectSwitcher.tsx`、`WorkspaceMenu.tsx` 和 `workspace-menu.css`:左上角控件固定高 `60px`,项目名和全局活动为同级按钮,第二行无活动时显示“暂无活动”;恢复反馈位于控件下方并关联可访问描述。左栏搜索框使用 `border-box` 限制边界,两栏之间使用 `--border-control` 分隔线,全部项目及搜索结果补充分组标题,右栏状态工具栏收紧间距。菜单项目行继续按名称、活动、目录分行显示,高度随内容增长。具体规则见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
以下结果由实现方提供,本次文档更新未重跑产品测试:
|
||||
|
||||
- 79 项定向组件测试、68 项项目相关 App 测试通过。
|
||||
- 完整类型检查与修改范围 lint 通过。
|
||||
- 真实 Electron 在 `1280×800`、`1280×480`、`560×640` 的浅深主题下验证搜索框边界、分隔线、紧凑工具栏、项目三行信息,以及左上角控件在空闲和活动状态下的固定高度与焦点。
|
||||
- 模型调用 0 次。
|
||||
|
||||
最近会话上限已由 5 条调整为 10 条,生产设计与验收条目已同步。最终运行 `npx vitest run --project unit src/renderer/src/workspace-menu-selectors.test.ts src/renderer/src/WorkspaceMenu.test.tsx src/renderer/src/ProjectSwitcher.test.tsx`,3 个文件共 44 项通过,覆盖最近 10 条、活动不占最近名额、已完成筛选超过 10 条,以及完成后保留历史。Demo 文件未修改,仍保留原型的最近 5 条行为。
|
||||
|
||||
## 2026-10-04:生产统一工作空间菜单
|
||||
|
||||
本节保留首次接入时的历史实现与验证范围,其中最近 5 条为当时上限,现行调整见上节。
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。已核对生产 `WorkspaceMenu.tsx`、`workspace-menu-selectors.ts`、`ProjectSwitcher.tsx` 与 `App.tsx` 差异:项目入口与活动摘要共用菜单,替代独立 `ProjectActivity` 级联。已接入全部项目优先的分类 PageTabs、悬停预览与点击进入、最近 5 条、同排状态筛选与新建会话、真实项目设置和创建、项目三行信息,以及会话期内上次打开记录。预览只用内存数据,项目和会话列表复用共享窗口化;规则见[活动逻辑](./logic-design.md#9-项目活动汇总)与[技术设计](./technical-design.md#12-项目活动汇总)。
|
||||
|
||||
最终源码补充:菜单最近时间取最新消息时间与 Store 会话期访问/状态时间的最大值;`recordActivityStatuses` 首次只建立基线,清除完成提醒不更新时间。项目上次打开 ID 仍只保留在内存,获准后的项目进入及新建回调使用 `commitView`,避免重复离开检查。
|
||||
|
||||
以下为实现代理提供的验证结果,本次仅更新文档,未重跑产品测试:
|
||||
|
||||
- 82 项定向测试通过。
|
||||
- 31 项选定 App 测试通过。
|
||||
- 1 项真实 Electron overlay 测试通过,最终场景也在全量测试运行中通过。
|
||||
- 最终完整 `npm run typecheck`、`npm run lint` 通过。
|
||||
- 全量 `npm test` 在 600 秒超时前未完成。已结束的 App 文件共 347 项,346 项通过、1 项监督者设置测试失败;`tests/supervisor-layout.electron.test.ts` 也失败。尚无证据将这些失败归为既有问题,全量测试未通过。
|
||||
- 首次 200 秒尝试报告 `activity-history-repository` worker 失败;第二次截至超时未报告该处失败。同期有并行性能修改,未确定失败原因,也不据此认定已修复。
|
||||
|
||||
本次没有性能基准结论。README 保留未提交的工作空间 Demo 描述,并区分生产菜单与旧演示;下方级联菜单记录保留当时实现和验证范围,不再定义当前生产交互。
|
||||
|
||||
## 2026-10-01:设备共享入口
|
||||
|
||||
新增 `device-sharing`,启用后应用菜单及管理 Modal 均进入主内容区页面;管理行可排序。
|
||||
@@ -428,7 +468,7 @@ Windows Electron 43.2.0 / Chromium 150 的当前组件 fixture 验证浅深主
|
||||
|
||||
## 2026-09-13:生产活动入口
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e项目活动级联)。
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e统一工作空间菜单)。
|
||||
本页只记录项目活动入口,不表示应用中心计划已交付。
|
||||
|
||||
`App` 已接入 `ProjectActivity` 常驻摘要和锚定级联菜单。会话和项目计数继续使用现有
|
||||
|
||||
@@ -468,7 +468,7 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
|
||||
|
||||
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
|
||||
`assistantTasks`、`completedConversationIds` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
|
||||
`ProjectActivity` 在有活动时渲染汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。
|
||||
`ProjectSwitcher` 使用单个固定双行按钮显示项目名与全局活动,活动为空时第二行显示空闲文案,统一打开 `WorkspaceMenu` 并默认预览当前项目;生产 `App` 不再挂载独立 `ProjectActivity` 级联菜单。按钮通过 `aria-describedby` 关联活动描述及控件外的恢复反馈,尺寸与样式见活动界面。
|
||||
- `use-unviewed-completions.ts` 在 Renderer 内存维护完成集合和上一轮 Task 状态;已观察到的
|
||||
顶层 Task 转为 `completed` 时加入集合,初始历史不生成通知。`App` 的本地 `done` 事件和
|
||||
活动运行持久化快照的成功终态调用 `markConversationCompleted`,失败、取消不调用。
|
||||
@@ -477,16 +477,15 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
|
||||
`visibilitychange`,文档不为 `hidden` 时才按当前会话清除通知。这使普通侧栏、活动菜单
|
||||
导航和可见性恢复共用查看规则,KeepAlive 挂载本身不代表查看。集合不写入 SQLite 或设置。
|
||||
- 聚合状态新增 `completed`,全局和项目计数包含 `attention`、`running`、`completed`,
|
||||
同一 Conversation ID 只按最高优先级计数;活动行为空才隐藏摘要。
|
||||
- `App` 向 `ProjectActivity` 传入项目元数据、活动行、侧栏可见状态与既有精确跳转回调。
|
||||
菜单按项目 ID 分组并记忆化派生集合;排序仅影响展示,不改聚合状态或持久化数据。
|
||||
- 两层菜单在同一 body Portal 内相接,桌面会话层使用独立 fixed 定位,不参与项目层高度计算。
|
||||
项目层锚定摘要,会话层锚定选中项目行并独立限制窗口边界。布局在打开、层级变化、实时数据更新、窗口缩放和
|
||||
滚动时按锚点重算;窄窗口隐藏项目面板并保留返回操作。菜单不调用 Modal 焦点隔离,
|
||||
捕获阶段消费 Escape,防止触发窄侧栏的文档级监听;Tab 关闭后交还正常焦点顺序。
|
||||
菜单关闭与焦点恢复在导航回调之前同步完成。
|
||||
- `role="menu"` 使浮层直接进入共享 `browser-viewport-occlusion.ts` 相交检测。未增加
|
||||
浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
|
||||
同一 Conversation ID 只按最高优先级计数;活动行为空时以“暂无活动”替换计数,保留全局入口。
|
||||
- `App` 向 `ProjectSwitcher` 传入 `conversationStore`、活动行、侧栏可见状态和确切会话跳转、新建回调。`WorkspaceMenu` 持有分类、预览范围、搜索及状态筛选;分类复用 `PageTabs`,状态筛选复用 `SegmentedControl`,项目行与真实设置/创建表单继续由 `ProjectSwitcher` 提供。
|
||||
- `workspace-menu-selectors.ts` 订阅会话 Store,投影 ID、项目、标题、排序时间及通道标记,以字段相等比较保留未变化结果;排序时间为 `Math.max(conversationActivityTime(conversation), activityTimes.get(conversation.id) ?? 0)`。与活动行按 ID 合并后派生分组和最近 10 条;仅有活动、尚无摘要的行使用 Store 时间,缺失时取 0。悬停只改变 Renderer 预览范围,不发 IPC、不加载详情或请求模型。打开菜单所需的既有主机快照读取与悬停预览分开。
|
||||
- Store 的 `activityTimes` 保存本次应用会话内的访问/状态变化时间,更新时间为 `Math.max(Date.now(), lastActivityTime + 1)`。`ProjectSwitcher` 调用 `recordActivityStatuses`:首次快照只建立基线,后续状态变化触发更新,但 `completed` 变为无状态的完成提醒清除不触发更新。`WorkspaceMenu` 经 `useSyncExternalStore` 订阅该时间映射;时间不持久化,悬停不更新,普通侧栏原有消息排序规则不变。
|
||||
- 项目、会话列表均使用共享 `useListWindow`,分别估算 `64px`、`56px` 行高并测量实际高度,保留键盘目标行;范围改变时重置滚动。窗口化仅限制挂载行数,不截断活动集合,不代表已有性能基准结果。
|
||||
- 菜单通过 `FloatingPortal` 显示同一双栏表面,定位和焦点恢复共用项目按钮的引用;尺寸、窗口和滚动变化时重算位置。非模态 `dialog` 不调用焦点隔离,Escape 关闭整个菜单,Tab 可访问内部控件,焦点离开后关闭。窄窗口仍为双栏,侧栏隐藏时卸载菜单。
|
||||
- 浮层沿用共享 `browser-viewport-occlusion.ts` 相交检测,不增加浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
|
||||
- `conversationStore.rememberOpened` 在 `App.setActiveId` 时为存在的会话记录访问时间,并记住其项目最后打开的 ID。`projectConversation` 优先返回仍在候选集合中的记忆 ID,否则按 `conversationActivityTime` 选择最近会话,不使用菜单的访问/状态时间;通道项目只选带 `remote` 的会话。记忆仅存在当前 Store 的 Map 中,不持久化、不跨重载恢复。普通项目无会话时创建,通道无会话时清空活动 ID。
|
||||
- 项目进入、新建会话及创建项目后的导航均经过 `requestWorkspaceLeave`。获准后的 `commitProjectSelection` 与新建会话 `ready` 回调直接调用 `commitView('chat')`,避免经 `setView` 再次触发离开检查;新建会话就绪后才提交项目、会话及输入框焦点。
|
||||
- 持久化快照刷新时,同一条仍在流式输出的消息保留本地待审批、待回答信息,持久化终态
|
||||
则清除这些信息。响应成功后对应 Task 立即退出等待状态;响应期间到达的新问题、审批或
|
||||
终态不能被旧响应覆盖。
|
||||
@@ -503,5 +502,5 @@ OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Re
|
||||
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
|
||||
|
||||
完成通知的定向验证应覆盖本地成功、持久化活动运行成功、后台 Task 状态转换、初始历史、
|
||||
失败和取消、实时状态优先、三类计数、普通侧栏查看、设置覆盖、隐藏文档与恢复可见、无活动行隐藏。
|
||||
验证记录见[项目活动级联实施进度](./progress.md);源码核对不能替代真实 Electron 路径验证。
|
||||
失败和取消、实时状态优先、三类计数、普通侧栏查看、设置覆盖、隐藏文档与恢复可见、无活动时的空闲占位。
|
||||
统一菜单还应覆盖固定双行单按钮及焦点、当前项目默认范围和菜单内全部项目切换、分类与跨分类搜索、搜索框边界与两栏分隔、紧凑工具栏、预览无 IPC、最近 10 条与不限量活动筛选、真实设置/创建入口、会话期恢复及窗口化键盘导航。验证记录见[实施进度](./progress.md);源码核对不能替代真实 Electron 路径验证。
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
```text
|
||||
品牌
|
||||
项目选择
|
||||
项目活动汇总(仅有活动时显示)
|
||||
项目名称(固定双行控件的第一行)
|
||||
全局活动汇总/暂无活动(第二行)
|
||||
[新对话] [搜索图标]
|
||||
|
||||
当前对话
|
||||
@@ -307,27 +307,20 @@ OCR 忙碌或模型不可用时禁用加载与释放,并在执行模块再次
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
- 全局汇总按钮仅在有活动行时显示在项目选择器下方,紧凑显示待处理、运行中与已完成数量,无活动行时整行隐藏,
|
||||
右侧显示展开箭头,与下方新建会话保留 `8px` 间距。可访问名称明确数量属于“全项目活动”。
|
||||
- 项目菜单在项目行显示同口径数量,保留“本地项目 / 远程项目 / 远程通道”分组和 SSH Host
|
||||
层级,不因活动状态重排项目。数量为零时不显示标记,连接状态仍独立显示。
|
||||
项目名独占一行,状态数量排列在名称下方,避免窄侧栏中数量挤占名称宽度。
|
||||
- 点击汇总在按钮下方打开项目菜单,只列有活动会话的项目,每项显示项目名和同口径计数。
|
||||
桌面鼠标悬停或点击项目展开右侧会话菜单,右侧空间不足且左侧可容纳时向左展开。两个面板
|
||||
相接,各自拥有边框、圆角与阴影;会话层对齐所选项目行,底部空间不足时仅上移会话层,
|
||||
不改变项目层的位置或高度。指针离开项目行进入会话层时不收起。展开项目不切换当前会话。
|
||||
- 会话层顶部显示项目名,每行显示完整可换行的会话标题与明确状态文字,待处理会话排在
|
||||
运行中会话之前,已完成未查看会话排在最后并显示“已完成”。项目行保留子菜单箭头,会话行不显示箭头。不展开 Task、Job 或 Run 列表。
|
||||
- 点击会话先关闭菜单并恢复入口焦点,再请求进入该条会话;允许离开当前页面后,
|
||||
选择所属项目、清除搜索和该会话未读标记、清除任务选择与会话操作菜单,窄窗口同时关闭侧栏。
|
||||
设置页沿用未保存修改的离开检查,取消离开时保留原页面和完成通知。完成通知在目标聊天
|
||||
实际可见后清除,从普通侧栏进入也相同;设置覆盖下的聊天或隐藏文档不触发清除。
|
||||
- 菜单挂载到 body,使用 `menu`、`menuitem`、`aria-haspopup`、`aria-expanded` 与控制关系,
|
||||
不隔离背景、不增加遮罩。原生浏览器相交遮挡沿用共享机制。
|
||||
- 打开后焦点进入首个项目;上下方向键循环选择当前层项目或会话,Home、End 到首尾,
|
||||
Enter、Space 激活,右方向键进入会话层。左方向键或 Escape 返回项目层并恢复对应项目
|
||||
焦点;顶层 Escape 关闭并恢复汇总焦点,不继续关闭窄侧栏。Tab 关闭菜单后继续正常焦点
|
||||
顺序;点击外部、焦点离开、再次点击入口或隐藏侧栏也关闭菜单。
|
||||
- 单面板宽度为 `264px`,窗口小于 `620px` 或项目菜单两侧均无法容纳子菜单时,就地进入会话层并显示“返回项目列表”,
|
||||
触控无需悬停。浮层距窗口边缘至少 `16px`,每层最大高度为 `480px`,短窗口进一步限制
|
||||
高度并独立滚动。成功完成但尚未查看的会话继续保留;所有活动行移除后,关闭已打开菜单并隐藏摘要。
|
||||
左上角是固定高 `60px` 的单个按钮,第一行显示项目名称,第二行显示全局待处理、运行中、已完成数量,无活动时显示“暂无活动”。点击任一行都打开锚定整个按钮的工作空间菜单,默认预览当前项目;菜单内点击“全部项目”切换至全局范围。按钮只有一个键盘焦点、焦点环及展开状态,`aria-controls` 关联菜单,`aria-describedby` 关联活动描述和控件下方的远程恢复反馈。活动变化不改变按钮高度,也不关闭已打开的菜单;长内容单行截断。
|
||||
|
||||
左栏固定搜索、分类及底部“新建项目”,项目列表独立滚动。分类使用共享 `PageTabs` 的 `segmented` 变体,依次为“全部项目、本地、远程、消息通道”,保留 `tablist`/`tab`/`tabpanel`、游标焦点及方向键语义;远程能力关闭时隐藏远程分类。搜索跨分类匹配,SSH Host 分组可折叠,连接状态独立于会话数量。
|
||||
|
||||
左栏使用 `--surface-subtle`,与右栏之间以 `1px` 的 `--border-control` 竖线分隔。搜索框使用 `border-box`,宽度包含边框和内边距,并扣除左右外边距,避免越过分隔线。全部项目及搜索结果按实际存在的本地、远程、消息通道显示分组标题,组间用横线和留白区分;远程组内保留 Host 标题。
|
||||
|
||||
项目行显示名称、活动数量、目录三行,无活动时省略数量行,高度随内容增长;恢复状态继续显示在目录下方。当前项目以勾选和 `aria-checked` 标识,预览使用单独背景。设置按钮独立,桌面悬停或聚焦显示,窄窗口及粗指针设备可直接访问;打开既有真实项目设置,保留目录选择、Runtime、模式、删除及远程恢复等适用操作,不触发项目切换。悬停或聚焦项目只预览右栏,点击项目直接进入;通道无会话时保留空态。
|
||||
|
||||
右栏顶部用共享 `SegmentedControl` 显示“全部、待处理、运行中、已完成”,采用辅助字号和紧凑内边距,与靠右的“新建会话”同排。新建使用预览项目,全部项目范围使用当前项目;消息通道禁用并显示自动创建说明。默认会话列表按待处理、运行中、最近会话分组,最近最多 10 条,完成未查看在行内标注;“已完成”筛选不受此上限限制。会话标题单行截断,全部项目范围补充项目名,不展开 Task、Job 或 Run。无活动仍显示最近会话,无匹配会话时显示空态。
|
||||
|
||||
点击会话关闭菜单并请求确切 ID 导航;通过既有未保存离开检查后,选择所属项目、清除侧栏搜索、未读标记、任务选择和会话菜单,窄窗口关闭侧栏。取消离开保留原页面和完成通知。完成通知仅在目标聊天实际可见后清除,预览、设置覆盖或隐藏文档不触发清除。
|
||||
|
||||
菜单使用共享 `FloatingPortal`,外层为非模态 `dialog`,入口提供 `aria-haspopup="dialog"`、展开状态及控制关系,不增加遮罩或背景隔离。项目列表使用 `menu`/`menuitemradio`,独立操作使用 `menuitem`;会话列表使用 `list`/`listitem` 和按钮。原生浏览器相交遮挡沿用共享机制。
|
||||
|
||||
打开后聚焦选中分类。列表内上下方向键循环移动,Home、End 到首尾;右方向键进入会话列表,左方向键返回预览项目,项目不可见时回到选中分类。Enter、Space 激活按钮;Tab 在控件间正常移动,焦点离开后关闭。Escape 关闭整个菜单并恢复仍可用的触发入口焦点,触发入口已卸载时回到项目入口,不继续关闭窄侧栏。点击外部、再次点击入口或隐藏侧栏也关闭。
|
||||
|
||||
菜单最大宽度 `920px`,高度 `640px` 且受视口限制,距窗口边缘至少 `16px`,锚点下方间隔 `8px`。桌面左栏为 `240px` 至 `360px`,窄窗口仍保留双栏并各自滚动,不使用旧级联返回页。状态筛选可横向滚动,新建按钮保持可见。项目和会话列表均采用共享窗口化渲染,键盘可到达完整列表首尾。
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
- Given 右侧工作栏有多个工具实例
|
||||
- Then Tab 单行横向滚动且“+”持续可见,工作栏停靠回流,不遮罩主页面;布局和 Tab 交互仍由工作栏负责
|
||||
|
||||
## 6. Epic E:项目活动级联
|
||||
## 6. Epic E:统一工作空间菜单
|
||||
|
||||
### US-E1 从摘要进入确切会话
|
||||
|
||||
@@ -364,8 +364,11 @@
|
||||
查看跨项目活动,先定位项目,再进入需要处理的会话。
|
||||
|
||||
- Given 多个项目存在运行中和待处理会话,当前搜索仅匹配当前项目
|
||||
- When 打开全项目活动并悬停或点击另一个项目
|
||||
- Then 项目顺序保持稳定,该项目会话按待处理优先展示,当前页面和会话保持原选择
|
||||
- When 打开全项目活动并悬停或键盘聚焦另一个项目
|
||||
- Then 同一菜单右栏预览该项目会话,待处理、运行中优先,最近会话最多 10 条,当前页面和会话保持原选择
|
||||
- And 分类依次为全部项目、本地、远程、消息通道;远程能力关闭时隐藏远程分类及项目,搜索跨分类,远程主机可折叠
|
||||
- When 点击项目
|
||||
- Then 进入该项目,恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目没有会话时新建,消息通道保持空态
|
||||
- When 点击会话
|
||||
- Then 进入该确切 Conversation ID,清除搜索、任务选择和会话菜单,窄窗口关闭侧栏
|
||||
- Given 设置页有未保存修改
|
||||
@@ -373,20 +376,30 @@
|
||||
- Given 用户选择的确切 Conversation ID 未在内存中或已被删除
|
||||
- When 请求进入该会话
|
||||
- Then 刷新持久化会话列表后仍只查找同一 ID;刷新失败或会话不存在时显示通知并保留原页面和选择,不回退到项目最新会话或其他会话
|
||||
- Given 从项目入口打开菜单
|
||||
- Then 默认预览当前项目;点击“全部项目”恢复全局范围,“已完成”筛选列出全部完成未查看会话,不受最近 10 条限制
|
||||
- When 实际打开会话、产生新消息或活动状态变化
|
||||
- Then 菜单按最新消息与本次应用会话内访问/状态变化时间中的较晚者排序;首次状态载入和仅清除完成提醒不更新时间,悬停不改变排序
|
||||
- When 点击与状态筛选同排的“新建会话”
|
||||
- Then 在预览项目创建,全部项目范围使用当前项目;消息通道禁用手动新建并说明自动创建规则
|
||||
- When 点击项目行独立设置按钮或左栏底部“新建项目”
|
||||
- Then 打开真实设置或创建表单;设置不触发项目切换,创建成功后经离开检查进入新项目
|
||||
- And 项目进入或新建会话已获准离开后,提交聊天视图不再次触发同一离开检查
|
||||
|
||||
### US-E2 键盘、窄窗口与实时结束
|
||||
|
||||
- Given 菜单已打开
|
||||
- Then 上下方向键与 Home、End 选择当前层,右方向键或 Enter 进入会话层,左方向键或
|
||||
Escape 返回;再次 Escape 关闭活动菜单并恢复摘要焦点,窄侧栏保持打开
|
||||
- Given 窗口小于 `620px`
|
||||
- Then 点击项目后仅显示会话层,可通过返回按钮回到项目列表;长列表可滚动到最后一项
|
||||
- Given 鼠标从项目行移向相接的会话面板
|
||||
- Then 子菜单保持打开,点击会话正常导航
|
||||
- Then 初始焦点进入选中分类,上下方向键与 Home、End 移动列表焦点,右方向键进入会话列表,左方向键返回预览项目或选中分类;Enter、Space 激活按钮
|
||||
- When 按 Escape
|
||||
- Then 关闭整个工作空间菜单并恢复可用触发入口焦点,窄侧栏保持打开
|
||||
- Given 窄窗口或长列表
|
||||
- Then 两栏有清晰分隔并独立滚动,搜索框不越过左栏边界,全部项目及搜索结果按本地、远程、消息通道显示分组标题;紧凑状态筛选可横向滚动,新建按钮保持可见,触控点击项目直接进入
|
||||
- Given 当前项目已无待处理、运行中或已完成未查看会话
|
||||
- Then 返回项目层并恢复可用焦点;全局无活动时整行隐藏摘要并关闭菜单,新活动到来时只恢复摘要
|
||||
- When 点击外部、焦点离开、按 Tab 或隐藏侧栏
|
||||
- Then 显示最近会话或空态;全局无活动时第二行显示“暂无活动”,仍可打开菜单并在其中切换全部项目范围,已打开的菜单仍可使用,新活动不自动打开菜单
|
||||
- And 左上角是一个固定两行高度的按钮,项目名和全局活动共用一个键盘焦点;点击任一行都默认预览当前项目,恢复提示显示在按钮下方并关联可访问描述
|
||||
- When 点击外部、焦点离开或隐藏侧栏
|
||||
- Then 菜单关闭,后台新活动不自动重新展开
|
||||
- And Tab 可在菜单内访问搜索、分类、项目设置、创建及会话按钮,焦点离开后关闭
|
||||
|
||||
### US-E3 完成后保留至查看
|
||||
|
||||
@@ -396,7 +409,7 @@
|
||||
- When 本次应用会话内的运行成功完成,或已观察到的后台任务转为完成
|
||||
- Then 活动会话保留为“已完成”,全局与项目的已完成计数同步更新
|
||||
- When 从活动菜单或普通侧栏实际进入该会话的可见聊天
|
||||
- Then 清除其完成通知;没有其他活动行时隐藏摘要并关闭菜单
|
||||
- Then 清除其完成通知;没有其他活动行时第二行恢复“暂无活动”,已查看会话仍可出现在最近会话中
|
||||
- Given 聊天被设置覆盖或文档隐藏
|
||||
- When 对应运行完成
|
||||
- Then 完成通知保留,直到设置关闭且目标聊天在可见文档中显示;取消设置离开不清除通知
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-canvas: #e8f0f5; --surface-raised: #fff; --surface-subtle: #f4f8fb;
|
||||
--text-primary: #17263a; --text-secondary: #4f6278; --border-default: #cedce7;
|
||||
--accent: #086fc7; --accent-subtle: #e8f4ff; --accent-selected: #a7cfea;
|
||||
--success: #147566; --success-subtle: #e5f4ee; --warning: #865200;
|
||||
--radius-control: 10px; --radius-card: 14px;
|
||||
--shadow: 0 24px 80px #17263a26;
|
||||
font-family: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif;
|
||||
}
|
||||
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --accent-selected: #28668f; --success: #72d8be; --success-subtle: #123d37; --warning: #f2bd66; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px 32px; background: var(--surface-subtle); color: var(--text-primary); font-size: 13px; line-height: 1.6; }
|
||||
button, textarea, input { font: inherit; color: inherit; }
|
||||
button { border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; padding: 8px 12px; cursor: pointer; }
|
||||
button:hover { background: var(--accent-subtle); }
|
||||
button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
h1, h2, h3, p { margin: 0; } .muted, .eyebrow, .section-label { color: var(--text-secondary); }
|
||||
.eyebrow, .section-label { font-size: 11px; letter-spacing: 1px; }
|
||||
.lab, .lab-foot { max-width: 1440px; margin: auto; display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.lab { margin-bottom: 24px; }.lab strong { font-size: 15px; }.lab span { margin-left: 24px; color: var(--text-secondary); }.tools { display: flex; gap: 4px; }.tools button { border-color: var(--border-default); }
|
||||
.shell { max-width: 1440px; margin: auto; height: calc(100dvh - 160px); min-height: 580px; display: grid; grid-template-columns: 256px minmax(0, 1fr); border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; background: var(--surface-raised); }
|
||||
.sidebar { background: var(--surface-canvas); padding: 24px 12px 12px; display: flex; flex-direction: column; border-right: 1px solid var(--border-default); }.brand { display: flex; align-items: center; gap: 8px; margin: 0 8px 32px; }.brand .muted { font-size: 11px; margin-left: auto; }.logo { background: var(--text-primary); color: var(--surface-raised); border-radius: var(--radius-control); padding: 4px 10px; }
|
||||
.workspace-control { background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; }#workspace-trigger { position: relative; display: grid; text-align: left; width: 100%; padding: 12px; }#project-name { font-size: 16px; }.expand { position: absolute; right: 12px; top: 20px; font-size: 22px; color: var(--accent); }#activity-trigger { border-top: 1px solid var(--border-default); border-radius: 0; width: 100%; font-size: 10px; padding: 8px; display: flex; gap: 4px; justify-content: space-between; }
|
||||
.new { margin: 16px 0 24px; text-align: left; border-color: var(--border-default); }.section-label { margin: 0 12px 8px; }#sessions { overflow: auto; }#sessions button { width: 100%; text-align: left; display: flex; gap: 8px; align-items: center; }#sessions button span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }[aria-current="true"], button[aria-pressed="true"] { background: var(--accent-subtle); border-color: var(--accent-selected); color: var(--accent); }.sidebar-bottom { margin-top: auto; padding-top: 24px; }.sidebar-bottom > span { display: block; margin: 8px 12px; font-size: 11px; }.sidebar-bottom button { width: 100%; text-align: left; }kbd { font: 10px/1.5 inherit; border: 1px solid var(--border-default); border-radius: 4px; padding: 2px 4px; color: var(--text-secondary); margin-left: 8px; }
|
||||
.workspace { display: flex; flex-direction: column; min-width: 0; overflow: hidden; }.titlebar { padding: 16px 24px; border-bottom: 1px solid var(--border-default); background: var(--surface-subtle); display: flex; justify-content: space-between; gap: 12px; }#conversation { flex: 1; overflow: auto; padding: 48px max(32px, calc((100% - 740px) / 2)); }#conversation h1 { font-size: 28px; font-weight: 550; letter-spacing: -1px; margin: 12px 0 32px; }.user-message { margin: 0 0 32px 48px; padding: 16px 24px; background: var(--surface-subtle); border-radius: var(--radius-card); }.assistant-label { color: var(--accent); font-size: 12px; margin-bottom: 12px; }.answer { font-size: 15px; line-height: 1.9; white-space: pre-wrap; }.execution { margin-top: 24px; padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }.execution p { margin: 8px 0 16px; color: var(--text-secondary); }.primary { background: var(--accent); color: var(--surface-raised); }.primary:hover { background: var(--text-primary); color: var(--surface-raised); }#composer { margin: 16px 32px 24px; border: 1px solid var(--border-default); border-radius: var(--radius-card); padding: 12px 16px; }textarea { display: block; width: 100%; resize: vertical; border: 0; background: transparent; padding: 8px 0; }#composer > div { display: flex; align-items: center; justify-content: space-between; }.lab-foot { margin-top: 16px; color: var(--text-secondary); font-size: 11px; }
|
||||
.waiting { color: var(--warning); }.running { color: var(--accent); }.done { color: var(--success); }.idle { color: var(--text-secondary); }.empty { text-align: center; padding: 48px 16px; color: var(--text-secondary); }#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 12px 24px; border: 1px solid var(--accent-selected); background: var(--surface-raised); color: var(--text-primary); border-radius: var(--radius-card); box-shadow: var(--shadow); z-index: 5; }
|
||||
@media (max-width: 800px) { body { padding: 12px; }.lab { flex-wrap: wrap; margin-bottom: 12px; }.lab span { display: none; }.shell { grid-template-columns: 208px minmax(0, 1fr); }.brand .muted { display: none; }#conversation { padding: 24px; }.lab-foot span:last-child { display: none; } }
|
||||
@media (max-width: 560px) { .shell { display: flex; flex-direction: column; height: auto; min-height: calc(100dvh - 150px); }.sidebar { border-right: 0; border-bottom: 1px solid var(--border-default); padding: 12px; }.brand, .new, .section-label, #sessions, .sidebar-bottom { display: none; }.workspace-control { display: flex; }#workspace-trigger { width: 48%; }#activity-trigger { width: 52%; flex-wrap: wrap; align-content: center; border-top: 0; border-left: 1px solid var(--border-default); }.workspace { min-height: 540px; }.titlebar { padding: 12px; }.titlebar > .muted { display: none; }#conversation { padding: 24px 16px; }#composer { margin: 12px; }.user-message { margin-left: 16px; } }
|
||||
#overview { position: fixed; inset: auto; margin: 0; padding: 0; width: min(920px, calc(100vw - 16px)); height: 640px; border: 1px solid var(--border-default); border-radius: var(--radius-card); box-shadow: var(--shadow); background: var(--surface-raised); color: var(--text-primary); overflow: hidden; }
|
||||
#overview:popover-open { display: flex; flex-direction: column; }
|
||||
#activity-trigger[hidden] { display: none; }
|
||||
.menu-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; border-bottom: 1px solid var(--border-default); flex-shrink: 0; }.menu-header button { font-size: 11px; }
|
||||
.menu-layout { display: grid; grid-template-columns: 360px minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||
.project-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; background: var(--surface-subtle); border-right: 1px solid var(--border-default); }
|
||||
#project-search { margin: 12px 8px 8px; width: calc(100% - 16px); min-width: 0; border: 1px solid var(--border-default); background: var(--surface-raised); border-radius: 6px; padding: 8px; font-size: 12px; }
|
||||
#all-projects { text-align: left; margin: 0 8px 8px; padding: 8px; }
|
||||
#project-tabs { display: flex; gap: 4px; padding: 0 8px 8px; border-bottom: 1px solid var(--border-default); flex-shrink: 0; }#project-tabs[hidden] { display: none; }#project-tabs button { flex: 1; white-space: nowrap; font-size: 11px; padding: 6px 4px; }#project-tabs [aria-selected="true"] { color: var(--accent); background: var(--accent-subtle); border-color: var(--accent-selected); }
|
||||
#desktops { display: block; padding: 8px; overflow-y: auto; flex: 1; min-height: 0; overscroll-behavior: contain; }
|
||||
.search-result > small { display: block; padding: 0 8px 8px 32px; font-size: 10px; color: var(--text-secondary); }.search-result .desktop { margin-bottom: 0; }
|
||||
.host-heading { cursor: pointer; }.host-heading > span:first-child::before { content: '▸'; margin-right: 4px; }details[open] > .host-heading > span:first-child::before { content: '▾'; }
|
||||
.project-row { position: relative; margin-bottom: 4px; }
|
||||
.project-row .desktop { display: grid; grid-template-columns: 16px minmax(0, 1fr) 14px; min-height: 52px; padding: 8px 44px 8px 8px; gap: 8px; margin: 0; }
|
||||
.project-copy { min-width: 0; display: grid; gap: 2px; }.project-heading { display: flex; align-items: center; gap: 8px; min-width: 0; }.project-heading strong { font-size: 13px; font-weight: 650; }.project-path { display: block; font-size: 11px; line-height: 16px; color: var(--text-secondary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }.project-row .project-counts { width: auto; padding: 0; flex-wrap: nowrap; }.current-project { color: var(--accent); font-size: 13px; }
|
||||
.project-settings-button { position: absolute; top: 50%; right: 4px; transform: translateY(-50%); width: 32px; height: 32px; padding: 8px; opacity: 0; pointer-events: none; color: var(--text-secondary); }.project-row:hover .project-settings-button, .project-row:focus-within .project-settings-button { opacity: 1; pointer-events: auto; }
|
||||
.project-copy > .project-counts { font-size: 11px; line-height: 16px; flex-wrap: wrap; gap: 2px 8px; }.project-copy > .project-counts > span { white-space: nowrap; }
|
||||
@media (hover: none) { .project-settings-button { opacity: 1; pointer-events: auto; } }
|
||||
#project-settings { width: min(480px, calc(100vw - 24px)); max-height: calc(100dvh - 32px); overflow-y: auto; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); color: var(--text-primary); padding: 24px; box-shadow: var(--shadow); }#project-settings::backdrop { background: color-mix(in srgb, var(--text-primary) 25%, transparent); }
|
||||
#settings-form { display: grid; gap: 16px; }#settings-form header, #settings-form footer { display: flex; align-items: center; gap: 8px; }#settings-form header strong, #settings-form footer small { flex: 1; }#settings-form label { display: grid; gap: 4px; font-size: 12px; }#settings-form input, #settings-form select, #settings-form textarea { width: 100%; min-width: 0; border: 1px solid var(--border-default); border-radius: 6px; background: var(--surface-subtle); color: var(--text-primary); padding: 8px; font: inherit; }
|
||||
.desktop { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px; margin-bottom: 4px; text-align: left; border-color: transparent; background: transparent; }.desktop strong { display: block; margin: 0; min-width: 0; flex: 1; font-size: 12px; font-weight: 500; }.project-counts { display: flex; gap: 8px; font-size: 10px; color: var(--text-secondary); flex-shrink: 0; }
|
||||
#desktops svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
|
||||
.project-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-default); }.project-section h2 { display: flex; align-items: center; gap: 8px; margin: 0 8px 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); }.host-group + .host-group { margin-top: 12px; }.host-heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 4px 8px; font-size: 11px; }.host-status { border: 1px solid var(--border-default); border-radius: 4px; padding: 0 4px; font-size: 10px; }.host-group .desktop { padding-left: 16px; }
|
||||
@media (min-width: 561px) and (max-width: 800px) { .menu-layout { grid-template-columns: 300px minmax(0, 1fr); } }
|
||||
.activity-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
#menu-new-chat { color: var(--accent); font-size: 11px; padding: 4px 8px; white-space: nowrap; flex-shrink: 0; }
|
||||
#project-form:not([hidden]) { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px; border-top: 1px solid var(--border-default); flex-shrink: 0; }#project-form input { min-width: 80px; width: 120px; flex: 1; background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: 6px; padding: 8px; }.desktop strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.overview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-top: 0; border-bottom: 1px solid var(--border-default); }
|
||||
#filters { display: flex; gap: 4px; overflow-x: auto; min-width: 0; }#filters button { font-size: 11px; padding: 4px 8px; white-space: nowrap; flex-shrink: 0; }
|
||||
#windows { overflow-y: auto; padding: 8px; flex: 1; min-height: 0; overscroll-behavior: contain; }
|
||||
.window-group { margin: 0 0 16px; }.group-title { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 500; margin: 8px; }
|
||||
.session-row { display: flex; align-items: center; gap: 12px; text-align: left; width: 100%; padding: 12px 8px; border-radius: 6px; }.session-copy { flex: 1; min-width: 0; }.session-copy strong { display: block; font-weight: 500; }.session-copy small { display: block; font-size: 11px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.row-action { font-size: 10px; flex-shrink: 0; }
|
||||
.overview-footer { padding: 8px 16px; background: var(--surface-subtle); border-top: 1px solid var(--border-default); min-height: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--text-secondary); font-size: 11px; }
|
||||
@media (max-width: 560px) {
|
||||
.project-row .desktop { grid-template-columns: minmax(0, 1fr) 12px; padding: 8px 36px 8px 4px; gap: 4px; }.project-row .desktop > svg { display: none; }.project-heading { flex-wrap: wrap; gap: 0; }.project-heading strong { flex-basis: 100%; }.project-settings-button { right: 0; }.project-row .project-counts { font-size: 9px; }.project-row .project-path { font-size: 10px; }
|
||||
#project-tabs { overflow-x: auto; gap: 0; padding-inline: 4px; }#project-tabs button { flex: 0 0 auto; }#project-search { font-size: 11px; padding: 6px; }.search-result > small { padding-left: 8px; }
|
||||
.desktop { flex-wrap: wrap; gap: 4px; }.desktop strong { flex-basis: calc(100% - 24px); }.project-counts { width: 100%; padding-left: 19px; }.host-heading { flex-wrap: wrap; gap: 4px; }.host-group .desktop { padding-left: 8px; }
|
||||
.menu-layout { grid-template-columns: 176px minmax(0, 1fr); }#desktops { padding: 4px; }.desktop { padding: 8px; }.desktop strong { font-size: 12px; }.project-counts { gap: 4px; flex-wrap: wrap; }.overview-toolbar { padding: 8px; }#filters { gap: 0; }#filters button { padding: 4px; }.session-row { align-items: flex-start; gap: 4px; flex-direction: column; }.session-copy { width: 100%; }.overview-footer { padding: 8px; gap: 4px; }.overview-footer span { max-width: 55%; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoodBuddy · 工作空间实验</title>
|
||||
<link rel="stylesheet" href="workspace-demo.css">
|
||||
<script src="workspace-demo.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="lab"><strong>GoodBuddy / 工作空间实验</strong><div class="tools"><button id="simulate">模拟后台完成</button><button id="theme">切换深色</button><button id="reset">重置</button></div></header>
|
||||
<main class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><b class="logo">G</b><strong>GoodBuddy</strong><span class="muted">个人空间</span></div>
|
||||
<div class="workspace-control"><button id="workspace-trigger" aria-haspopup="dialog" aria-controls="overview" aria-expanded="false"><span class="eyebrow">工作空间</span><strong id="project-name"></strong><span class="expand">▾</span></button><button id="activity-trigger" aria-haspopup="dialog" aria-controls="overview" aria-expanded="false" aria-label="查看全部项目活动"></button></div>
|
||||
<button class="new" id="new-chat">+ 新会话</button>
|
||||
<div class="section-label">会话</div><nav id="sessions" aria-label="会话窗口"></nav>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<header class="titlebar"><span id="breadcrumb"></span><span class="muted">会话窗口</span></header>
|
||||
<article id="conversation" aria-live="polite"></article>
|
||||
<form id="composer"><label for="message" class="eyebrow">继续这项工作</label><textarea id="message" placeholder="输入消息,继续与 GoodBuddy 协作…" rows="2" required></textarea><div><span class="muted">OpenCode · Execute</span><button class="primary" type="submit">发送 ↑</button></div></form>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="lab-foot"><span>Demo · 模拟数据</span></footer>
|
||||
<section id="overview" popover="auto" role="dialog" aria-labelledby="overview-title">
|
||||
<header class="menu-header"><strong id="overview-title">项目与活动</strong><button id="close-overview" aria-label="关闭项目与活动菜单">×</button></header>
|
||||
<div class="menu-layout">
|
||||
<aside class="project-pane">
|
||||
<input id="project-search" type="search" aria-label="搜索项目" placeholder="搜索项目…" autocomplete="off">
|
||||
<button id="all-projects" data-scope="all" aria-pressed="false">全部项目</button>
|
||||
<div id="project-tabs" role="tablist" aria-label="项目类型"></div>
|
||||
<nav id="desktops" role="tabpanel" aria-label="项目列表"></nav>
|
||||
</aside>
|
||||
<section class="activity-pane">
|
||||
<div class="overview-toolbar"><div id="filters" role="group" aria-label="筛选会话状态"></div><button id="menu-new-chat">+ 新建会话</button></div>
|
||||
<div id="windows"></div>
|
||||
</section>
|
||||
</div>
|
||||
<form id="project-form" hidden><label for="project-input">项目名称</label><input id="project-input" maxlength="40" required autocomplete="off"><button type="submit" class="primary">创建</button><button type="button" id="cancel-project">取消</button></form>
|
||||
<footer class="overview-footer"><button id="new-project" aria-expanded="false" aria-controls="project-form">+ 新建项目</button></footer>
|
||||
</section>
|
||||
<dialog id="project-settings" aria-labelledby="settings-title">
|
||||
<form id="settings-form">
|
||||
<header><strong id="settings-title">项目设置</strong><button type="button" id="close-settings" aria-label="关闭项目设置">×</button></header>
|
||||
<label>名称<input id="settings-name" maxlength="120" required></label>
|
||||
<label>说明<textarea id="settings-description" rows="2"></textarea></label>
|
||||
<label>执行空间<input id="settings-space" readonly></label>
|
||||
<label>工作目录<input id="settings-path" required></label>
|
||||
<label>默认模式<select id="settings-mode"><option value="ask">Ask · 只读问答</option><option value="execute">Execute · 完全权限</option></select></label>
|
||||
<label>新对话默认 Runtime<select id="settings-runtime"><option>OpenCode</option><option>Continue</option><option>直连模型</option></select></label>
|
||||
<footer><small class="muted">仅修改 Demo 数据</small><button type="button" id="cancel-settings">取消</button><button type="submit" class="primary">保存</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,355 @@
|
||||
const projects = [
|
||||
{ id: 'buddy', name: 'GoodBuddy', kind: 'local', location: '本地工作空间' },
|
||||
{ id: 'web', name: '品牌官网', kind: 'local', location: '本地工作空间' },
|
||||
{ id: 'data', name: '数据分析', kind: 'remote', host: 'dev-server', location: '远程 · dev-server' },
|
||||
{ id: 'api', name: '后端服务', kind: 'remote', host: 'dev-server', location: '远程 · dev-server' },
|
||||
{ id: 'lab', name: '模型实验', kind: 'remote', host: 'gpu-lab', location: '远程 · gpu-lab' },
|
||||
{ id: 'channel', name: '产品讨论群', kind: 'channel', location: '消息通道' }
|
||||
];
|
||||
const hosts = [{ name: 'dev-server', status: '就绪' }, { name: 'gpu-lab', status: '未连接' }];
|
||||
for (let index = 3; index <= 20; index++) {
|
||||
projects.push({ id: `local-${index}`, name: `本地项目 ${String(index).padStart(2, '0')}`, kind: 'local', location: '本地工作空间' });
|
||||
}
|
||||
for (let index = 4; index <= 20; index++) {
|
||||
const host = index % 2 ? 'gpu-lab' : 'dev-server';
|
||||
projects.push({ id: `remote-${index}`, name: `远程项目 ${String(index).padStart(2, '0')}`, kind: 'remote', host, location: `远程 · ${host}` });
|
||||
}
|
||||
projects.push(
|
||||
{ id: 'channel-2', name: '研发协作群', kind: 'channel', location: '消息通道' },
|
||||
{ id: 'channel-3', name: '运维通知群', kind: 'channel', location: '消息通道' }
|
||||
);
|
||||
const icons = {
|
||||
all: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||||
local: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>',
|
||||
remote: '<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6h.01M7 17h.01"/>',
|
||||
channel: '<path d="M21 11a8 8 0 0 1-8 8H6l-4 3V11a8 8 0 0 1 8-8h3a8 8 0 0 1 8 8Z"/><path d="M7 9h9M7 13h6"/>',
|
||||
folder: '<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>'
|
||||
};
|
||||
const icon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${icons[name]}</svg>`;
|
||||
for (const project of projects) {
|
||||
project.rootPath = project.kind === 'remote' ? `/srv/projects/${project.id}` : `D:/workspace/${project.id}`;
|
||||
project.description = '';
|
||||
project.defaultMode = 'ask';
|
||||
project.runtime = 'OpenCode';
|
||||
}
|
||||
const initialProjects = structuredClone(projects);
|
||||
const initial = [
|
||||
{ id: 1, project: 'buddy', title: '工作空间交互设计', state: 'idle', detail: '设计讨论已保存,随时可以继续。', prompt: '让项目与活动会话的关系更直观,做出操作系统的感觉。', answer: '左上角就是工作入口。\n\n展开项目与活动菜单,可以查看多个项目正在进行的会话,也可以直接处理等待你的工作。选择项目只筛选列表,进入会话才切换工作现场。\n\n所有状态都在这个下拉菜单中,不需要再打开独立页面。' },
|
||||
{ id: 2, project: 'buddy', title: '登录流程调整', state: 'waiting', detail: '需要你选择:使用单点登录还是邮箱登录?', prompt: '帮我完善内部用户的登录流程。', answer: '已梳理现有认证入口。需要确定首选登录方式,然后继续调整界面。', question: true },
|
||||
{ id: 3, project: 'buddy', title: '导航组件测试', state: 'running', detail: '正在检查键盘导航与焦点恢复。', prompt: '检查导航组件的交互和测试覆盖。', answer: '已经完成组件检查,正在验证键盘导航与焦点恢复。' },
|
||||
{ id: 4, project: 'web', title: '发布新版首页', state: 'waiting', detail: '预览构建已就绪,等待你批准部署。', prompt: '构建新版首页,发布前让我确认。', answer: '预览构建已完成,页面检查通过。请确认是否继续部署。' },
|
||||
{ id: 5, project: 'web', title: '首页文案精修', state: 'done', detail: '完成 3 处文案调整,结果等待查看。', prompt: '把首页介绍改得更简洁、自然。', answer: '首页文案调整已完成。\n\n首屏标题:把想法,变成已完成的工作。\n产品介绍:查资料、写方案、改代码。在一个工作空间里,与 AI 一起推进。\n行动按钮:开始你的第一项工作。' },
|
||||
{ id: 6, project: 'data', title: '本周使用情况分析', state: 'running', detail: '正在汇总各项目的使用趋势。', prompt: '整理本周各项目的使用情况。', answer: '已完成示例数据整理,正在汇总各项目的使用趋势。' },
|
||||
{ id: 7, project: 'api', title: '接口变更记录', state: 'idle', detail: '昨天的会话', prompt: '整理本次接口变更。', answer: '接口变更记录已整理。' },
|
||||
{ id: 8, project: 'lab', title: '推理参数对比', state: 'idle', detail: '上次会话', prompt: '对比两组推理参数。', answer: '上次对比结果已保存。' },
|
||||
{ id: 9, project: 'channel', title: '版本发布讨论', state: 'idle', detail: '今天的会话', prompt: '整理群里的版本讨论。', answer: '本轮发布讨论已整理。' }
|
||||
];
|
||||
for (const project of projects) {
|
||||
if (!initial.some((session) => session.project === project.id)) {
|
||||
initial.push({ id: initial.length + 1, project: project.id, title: '最近工作记录', state: 'idle', detail: '上次会话', prompt: '整理当前工作。', answer: '工作记录已保存。' });
|
||||
}
|
||||
}
|
||||
const labels = { waiting: '待处理', running: '运行中', done: '已完成' };
|
||||
const symbols = { waiting: '!', running: '◌', done: '✓' };
|
||||
const statusLabel = (session) => session.state === 'waiting'
|
||||
? session.question ? '等待你的回答' : '等待审批'
|
||||
: labels[session.state] || '';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
let sessions, current, scope = 'all', filter = 'all', projectTab = 'local', toastTimer, menuTrigger, activityOrder, settingsProjectId;
|
||||
const remembered = {};
|
||||
const drafts = {};
|
||||
const overview = $('overview');
|
||||
const projectOf = (session) => projects.find((p) => p.id === session.project);
|
||||
const badge = (state) => `<span class="${state}">${symbols[state]} ${labels[state]}</span>`;
|
||||
function notify(text) {
|
||||
$('toast').textContent = text;
|
||||
$('toast').hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { $('toast').hidden = true; }, 3500);
|
||||
}
|
||||
function render() {
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
const project = projectOf(session);
|
||||
$('project-name').textContent = project.name;
|
||||
$('breadcrumb').textContent = `${project.name} / ${session.title}`;
|
||||
$('activity-trigger').innerHTML = '<span class="muted">全部</span>' + ['waiting', 'running', 'done'].filter((state) => sessions.some((s) => s.state === state)).map((state) => `<span class="${state}">${symbols[state]} ${sessions.filter((s) => s.state === state).length} ${labels[state]}</span>`).join('');
|
||||
$('activity-trigger').hidden = !sessions.some((s) => labels[s.state]);
|
||||
$('sessions').innerHTML = sessions.filter((s) => s.project === session.project).map((s) => `<button data-open="${s.id}" aria-current="${s.id === current}">${statusLabel(s) ? `<span class="${s.state}" aria-label="${statusLabel(s)}">${symbols[s.state]}</span>` : ''}<span>${escapeHtml(s.title)}</span></button>`).join('');
|
||||
$('conversation').innerHTML = `<span class="eyebrow">${escapeHtml(project.location)}${statusLabel(session) ? ` / ${statusLabel(session)}` : ''}</span><h1>${escapeHtml(session.title)}</h1><div class="user-message">${escapeHtml(session.prompt)}</div><div class="assistant-label">G / GoodBuddy</div><div class="answer">${escapeHtml(session.answer)}</div>${session.state === 'waiting' ? `<section class="execution"><span class="waiting">! ${statusLabel(session)}</span><p>${escapeHtml(session.detail)}</p>${session.question ? '<button class="primary" data-resolve="单点登录">使用单点登录</button> <button data-resolve="邮箱登录">使用邮箱登录</button>' : '<button class="primary" data-resolve="批准部署">批准并继续</button> <button data-resolve="暂不部署">暂不部署</button>'}</section>` : session.state === 'running' ? `<section class="execution">${badge('running')}<p>${escapeHtml(session.detail)} 切换到其他工作空间后仍会继续。</p></section>` : ''}`;
|
||||
$('simulate').disabled = !sessions.some((s) => s.state === 'running');
|
||||
renderOverview();
|
||||
}
|
||||
function renderOverview() {
|
||||
const focused = document.activeElement;
|
||||
const focusScope = focused?.dataset.scope;
|
||||
const focusFilter = focused?.dataset.filter;
|
||||
const projectButton = (p) => {
|
||||
const list = sessions.filter((s) => p.id === 'all' || s.project === p.id);
|
||||
const counts = ['waiting', 'running', 'done'].filter((state) => list.some((s) => s.state === state)).map((state) => `<span class="${state}">${symbols[state]} ${list.filter((s) => s.state === state).length} 个${labels[state]}</span>`).join(' ');
|
||||
const active = sessions.find((s) => s.id === current).project === p.id;
|
||||
const detail = p.kind === 'local' ? `本地目录 · ${p.rootPath}` : p.kind === 'channel' ? `消息通道 · ${p.rootPath}` : p.rootPath;
|
||||
return `<div class="project-row"><button class="desktop" data-scope="${p.id}" title="${escapeHtml(p.name)}\n${escapeHtml(detail)}">${icon(p.kind === 'channel' ? 'channel' : 'folder')}<span class="project-copy"><span class="project-heading"><strong>${escapeHtml(p.name)}</strong></span>${counts ? `<span class="project-counts">${counts}</span>` : ''}<small class="project-path">${escapeHtml(detail)}</small></span><span class="current-project" ${active ? 'aria-label="当前项目"' : 'aria-hidden="true"'}>${active ? '✓' : ''}</span></button><button class="project-settings-button" data-settings="${p.id}" aria-label="管理项目 ${escapeHtml(p.name)}" title="项目设置"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 3-.7 3-2 .9-2.7-.8-2 3.4 2 2.2v2.6l-2 2.2 2 3.4 2.7-.8 2 .9L9 23h4l.7-3 2-.9 2.7.8 2-3.4-2-2.2v-2.6l2-2.2-2-3.4-2.7.8-2-.9L13 3Z" transform="translate(1 -1) scale(.95)"/><circle cx="12" cy="12" r="3"/></svg></button></div>`;
|
||||
};
|
||||
const query = $('project-search').value.trim().toLocaleLowerCase();
|
||||
const kinds = { local: '本地', remote: '远程', channel: '消息通道' };
|
||||
if (!$('project-tabs').children.length) {
|
||||
$('project-tabs').innerHTML = Object.entries(kinds).map(([kind, label]) => `<button id="tab-${kind}" role="tab" data-tab="${kind}" aria-controls="desktops">${label} <small></small></button>`).join('');
|
||||
}
|
||||
$('project-tabs').hidden = Boolean(query);
|
||||
$('project-tabs').querySelectorAll('[data-tab]').forEach((button) => {
|
||||
const selected = button.dataset.tab === projectTab;
|
||||
button.setAttribute('aria-selected', String(selected));
|
||||
button.tabIndex = selected ? 0 : -1;
|
||||
button.querySelector('small').textContent = projects.filter((p) => p.kind === button.dataset.tab).length;
|
||||
});
|
||||
$('desktops').setAttribute('aria-label', query ? '搜索结果' : kinds[projectTab]);
|
||||
$('all-projects').setAttribute('aria-pressed', String(scope === 'all'));
|
||||
const shown = projects.filter((p) => query
|
||||
? `${p.name} ${p.rootPath} ${p.host || ''} ${kinds[p.kind]}`.toLocaleLowerCase().includes(query)
|
||||
: p.kind === projectTab);
|
||||
const projectMarkup = query
|
||||
? shown.map((p) => `<div class="search-result">${projectButton(p)}<small>${kinds[p.kind]}${p.host ? ` · ${escapeHtml(p.host)}` : ''}</small></div>`).join('')
|
||||
: projectTab === 'remote'
|
||||
? hosts.map((host) => `<section class="host-group" aria-label="${host.name}"><details open><summary class="host-heading"><span>${host.name}</span><span class="host-status ${host.status === '就绪' ? 'done' : 'muted'}">${host.status}</span></summary>${shown.filter((p) => p.host === host.name).map(projectButton).join('')}</details></section>`).join('')
|
||||
: shown.map(projectButton).join('');
|
||||
// Keep hovered project nodes stable while the right-hand list changes.
|
||||
if ($('desktops').dataset.markup !== projectMarkup) {
|
||||
$('desktops').innerHTML = projectMarkup || '<div class="empty">无匹配项目</div>';
|
||||
$('desktops').dataset.markup = projectMarkup;
|
||||
}
|
||||
$('desktops').querySelectorAll('[data-scope]').forEach((button) => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.scope === scope));
|
||||
});
|
||||
const list = sessions.filter((s) => scope === 'all' || s.project === scope);
|
||||
$('filters').innerHTML = ['all', 'waiting', 'running', 'done'].map((state) => `<button data-filter="${state}" aria-pressed="${filter === state}">${state === 'all' ? '全部' : labels[state]} <small>${list.filter((s) => state === 'all' || s.state === state).length}</small></button>`).join('');
|
||||
const targetProject = projects.find((p) => p.id === (scope === 'all' ? sessions.find((s) => s.id === current).project : scope));
|
||||
$('menu-new-chat').title = `在 ${targetProject.name} 中新建会话`;
|
||||
$('menu-new-chat').setAttribute('aria-label', $('menu-new-chat').title);
|
||||
const groups = filter === 'all' ? ['waiting', 'running', 'recent'] : [filter];
|
||||
$('windows').innerHTML = groups.map((state) => {
|
||||
const sorted = list.filter((s) => state === 'recent' ? s.state === 'done' || s.state === 'idle' : s.state === state)
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||
const group = state === 'recent' ? sorted.slice(0, 5) : sorted;
|
||||
if (!group.length) return '';
|
||||
return `<section class="window-group"><h2 class="group-title">${state === 'recent' ? '最近会话' : badge(state)} <span class="muted">${group.length}</span></h2>${group.map((s) => `<button class="session-row" data-open="${s.id}" title="${escapeHtml(s.detail)}"><span class="session-copy"><strong>${escapeHtml(s.title)}</strong>${scope === 'all' ? `<small>${escapeHtml(projectOf(s).name)}</small>` : ''}</span><span class="row-action ${s.state}">${s.state === 'done' ? '✓ 已完成' : s.state === 'waiting' ? statusLabel(s) : s.id === current ? '当前' : '↗'}</span></button>`).join('')}</section>`;
|
||||
}).join('') || '<div class="empty">暂无会话</div>';
|
||||
if (focusScope) overview.querySelector(`[data-scope="${focusScope}"]`)?.focus();
|
||||
if (focusFilter) overview.querySelector(`[data-filter="${focusFilter}"]`)?.focus();
|
||||
}
|
||||
function openSession(id) {
|
||||
const session = sessions.find((s) => s.id === id);
|
||||
drafts[current] = $('message').value;
|
||||
current = id;
|
||||
session.lastActivity = ++activityOrder;
|
||||
remembered[session.project] = id;
|
||||
if (session.state === 'done') { session.state = 'idle'; session.detail = '结果已查看,可以继续这项工作。'; }
|
||||
closeMenu();
|
||||
$('message').value = drafts[id] || '';
|
||||
render();
|
||||
$('conversation').scrollTop = 0;
|
||||
}
|
||||
function positionMenu() {
|
||||
const anchor = document.querySelector('.workspace-control').getBoundingClientRect();
|
||||
const top = Math.max(8, Math.min(anchor.bottom + 8, window.innerHeight - 240));
|
||||
overview.style.left = `${Math.max(8, Math.min(anchor.left, window.innerWidth - overview.offsetWidth - 8))}px`;
|
||||
overview.style.top = `${top}px`;
|
||||
overview.style.maxHeight = `${window.innerHeight - top - 8}px`;
|
||||
}
|
||||
function closeMenu() {
|
||||
if (overview.matches(':popover-open')) overview.hidePopover();
|
||||
}
|
||||
function showOverview(project = 'all', trigger = $('workspace-trigger')) {
|
||||
if (overview.matches(':popover-open')) { closeMenu(); return; }
|
||||
menuTrigger = trigger;
|
||||
scope = project;
|
||||
projectTab = projects.find((p) => p.id === (project === 'all' ? sessions.find((s) => s.id === current).project : project)).kind;
|
||||
$('project-search').value = '';
|
||||
filter = 'all';
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
renderOverview();
|
||||
overview.showPopover();
|
||||
positionMenu();
|
||||
overview.querySelector('[data-scope][aria-pressed="true"]').focus();
|
||||
}
|
||||
document.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button');
|
||||
if (!button) return;
|
||||
if (button.dataset.settings) {
|
||||
settingsProjectId = button.dataset.settings;
|
||||
const project = projects.find((p) => p.id === settingsProjectId);
|
||||
$('settings-name').value = project.name;
|
||||
$('settings-name').readOnly = project.kind === 'channel';
|
||||
$('settings-description').value = project.description || '';
|
||||
$('settings-space').value = project.location;
|
||||
$('settings-path').value = project.rootPath;
|
||||
$('settings-mode').value = project.defaultMode || 'ask';
|
||||
$('settings-runtime').value = project.runtime || 'OpenCode';
|
||||
$('settings-runtime').querySelector('option:last-child').disabled = project.kind === 'remote';
|
||||
closeMenu();
|
||||
$('project-settings').showModal();
|
||||
}
|
||||
if (button.dataset.tab) {
|
||||
projectTab = button.dataset.tab;
|
||||
renderOverview();
|
||||
$('desktops').scrollTop = 0;
|
||||
}
|
||||
if (button.dataset.open) openSession(Number(button.dataset.open));
|
||||
if (button.dataset.scope === 'all') { scope = 'all'; renderOverview(); }
|
||||
else if (button.dataset.scope) {
|
||||
const project = button.dataset.scope;
|
||||
const session = sessions.find((s) => s.id === remembered[project]) ||
|
||||
sessions.filter((s) => s.project === project).sort((a, b) => b.lastActivity - a.lastActivity)[0];
|
||||
if (session) openSession(session.id); else createSession(project);
|
||||
}
|
||||
if (button.dataset.filter) { filter = button.dataset.filter; renderOverview(); }
|
||||
if (button.dataset.resolve) {
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
const declined = button.dataset.resolve === '暂不部署';
|
||||
session.state = declined ? 'idle' : 'running';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = declined ? '已暂缓部署,等待后续安排。' : `已确认${button.dataset.resolve},正在继续执行。`;
|
||||
session.answer = session.detail;
|
||||
render();
|
||||
notify(declined ? '已暂缓本次部署' : '已处理,工作继续进行');
|
||||
}
|
||||
});
|
||||
$('desktops').addEventListener('pointerover', (event) => {
|
||||
if (event.pointerType === 'touch') return;
|
||||
const button = event.target.closest('[data-scope]');
|
||||
if (!button || button.dataset.scope === scope) return;
|
||||
scope = button.dataset.scope;
|
||||
renderOverview();
|
||||
});
|
||||
$('project-search').addEventListener('input', () => { renderOverview(); $('desktops').scrollTop = 0; });
|
||||
$('project-tabs').addEventListener('keydown', (event) => {
|
||||
const tabs = [...$('project-tabs').querySelectorAll('[data-tab]')];
|
||||
const index = tabs.indexOf(document.activeElement);
|
||||
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
||||
tabs[next].focus();
|
||||
tabs[next].click();
|
||||
});
|
||||
$('settings-form').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const project = projects.find((p) => p.id === settingsProjectId);
|
||||
const name = $('settings-name').value.trim();
|
||||
const path = $('settings-path').value.trim();
|
||||
if (!name || !path) return;
|
||||
if (project.kind !== 'channel') project.name = name;
|
||||
project.rootPath = path;
|
||||
project.description = $('settings-description').value;
|
||||
project.defaultMode = $('settings-mode').value;
|
||||
project.runtime = $('settings-runtime').value;
|
||||
render();
|
||||
$('project-settings').close();
|
||||
};
|
||||
$('close-settings').onclick = $('cancel-settings').onclick = () => $('project-settings').close();
|
||||
$('project-settings').addEventListener('close', () => {
|
||||
showOverview(settingsProjectId);
|
||||
overview.querySelector(`[data-settings="${settingsProjectId}"]`)?.focus();
|
||||
});
|
||||
$('workspace-trigger').onclick = () => showOverview(sessions.find((s) => s.id === current).project);
|
||||
$('activity-trigger').onclick = () => showOverview('all', $('activity-trigger'));
|
||||
$('close-overview').onclick = () => { closeMenu(); menuTrigger?.focus(); };
|
||||
$('simulate').onclick = () => {
|
||||
const session = sessions.find((s) => s.state === 'running' && s.id !== current) || sessions.find((s) => s.state === 'running');
|
||||
if (!session) return;
|
||||
const visible = session.id === current && !overview.matches(':popover-open') && !document.hidden;
|
||||
session.state = visible ? 'idle' : 'done';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = visible ? '结果已查看,可以继续这项工作。' : '工作已完成,结果等待查看。';
|
||||
session.answer = '本轮工作已完成。\n\n示例结果:检查项已通过,相关说明已整理。你可以继续追问,或者切换到下一项工作。';
|
||||
render();
|
||||
notify(`${projectOf(session).name} / ${session.title} 已完成`);
|
||||
};
|
||||
overview.addEventListener('toggle', (event) => {
|
||||
const open = event.newState === 'open';
|
||||
$('workspace-trigger').setAttribute('aria-expanded', String(open));
|
||||
$('activity-trigger').setAttribute('aria-expanded', String(open));
|
||||
if (open) return;
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
if (session.state === 'done') { session.state = 'idle'; session.detail = '结果已查看,可以继续这项工作。'; render(); }
|
||||
});
|
||||
$('theme').onclick = () => {
|
||||
const dark = document.documentElement.dataset.theme !== 'dark';
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||
$('theme').textContent = dark ? '切换浅色' : '切换深色';
|
||||
};
|
||||
function createSession(project) {
|
||||
const id = Math.max(...sessions.map((s) => s.id)) + 1;
|
||||
sessions.push({ id, project, title: '新的工作', state: 'idle', detail: '等待你的第一条消息。', prompt: '开始一项新的工作。', answer: '在下方输入你的想法。' });
|
||||
openSession(id);
|
||||
$('message').focus();
|
||||
}
|
||||
$('new-chat').onclick = () => createSession(sessions.find((s) => s.id === current).project);
|
||||
$('menu-new-chat').onclick = () => createSession(scope === 'all' ? sessions.find((s) => s.id === current).project : scope);
|
||||
$('new-project').onclick = () => {
|
||||
$('project-form').hidden = !$('project-form').hidden;
|
||||
$('new-project').setAttribute('aria-expanded', String(!$('project-form').hidden));
|
||||
if (!$('project-form').hidden) $('project-input').focus();
|
||||
};
|
||||
$('cancel-project').onclick = () => {
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
$('new-project').focus();
|
||||
};
|
||||
$('project-form').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const name = $('project-input').value.trim();
|
||||
if (!name) { $('project-input').focus(); return; }
|
||||
const id = `project-${projects.length + 1}`;
|
||||
projects.push({ id, name, kind: 'local', location: '本地工作空间', rootPath: `D:/workspace/${id}`, description: '', defaultMode: 'ask', runtime: 'OpenCode' });
|
||||
scope = id;
|
||||
$('project-form').reset();
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
createSession(id);
|
||||
};
|
||||
$('composer').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const text = $('message').value.trim();
|
||||
if (!text) return;
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
if (session.state === 'running' || session.state === 'waiting') { notify('请先完成当前模拟执行或处理待办,再发送消息。'); return; }
|
||||
session.prompt = text;
|
||||
session.state = 'running';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = '正在处理你的消息。';
|
||||
session.answer = '已开始模拟执行。你可以切换工作空间,然后点击页面上方“模拟后台完成”查看通知效果。';
|
||||
$('message').value = '';
|
||||
render();
|
||||
};
|
||||
function reset() {
|
||||
projects.splice(0, projects.length, ...structuredClone(initialProjects));
|
||||
scope = 'all';
|
||||
projectTab = 'local';
|
||||
$('project-search').value = '';
|
||||
filter = 'all';
|
||||
$('project-form').reset();
|
||||
sessions = structuredClone(initial);
|
||||
sessions.forEach((session, index) => { session.lastActivity = initial.length - index; });
|
||||
activityOrder = initial.length;
|
||||
current = 1;
|
||||
Object.keys(remembered).forEach((key) => delete remembered[key]);
|
||||
Object.keys(drafts).forEach((key) => delete drafts[key]);
|
||||
remembered.buddy = 1;
|
||||
closeMenu();
|
||||
$('message').value = '';
|
||||
render();
|
||||
}
|
||||
$('reset').onclick = reset;
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if ($('project-settings').open) return;
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.code === 'KeyK') {
|
||||
event.preventDefault();
|
||||
if (overview.matches(':popover-open')) { closeMenu(); menuTrigger?.focus(); } else showOverview();
|
||||
}
|
||||
if (event.key === 'Escape' && overview.matches(':popover-open')) {
|
||||
event.preventDefault(); closeMenu(); menuTrigger?.focus();
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', () => { if (overview.matches(':popover-open')) positionMenu(); });
|
||||
reset();
|
||||
+228
-78
@@ -1498,6 +1498,41 @@ describe("App", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("skips attachment draft reads for empty channel projects and restores local drafts", async () => {
|
||||
const conversationId = "00000000-0000-4000-8000-000000000471";
|
||||
const attachment = {
|
||||
id: "00000000-0000-4000-8000-000000000472",
|
||||
name: "restored-draft.txt",
|
||||
size: 12,
|
||||
preview: "Saved draft",
|
||||
kind: "text" as const,
|
||||
};
|
||||
const channelProject = {
|
||||
...project,
|
||||
id: "00000000-0000-4000-8000-000000000473",
|
||||
name: "Empty channel",
|
||||
kind: "channel" as const,
|
||||
channel: "weixin" as const,
|
||||
};
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([project, channelProject]);
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{ id: conversationId, projectId, title: "Saved conversation", updatedAt: 100, messages: [] },
|
||||
]);
|
||||
render(<App />);
|
||||
await waitFor(() => expect(api.context.getDraft).toHaveBeenCalledWith(conversationId));
|
||||
vi.mocked(api.context.getDraft).mockClear();
|
||||
selectProjectOption(channelProject.name);
|
||||
expect(screen.getAllByText("尚无远程会话").length).toBeGreaterThan(0);
|
||||
expect(api.context.getDraft).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(api.context.getDraft).mockResolvedValueOnce([attachment]);
|
||||
selectProjectOption(project.name);
|
||||
await waitFor(() => {
|
||||
expect(api.context.getDraft).toHaveBeenCalledExactlyOnceWith(conversationId);
|
||||
expect(screen.getByText(attachment.name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project activity integration", () => {
|
||||
const otherProject = {
|
||||
...project,
|
||||
@@ -1532,7 +1567,7 @@ describe("App", () => {
|
||||
const search = await screen.findByLabelText("搜索对话");
|
||||
fireEvent.change(search, { target: { value: "no matching conversation" } });
|
||||
await act(async () => initialTasks.resolve([task, { ...task, id: "duplicate-task" }]));
|
||||
const summary = await screen.findByRole("button", { name: /全项目活动/u });
|
||||
const summary = await screen.findByRole("button", { name: "当前项目" });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
expect(screen.queryByTitle("Exact background discussion")).not.toBeInTheDocument();
|
||||
fireEvent.change(search, { target: { value: "Current discussion" } });
|
||||
@@ -1543,22 +1578,23 @@ describe("App", () => {
|
||||
expect(projectRow).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(summary);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
|
||||
const running = screen.getByRole("menu", { name: "Background project" });
|
||||
expect(within(running).getAllByRole("menuitem")).toHaveLength(1);
|
||||
expect(within(running).getByRole("menuitem", { name: /Exact background discussion/u })).toHaveTextContent("运行中");
|
||||
fireEvent.pointerOver(screen.getByRole("menuitemradio", { name: /Background project/u }), { pointerType: 'mouse' });
|
||||
const running = screen.getByRole("list", { name: "会话" });
|
||||
expect(within(running).getAllByRole("button")).toHaveLength(2);
|
||||
expect(within(running).getByRole("button", { name: /Exact background discussion/u })).toHaveTextContent("运行中");
|
||||
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: "completed" }]);
|
||||
act(() => conversationQueueChangeListener?.(task.conversationId!));
|
||||
await waitFor(() => expect(summary).toHaveTextContent("1 个已完成"));
|
||||
expect(summary).not.toHaveTextContent("待处理");
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
|
||||
fireEvent.click(within(running).getByRole("button", { name: /Exact background discussion/u }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
await waitFor(() => expect(summary).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(summary).toHaveTextContent("暂无活动"));
|
||||
expect(screen.queryByRole("dialog", { name: "项目与活动" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
expect(within(screen.getByRole("menu")).getByRole("menuitemradio", { name: /Background project/u }))
|
||||
.not.toHaveTextContent("个运行中");
|
||||
expect(within(screen.getByRole('list', { name: '会话' })).getByRole('button', { name: 'Exact background discussion' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each((["foreground", "background"] as const).flatMap((location) =>
|
||||
@@ -1573,7 +1609,7 @@ describe("App", () => {
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
expect(request.conversationId).toBe("activity-current");
|
||||
const summary = screen.getByRole("button", { name: /全项目活动/u });
|
||||
const summary = screen.getByRole("button", { name: "当前项目" });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ header: expect.objectContaining({ id: request.conversationId }), messages: expect.arrayContaining([
|
||||
@@ -1608,8 +1644,8 @@ describe("App", () => {
|
||||
expect(summary).not.toHaveTextContent("运行中");
|
||||
expect(screen.getByText("Exact conversation content")).toBeVisible();
|
||||
fireEvent.click(summary);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: new RegExp(project.name, "u") }));
|
||||
expect(screen.getByRole("menu", { name: project.name })).toHaveTextContent("已完成");
|
||||
fireEvent.pointerOver(screen.getByRole("menuitemradio", { name: new RegExp(project.name, "u") }), { pointerType: 'mouse' });
|
||||
expect(screen.getByRole("list", { name: '会话' })).toHaveTextContent("已完成");
|
||||
fireEvent.click(summary);
|
||||
expect(summary).toHaveTextContent("1 个已完成");
|
||||
selectProjectOption(project.name);
|
||||
@@ -1617,10 +1653,10 @@ describe("App", () => {
|
||||
.findByTitle(saved.header.title));
|
||||
}
|
||||
expect(await screen.findByText("Local completion result")).toBeVisible();
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动"));
|
||||
selectProjectOption(otherProject.name);
|
||||
await screen.findByTitle("Exact background discussion");
|
||||
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
|
||||
await within(container.querySelector<HTMLElement>('.conversation-list')!).findByTitle("Exact background discussion");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动");
|
||||
});
|
||||
|
||||
it.each(["failed", "cancelled"] as const)("does not mark a background local run completed after %s", async (status) => {
|
||||
@@ -1635,11 +1671,11 @@ describe("App", () => {
|
||||
selectProjectOption(otherProject.name);
|
||||
fireEvent.click(await screen.findByTitle("Exact background discussion"));
|
||||
await screen.findByText("Exact conversation content");
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个运行中");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个运行中");
|
||||
act(() => agentListener?.({
|
||||
requestId: request.requestId, type: "error", status, message: `Local activity ${status}`,
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动"));
|
||||
expect(screen.getByText("Exact conversation content")).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
expect(within(screen.getByRole("menu", { name: "当前项目" }))
|
||||
@@ -1653,7 +1689,7 @@ describe("App", () => {
|
||||
const selected = (await within(container.querySelector<HTMLElement>(".conversation-list")!)
|
||||
.findByTitle("Current discussion")).closest("button")!;
|
||||
expect(selected).toHaveClass("conversation-item--active");
|
||||
const summary = await screen.findByRole("button", { name: /全项目活动/u });
|
||||
const summary = await screen.findByRole("button", { name: "当前项目" });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
|
||||
const settings = await screen.findByRole("dialog", { name: "设置中心" });
|
||||
@@ -1666,7 +1702,97 @@ describe("App", () => {
|
||||
fireEvent.click(within(settings).getByRole("button", { name: "关闭设置" }));
|
||||
await waitFor(() => expect(settings).not.toBeInTheDocument());
|
||||
expect(screen.getByLabelText("向 GoodBuddy 提问")).toBeVisible();
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动"));
|
||||
});
|
||||
|
||||
it('promotes an old history visit into the first of ten recent workspace conversations', async () => {
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([]);
|
||||
const history = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `recent-${index}`, projectId, title: `Recent ${index}`, updatedAt: 1000 - index, messages: [],
|
||||
}));
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([...history, {
|
||||
id: 'old-visit', projectId, title: 'Older visit', updatedAt: 1,
|
||||
messages: [{ id: 'old-message', role: 'assistant', content: 'Opened old history', createdAt: 1, state: 'complete' }],
|
||||
}]);
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByTitle('Older visit'));
|
||||
await screen.findByText('Opened old history');
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }));
|
||||
const rows = within(screen.getByRole('list', { name: '会话' })).getAllByRole('button');
|
||||
expect(rows).toHaveLength(10);
|
||||
expect(rows[0]).toHaveTextContent('Older visit');
|
||||
});
|
||||
|
||||
it('promotes a background completion above newer-message history in the recent ten', async () => {
|
||||
const newerHistory = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `other-recent-${index}`, projectId: otherProject.id, title: `Other recent ${index}`, updatedAt: 1000 - index, messages: [],
|
||||
}));
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([...conversations, ...newerHistory]);
|
||||
render(<App />);
|
||||
const summary = await screen.findByRole('button', { name: '当前项目' });
|
||||
await waitFor(() => expect(summary).toHaveTextContent('1 个运行中'));
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: 'completed' }]);
|
||||
act(() => conversationQueueChangeListener?.(task.conversationId!));
|
||||
await waitFor(() => expect(summary).toHaveTextContent('1 个已完成'));
|
||||
fireEvent.click(summary);
|
||||
fireEvent.pointerOver(screen.getByRole('menuitemradio', { name: /Background project/u }), { pointerType: 'mouse' });
|
||||
const rows = within(screen.getByRole('list', { name: '会话' })).getAllByRole('button');
|
||||
expect(rows).toHaveLength(10);
|
||||
expect(rows[0]).toHaveTextContent('Exact background discussion');
|
||||
expect(rows[0]).toHaveTextContent('已完成');
|
||||
});
|
||||
|
||||
it("restores each project's last opened conversation and its composer draft", async () => {
|
||||
render(<App />);
|
||||
await screen.findByLabelText('更多会话操作 Current discussion');
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Keep this local draft' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全部项目' }));
|
||||
fireEvent.click(within(screen.getByRole('list', { name: '会话' })).getByRole('button', { name: /Exact background discussion/u }));
|
||||
await screen.findByText('Exact conversation content');
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Keep this background draft' } });
|
||||
selectProjectOption(project.name);
|
||||
await waitFor(() => expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('Keep this local draft'));
|
||||
selectProjectOption(otherProject.name);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Exact conversation content')).toBeVisible();
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('Keep this background draft');
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['project', 'new conversation', 'new project'] as const)('guards the workspace %s action before changing project', async (action) => {
|
||||
const createdProject = { ...otherProject, id: 'workspace-created', name: 'Created workspace' };
|
||||
if (action === 'new project') vi.mocked(api.projects.create).mockResolvedValueOnce(createdProject);
|
||||
render(<App />);
|
||||
await screen.findByRole('button', { name: '当前项目' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置' }));
|
||||
await screen.findByRole('heading', { name: '设置中心' });
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }));
|
||||
fireEvent.change(await screen.findByLabelText('默认工作区目录'), { target: { value: 'C:\\Unsaved workspace draft' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }));
|
||||
const target = screen.getByRole('menuitemradio', { name: /Background project/u });
|
||||
if (action === 'project') fireEvent.click(target);
|
||||
else if (action === 'new conversation') {
|
||||
fireEvent.pointerOver(target, { pointerType: 'mouse' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建会话' }));
|
||||
} else {
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' });
|
||||
fireEvent.change(within(dialog).getByLabelText('名称'), { target: { value: createdProject.name } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
|
||||
}
|
||||
const discard = await screen.findByRole('button', { name: '放弃更改并关闭' });
|
||||
expect(screen.getByRole('button', { name: '当前项目' })).not.toHaveTextContent('Background project');
|
||||
expect(screen.getByLabelText('默认工作区目录')).toHaveValue('C:\\Unsaved workspace draft');
|
||||
fireEvent.click(discard);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '当前项目' })).toHaveTextContent(action === 'new project' ? createdProject.name : 'Background project');
|
||||
expect(screen.queryByRole('dialog', { name: '设置中心' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '放弃更改并关闭' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '对话' })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
if (action === 'new conversation') expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('');
|
||||
});
|
||||
|
||||
it("opens the exact off-project conversation and clears task selection, search, menu and narrow sidebar", async () => {
|
||||
@@ -1689,14 +1815,14 @@ describe("App", () => {
|
||||
await waitFor(() => expect(sidebar.querySelector(".conversation-more")).toBeInTheDocument());
|
||||
fireEvent.click(sidebar.querySelector<HTMLButtonElement>(".conversation-more")!);
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全部项目' }));
|
||||
fireEvent.click(within(screen.getByRole('list', { name: '会话' })).getByRole("button", { name: /Exact background discussion/u }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
expect(sidebar).toHaveClass("sidebar--closed");
|
||||
expect(within(screen.getByRole("region", { name: "当前会话的任务" }))
|
||||
.getByRole("button", { name: /会话任务/u })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "项目与活动" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
expect(screen.getByLabelText("搜索对话")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
|
||||
@@ -1712,23 +1838,21 @@ describe("App", () => {
|
||||
|
||||
it("keeps keyboard focus inside activity without dismissing the narrow sidebar", async () => {
|
||||
const { container } = render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
await screen.findByRole("button", { name: "当前项目" });
|
||||
const originalWidth = window.innerWidth;
|
||||
try {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 680 });
|
||||
act(() => window.dispatchEvent(new Event("resize")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
const trigger = screen.getByRole("button", { name: /全项目活动/u });
|
||||
const trigger = screen.getByRole("button", { name: "当前项目" });
|
||||
fireEvent.click(trigger);
|
||||
const projectItem = screen.getByRole("menuitem", { name: /Background project/u });
|
||||
expect(projectItem).toHaveFocus();
|
||||
fireEvent.keyDown(projectItem, { key: "ArrowRight" });
|
||||
expect(screen.getByRole("menuitem", { name: /Exact background discussion/u })).toHaveFocus();
|
||||
expect(screen.getByRole('tab', { name: '全部项目' })).toHaveFocus();
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全部项目' }));
|
||||
const conversation = within(screen.getByRole('list', { name: '会话' })).getByRole('button', { name: /Exact background discussion/u });
|
||||
act(() => conversation.focus());
|
||||
expect(conversation).toHaveFocus();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(projectItem).toHaveFocus();
|
||||
expect(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "项目与活动" })).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
|
||||
expect(trigger).toHaveFocus();
|
||||
} finally {
|
||||
@@ -1738,14 +1862,14 @@ describe("App", () => {
|
||||
|
||||
it("respects the Settings leave guard before changing project and conversation", async () => {
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
await screen.findByRole("button", { name: "当前项目" });
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
|
||||
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: '全部项目' }));
|
||||
fireEvent.click(within(screen.getByRole('list', { name: '会话' })).getByRole("button", { name: /Exact background discussion/u }));
|
||||
expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible();
|
||||
expect(screen.getAllByRole("alert").some((alert) =>
|
||||
alert.textContent?.includes("当前设置有未保存更改")
|
||||
@@ -1755,7 +1879,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
|
||||
expect(screen.queryByRole("menu", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "项目与活动" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["question", "approval"] as const)("preserves live %s across snapshot refresh and clears persisted terminal prompts", async (kind) => {
|
||||
@@ -1792,7 +1916,7 @@ describe("App", () => {
|
||||
await screen.findAllByText(refreshed.title);
|
||||
if (kind === "question") expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理");
|
||||
}
|
||||
let resolveReply: (() => void) | undefined;
|
||||
if (kind === "question") {
|
||||
@@ -1816,7 +1940,7 @@ describe("App", () => {
|
||||
expect(screen.getByText("Persisted terminal result")).toBeInTheDocument();
|
||||
if (kind === "question") expect(screen.getByText("问题与回答")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it.each((["question", "approval"] as const).flatMap((kind) =>
|
||||
@@ -1842,7 +1966,7 @@ describe("App", () => {
|
||||
title: "Response approval", description: "Confirm response", toolName: "Bash", argumentSummary: "echo response", allowPermanent: false,
|
||||
};
|
||||
act(() => agentListener?.(kind === "question" ? questionEvent : approvalEvent));
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理 1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: kind === "question" ? "跳过" : "仅此次" }));
|
||||
await waitFor(() => expect(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
@@ -1854,7 +1978,7 @@ describe("App", () => {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const summary = screen.getByRole("button", { name: /全项目活动/u });
|
||||
const summary = screen.getByRole("button", { name: "当前项目" });
|
||||
expect(summary).toHaveTextContent(outcome === "success" ? "2 个运行中"
|
||||
: outcome === "done" ? "1 个运行中" : "1 个待处理 1 个运行中");
|
||||
if (outcome === "success" || outcome === "done") expect(summary).not.toHaveTextContent("待处理");
|
||||
@@ -1871,7 +1995,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("2 个运行中");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("2 个运行中");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task, {
|
||||
...task, id: "live-task", projectId, conversationId: request.conversationId, status: "waiting_approval",
|
||||
}]);
|
||||
@@ -1885,16 +2009,16 @@ describe("App", () => {
|
||||
});
|
||||
conversationQueueChangeListener?.(request.conversationId!);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
const menu = screen.getByRole("menu", { name: "全项目活动" });
|
||||
expect(within(menu).getAllByRole("menuitem")).toHaveLength(2);
|
||||
fireEvent.click(within(menu).getByRole("menuitem", { name: /1 个待处理/u }));
|
||||
const pending = screen.getByRole("menu", { name: project.name });
|
||||
expect(within(pending).getAllByRole("menuitem")).toHaveLength(1);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理 1 个运行中"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
const menu = screen.getByRole("dialog", { name: "项目与活动" });
|
||||
fireEvent.click(within(screen.getByRole('group', { name: '筛选会话状态' })).getByRole('button', { name: '待处理' }));
|
||||
const pending = screen.getByRole("list", { name: '会话' });
|
||||
expect(within(pending).getAllByRole("button")).toHaveLength(1);
|
||||
expect(pending).toHaveTextContent(kind === "question" ? "等待你的回答" : "等待审批");
|
||||
fireEvent.click(within(menu).getByRole("menuitem", { name: /Background project/u }));
|
||||
expect(screen.getByRole("menu", { name: "Background project" })).toHaveTextContent("Exact background discussion");
|
||||
fireEvent.click(within(screen.getByRole('group', { name: '筛选会话状态' })).getByRole('button', { name: '运行中' }));
|
||||
fireEvent.pointerOver(within(menu).getByRole("menuitemradio", { name: /Background project/u }), { pointerType: 'mouse' });
|
||||
expect(screen.getByRole("list", { name: '会话' })).toHaveTextContent("Exact background discussion");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([]);
|
||||
act(() => {
|
||||
agentListener?.(kind === "question"
|
||||
@@ -1902,8 +2026,9 @@ describe("App", () => {
|
||||
: { requestId: request.requestId, type: "error", status: "failed", message: "Activity execution failed" });
|
||||
conversationQueueChangeListener?.(request.conversationId!);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument());
|
||||
expect(menu).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("暂无活动"));
|
||||
expect(menu).toBeInTheDocument();
|
||||
fireEvent.keyDown(menu, { key: 'Escape' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2895,7 +3020,7 @@ describe("App", () => {
|
||||
});
|
||||
});
|
||||
await act(async () => { finishRefresh!(snapshots); });
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理");
|
||||
fireEvent.click(within(container.querySelector<HTMLElement>(".conversation-list")!).getByText(target.title));
|
||||
expect(await screen.findByText("Retained scheduled history")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "仅此次" })).toBeInTheDocument();
|
||||
@@ -2946,7 +3071,7 @@ describe("App", () => {
|
||||
expect(await screen.findAllByText("请求写入工作区")).not.toHaveLength(0);
|
||||
expect(screen.getByText("仅此次")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("任务结果:发布任务")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个待处理");
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: /任务中心/u }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /项目任务/u }));
|
||||
@@ -2956,8 +3081,8 @@ describe("App", () => {
|
||||
fireEvent.click(within(taskRow).getByRole("button", { name: "仅此次允许" }));
|
||||
await waitFor(() => expect(api.agent.respondApproval).toHaveBeenCalledWith("00000000-0000-4000-8000-000000000834", "once"));
|
||||
await waitFor(() => expect(within(taskRow).queryByLabelText("等待审批: write_file")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个运行中"));
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("1 个运行中"));
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("idle-preloads only the small Heartbeat route at startup", async () => {
|
||||
@@ -8507,7 +8632,8 @@ describe("App", () => {
|
||||
.map((option) => option.querySelector("span")?.textContent),
|
||||
).toEqual(["Ask · 只读问答", "Execute · 完全权限"]);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("新建项目"));
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
const dialog = screen.getByRole("dialog", { name: "新建项目" });
|
||||
const defaultMode = within(dialog)
|
||||
.getAllByRole("group", { name: "默认模式" })
|
||||
@@ -9697,12 +9823,10 @@ describe("App", () => {
|
||||
fireEvent.keyDown(trigger, { key: "ArrowDown" });
|
||||
|
||||
const menu = screen.getByRole("menu", { name: "当前项目" });
|
||||
expect(
|
||||
within(menu).getByRole("group", { name: "本地项目" }),
|
||||
).toHaveTextContent("本地目录 · C:\\Users\\test");
|
||||
expect(
|
||||
within(menu).getByRole("group", { name: "远程通道" }),
|
||||
).toHaveTextContent("微信 ClawBot · 远程通道 · C:\\Users\\test");
|
||||
const tabs = screen.getByRole('tablist', { name: '项目类型' });
|
||||
expect(within(tabs).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['全部项目', '本地', '消息通道']);
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /本地目录/u })).toHaveTextContent("本地目录 · C:\\Users\\test");
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /微信 ClawBot/u })).toHaveTextContent("微信 ClawBot · 远程通道 · C:\\Users\\test");
|
||||
const selectedProject = within(menu)
|
||||
.getByText(project.name, { selector: "b" })
|
||||
.closest<HTMLButtonElement>('[role="menuitemradio"]');
|
||||
@@ -9710,10 +9834,11 @@ describe("App", () => {
|
||||
name: `管理项目 ${channelProject.name}`,
|
||||
});
|
||||
expect(selectedProject).toHaveAttribute("aria-checked", "true");
|
||||
await waitFor(() => expect(selectedProject).toHaveFocus());
|
||||
await waitFor(() => expect(within(tabs).getByRole('tab', { name: '全部项目' })).toHaveFocus());
|
||||
|
||||
fireEvent.keyDown(selectedProject!, { key: "End" });
|
||||
expect(remoteProjectSettings).toHaveFocus();
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /微信 ClawBot/u })).toHaveFocus();
|
||||
act(() => remoteProjectSettings.focus());
|
||||
fireEvent.keyDown(remoteProjectSettings, { key: "Escape" });
|
||||
expect(trigger).toHaveFocus();
|
||||
expect(
|
||||
@@ -12017,8 +12142,9 @@ describe("App", () => {
|
||||
it("opens project creation as an unobscured dialog", async () => {
|
||||
render(<App />);
|
||||
|
||||
const newProjectButton = await screen.findByLabelText("新建项目");
|
||||
fireEvent.click(newProjectButton);
|
||||
const projectButton = await screen.findByRole('button', { name: '当前项目' });
|
||||
fireEvent.click(projectButton);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
let dialog = screen.getByRole("dialog", { name: "新建项目" });
|
||||
expect(dialog).toHaveClass("project-create-card");
|
||||
expect(within(dialog).getByRole("button", { name: "创建" })).toBeDisabled();
|
||||
@@ -12028,9 +12154,10 @@ describe("App", () => {
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "新建项目" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(newProjectButton).toHaveFocus();
|
||||
expect(projectButton).toHaveFocus();
|
||||
|
||||
fireEvent.click(newProjectButton);
|
||||
fireEvent.click(projectButton);
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
dialog = screen.getByRole("dialog", { name: "新建项目" });
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("名称"), {
|
||||
@@ -12054,7 +12181,8 @@ describe("App", () => {
|
||||
);
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("新建项目"));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '当前项目' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||||
const dialog = screen.getByRole("dialog", { name: "新建项目" });
|
||||
const nameInput = within(dialog).getByLabelText("名称");
|
||||
fireEvent.change(nameInput, {
|
||||
@@ -13080,7 +13208,7 @@ describe("App", () => {
|
||||
expect(screen.getAllByText("问题与回答")).toHaveLength(3);
|
||||
act(() => agentListener?.(first));
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("removes remotely resolved questions without recording a skip or blocking the next question", async () => {
|
||||
@@ -13103,7 +13231,7 @@ describe("App", () => {
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Retain draft");
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "second" }));
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("does not save a duplicate question-resolved event after the first resolution is persisted", async () => {
|
||||
@@ -15198,7 +15326,7 @@ describe("App", () => {
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('focuses the chat composer after a guarded Magic Notes leave to a new conversation', async () => {
|
||||
it.each(['sidebar', 'project', 'new conversation'] as const)('opens chat after a single guarded Magic Notes discard from %s', async (entry) => {
|
||||
await api.updates!.updateSettings({ magicNotesEnabled: true })
|
||||
const note = {
|
||||
id: '00000000-0000-4000-8000-000000000602', title: 'Focus note', preview: '',
|
||||
@@ -15207,17 +15335,39 @@ describe("App", () => {
|
||||
}
|
||||
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [note], tags: [] })
|
||||
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
|
||||
const targetProject = { ...project, id: '00000000-0000-4000-8000-000000000607', name: 'Guarded workspace' }
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([project, targetProject])
|
||||
render(<App />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '魔法笔记' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Focus note.*条记录/ }))
|
||||
fireEvent.change(await screen.findByLabelText('笔记标题'), { target: { value: 'Unsaved focus title' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /新建对话/ }))
|
||||
const title = await screen.findByLabelText('笔记标题')
|
||||
fireEvent.change(title, { target: { value: 'Unsaved focus title' } })
|
||||
if (entry === 'sidebar') fireEvent.click(screen.getByRole('button', { name: /新建对话/ }))
|
||||
else {
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
const target = screen.getByRole('menuitemradio', { name: /Guarded workspace/ })
|
||||
if (entry === 'project') fireEvent.click(target)
|
||||
else {
|
||||
fireEvent.pointerOver(target, { pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建会话' }))
|
||||
}
|
||||
}
|
||||
const discard = await screen.findByRole('button', { name: '放弃草稿并切换' })
|
||||
expect(title).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '当前项目' })).not.toHaveTextContent(targetProject.name)
|
||||
await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)) })
|
||||
// A real pointer click moves focus to the confirmation button before the leave completes.
|
||||
discard.focus()
|
||||
fireEvent.click(discard)
|
||||
await waitFor(() => expect(screen.getByRole('textbox', { name: '向 GoodBuddy 提问' })).toHaveFocus())
|
||||
await waitFor(() => {
|
||||
const composer = screen.getByRole('textbox', { name: '向 GoodBuddy 提问' })
|
||||
expect(composer).toBeVisible()
|
||||
if (entry !== 'project') expect(composer).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '对话' })).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
expect(title).not.toBeVisible()
|
||||
if (entry !== 'sidebar') expect(screen.getByRole('button', { name: '当前项目' })).toHaveTextContent(targetProject.name)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['title', 'text'])('guards real App navigation to multiple apps and discards only after confirmation (%s)', async (draft) => {
|
||||
|
||||
+21
-26
@@ -215,7 +215,6 @@ import {
|
||||
ScopeBadge,
|
||||
} from "./WorkspacePrimitives";
|
||||
import { ProjectSwitcher } from "./ProjectSwitcher";
|
||||
import { ProjectActivity } from "./ProjectActivity";
|
||||
import { useStableHandlers } from "./stable-derived-value";
|
||||
import { useUnviewedCompletions } from "./use-unviewed-completions";
|
||||
import { useExecutionStats } from "./use-execution-stats";
|
||||
@@ -1527,6 +1526,7 @@ function App(): React.JSX.Element {
|
||||
? update(activeConversationIdRef.current)
|
||||
: update;
|
||||
activeConversationIdRef.current = next;
|
||||
conversationStore.rememberOpened(next);
|
||||
if (next) {
|
||||
const now = Date.now();
|
||||
setCachedConversationViews((current) =>
|
||||
@@ -1539,7 +1539,7 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
setActiveIdState(next);
|
||||
}, [activeConversationViewIds]);
|
||||
}, [activeConversationViewIds, conversationStore]);
|
||||
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false);
|
||||
const [
|
||||
conversationHtmlRenderingEnabled,
|
||||
@@ -1672,7 +1672,7 @@ function App(): React.JSX.Element {
|
||||
}), []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (attachmentsRef.current.has(activeId) || conversationStore.getState().find((conversation) => conversation.id === activeId)?.remote) return;
|
||||
if (!activeId || attachmentsRef.current.has(activeId) || conversationStore.getState().find((conversation) => conversation.id === activeId)?.remote) return;
|
||||
void window.goodbuddy.context.getDraft(activeId).then((saved) => {
|
||||
if (!active || attachmentsRef.current.has(activeId) || !saved.length) return;
|
||||
attachmentsRef.current.set(activeId, saved);
|
||||
@@ -4244,11 +4244,7 @@ function App(): React.JSX.Element {
|
||||
candidateConversations = conversationStore.getState(),
|
||||
): void => {
|
||||
setActiveProjectId(selected.id);
|
||||
const conversation = candidateConversations.find(
|
||||
(candidate) =>
|
||||
candidate.projectId === selected.id &&
|
||||
(selected.kind !== "channel" || candidate.remote !== undefined),
|
||||
);
|
||||
const conversation = conversationStore.projectConversation(selected.id, selected.kind === "channel", candidateConversations);
|
||||
if (conversation) {
|
||||
// Remote projects ignore incompatible conversation choices at resolution time
|
||||
// and say so in the picker, instead of silently rewriting saved conversations.
|
||||
@@ -4264,7 +4260,7 @@ function App(): React.JSX.Element {
|
||||
setConversations((current) => [created, ...current]);
|
||||
setActiveId(created.id);
|
||||
}
|
||||
setView("chat");
|
||||
commitView("chat");
|
||||
};
|
||||
|
||||
const selectProject = (projectId: string): void => {
|
||||
@@ -4290,15 +4286,7 @@ function App(): React.JSX.Element {
|
||||
): Promise<AssistantProject> => {
|
||||
const project = await window.goodbuddy.projects.create(input);
|
||||
setProjects((current) => [project, ...current]);
|
||||
setActiveProjectId(project.id);
|
||||
const conversation = createConversation(
|
||||
project.id,
|
||||
undefined,
|
||||
t("conversation.greeting"),
|
||||
);
|
||||
setConversations((current) => [conversation, ...current]);
|
||||
setActiveId(conversation.id);
|
||||
setView("chat");
|
||||
requestWorkspaceLeave("chat", () => commitProjectSelection(project));
|
||||
return project;
|
||||
};
|
||||
|
||||
@@ -4328,7 +4316,7 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
: [project, ...current],
|
||||
);
|
||||
commitProjectSelection(project);
|
||||
requestWorkspaceLeave("chat", () => commitProjectSelection(project));
|
||||
if (
|
||||
!isProjectRecoveryUnsettled(
|
||||
projectRecoveryByProjectIdRef.current[project.id],
|
||||
@@ -6580,7 +6568,16 @@ function App(): React.JSX.Element {
|
||||
onCreate: createProject,
|
||||
onDelete: deleteProject,
|
||||
onRemoteCommitted: loadCommittedRemoteProject,
|
||||
onSelect: selectProject,
|
||||
onSelect: (projectId: string) => requestWorkspaceLeave('chat', () => { selectProject(projectId); if (narrowWindow) closeNarrowSidebar(); }),
|
||||
onNewConversation: (projectId: string) => requestWorkspaceLeave('chat', () => {
|
||||
startNewConversation(projectId, { ready: (conversation) => {
|
||||
setActiveProjectId(projectId);
|
||||
setActiveId(conversation.id);
|
||||
commitView('chat');
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
if (narrowWindow) closeNarrowSidebar();
|
||||
} });
|
||||
}),
|
||||
onSelectRoot: () => window.goodbuddy.settings.selectWorkspace(),
|
||||
onUpdate: updateProject,
|
||||
});
|
||||
@@ -6805,6 +6802,10 @@ function App(): React.JSX.Element {
|
||||
|
||||
<ProjectSwitcher
|
||||
activeProjectId={activeProjectId}
|
||||
conversationStore={conversationStore}
|
||||
activities={projectActivity.activities}
|
||||
visible={sidebarOpen}
|
||||
onOpenConversation={openActivityConversation}
|
||||
activityByProjectId={projectActivity.byProjectId}
|
||||
recoveryByProjectId={projectRecoveryByProjectId}
|
||||
runtimeSettings={runtimeSettings}
|
||||
@@ -6813,12 +6814,6 @@ function App(): React.JSX.Element {
|
||||
projects={projects}
|
||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||
/>
|
||||
<ProjectActivity
|
||||
activities={projectActivity.activities}
|
||||
projects={projects}
|
||||
visible={sidebarOpen}
|
||||
onOpenConversation={openActivityConversation}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`sidebar-conversation-controls${searchOpen ? " sidebar-conversation-controls--searching" : ""}`}
|
||||
|
||||
@@ -26,6 +26,20 @@ import type {
|
||||
} from '../../shared/remote-project-recovery-contracts'
|
||||
import i18n from './i18n'
|
||||
import { ProjectSwitcher } from './ProjectSwitcher'
|
||||
import { createConversationStores } from './conversation-store'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
|
||||
const menuProps = {
|
||||
conversationStore: createConversationStores([], { flushIntervalMs: 250 }).conversations,
|
||||
activities: [] as ConversationActivity[],
|
||||
onOpenConversation: vi.fn(),
|
||||
onNewConversation: vi.fn()
|
||||
}
|
||||
|
||||
function openCreate(name: '新建项目' | 'New project'): void {
|
||||
if (!screen.queryByRole('menu')) fireEvent.click(screen.getByRole('button', { name: /^(当前项目|Current project)$/u }))
|
||||
fireEvent.click(screen.getByRole('button', { name }))
|
||||
}
|
||||
|
||||
const profileId = '00000000-0000-4000-8000-000000000011'
|
||||
const runtimeSettings: RuntimeSettings = {
|
||||
@@ -116,6 +130,7 @@ function renderSwitcher(
|
||||
const onRetryRecovery = vi.fn(async () => undefined)
|
||||
render(
|
||||
<ProjectSwitcher
|
||||
{...menuProps}
|
||||
activeProjectId={currentProject.id}
|
||||
onArchive={vi.fn(async () => undefined)}
|
||||
onCreate={onCreate}
|
||||
@@ -309,6 +324,26 @@ function installRemoteApi(
|
||||
}
|
||||
|
||||
describe('ProjectSwitcher project activity integration', () => {
|
||||
it('uses one idle button for both rows and restores its keyboard focus', () => {
|
||||
renderSwitcher()
|
||||
const trigger = screen.getByRole('button', { name: '当前项目' })
|
||||
expect(screen.getAllByRole('button')).toEqual([trigger])
|
||||
expect(trigger).toHaveTextContent('Local project')
|
||||
expect(trigger).toHaveAccessibleDescription('暂无活动')
|
||||
fireEvent.click(within(trigger).getByText('暂无活动'))
|
||||
expect(screen.getByRole('region', { name: project.name })).toBeVisible()
|
||||
fireEvent.keyDown(screen.getByRole('dialog', { name: '项目与活动' }), { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
for (const key of ['Enter', ' ', 'ArrowDown']) {
|
||||
fireEvent.keyDown(trigger, { key })
|
||||
const menu = screen.getByRole('dialog', { name: '项目与活动' })
|
||||
expect(trigger).toHaveAttribute('aria-controls', menu.id)
|
||||
fireEvent.keyDown(menu, { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps per-project title counts and menu ordering stable across recovery and Host states', async () => {
|
||||
const api = installRemoteApi({ connectionState: 'connecting' })
|
||||
const remote: AssistantProject = {
|
||||
@@ -317,6 +352,7 @@ describe('ProjectSwitcher project activity integration', () => {
|
||||
}
|
||||
const idle = { ...remote, id: 'idle-project', name: 'Idle remote' }
|
||||
const props = {
|
||||
...menuProps,
|
||||
activeProjectId: project.id, projects: [project, remote, idle],
|
||||
remoteProjectsEnabled: true, onArchive: vi.fn(), onCreate: vi.fn(),
|
||||
onDelete: vi.fn(), onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
|
||||
@@ -329,7 +365,7 @@ describe('ProjectSwitcher project activity integration', () => {
|
||||
const { rerender } = render(<ProjectSwitcher {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
const menu = screen.getByRole('menu', { name: '当前项目' })
|
||||
await within(menu).findByRole('group', { name: 'SSH 主机:Build host' })
|
||||
await within(menu).findByRole('menuitem', { name: /Build host/u })
|
||||
for (const stage of ['agent', 'failed', 'completed'] as const) {
|
||||
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
|
||||
[remote.id]: { projectId: remote.id, requestId: 'activity-recovery', stage,
|
||||
@@ -340,10 +376,12 @@ describe('ProjectSwitcher project activity integration', () => {
|
||||
expect(rows.map((row) => row.querySelector('b')?.textContent))
|
||||
.toEqual(['Local project', 'Remote activity', 'Idle remote'])
|
||||
expect(rows[0]!.querySelector('.project-switcher__project-heading'))
|
||||
.toHaveTextContent('Local project 1 个运行中')
|
||||
.toHaveTextContent('Local project')
|
||||
expect(rows[0]!.querySelector('.project-activity__counts')).toHaveTextContent('1 个运行中')
|
||||
const heading = rows[1]!.querySelector('.project-switcher__project-heading')!
|
||||
expect(heading).toHaveTextContent('Remote activity3 个待处理 2 个运行中')
|
||||
expect(heading.querySelector('b')?.nextElementSibling).toHaveClass('project-activity__counts')
|
||||
expect(heading).toHaveTextContent('Remote activity')
|
||||
expect(heading.nextElementSibling).toHaveClass('project-activity__counts')
|
||||
expect(heading.nextElementSibling).toHaveTextContent('3 个待处理 2 个运行中')
|
||||
expect(rows[2]!.querySelector('.project-activity__counts')).toBeNull()
|
||||
}
|
||||
fireEvent.click(within(menu).getByRole('menuitemradio', { name: /Remote activity/u }))
|
||||
@@ -360,16 +398,17 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(trigger.closest('.project-switcher')).not.toContainElement(menu)
|
||||
expect(menu.closest('.floating-portal')?.parentElement).toBe(document.body)
|
||||
expect(menu.style.width).toBe('380px')
|
||||
const surface = screen.getByRole('dialog', { name: '项目与活动' })
|
||||
expect(surface.style.width).toBe('920px')
|
||||
const selected = within(menu).getByRole('menuitemradio', { checked: true })
|
||||
await waitFor(() => expect(selected).toHaveFocus())
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: '全部项目' })).toHaveFocus())
|
||||
fireEvent.pointerDown(selected)
|
||||
expect(menu).toBeInTheDocument()
|
||||
|
||||
const width = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(360)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(menu.style.width).toBe('328px')
|
||||
expect(menu.style.left).toBe('16px')
|
||||
expect(surface.style.width).toBe('328px')
|
||||
expect(surface.style.left).toBe('16px')
|
||||
width.mockRestore()
|
||||
fireEvent.keyDown(selected, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
@@ -383,14 +422,14 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
it.each(['新建项目', '项目设置'])('keeps %s outside the sidebar and restores focus after closing', (name) => {
|
||||
renderSwitcher()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: name === '项目设置' ? '当前项目' : name
|
||||
name: '当前项目'
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
if (name === '项目设置') {
|
||||
fireEvent.click(screen.getByRole('menuitem', {
|
||||
name: '管理项目 Local project'
|
||||
}))
|
||||
}
|
||||
} else openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name })
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body)
|
||||
const body = dialog.querySelector('.project-create-card__body')!
|
||||
@@ -407,7 +446,7 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
it('creates an ordinary project with DeepSeek Harness', async () => {
|
||||
const { onCreate } = renderSwitcher()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.change(within(dialog).getByLabelText('名称'), {
|
||||
target: { value: 'Harness project' }
|
||||
@@ -473,7 +512,7 @@ describe('ProjectSwitcher runtime fields', () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
renderSwitcher()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('New project'))
|
||||
openCreate('New project')
|
||||
const dialog = screen.getByRole('dialog', { name: 'New project' })
|
||||
expect(within(dialog).getByRole('group', { name: 'Default Runtime for new conversations' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByLabelText('Execution mode')).not.toHaveValue('')
|
||||
@@ -544,23 +583,31 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
name: 'Current project'
|
||||
})
|
||||
const buildHost = await waitFor(() =>
|
||||
within(menu).getByRole('group', {
|
||||
name: 'SSH host: Build host'
|
||||
within(menu).getByRole('menuitem', {
|
||||
name: /Build host/u
|
||||
})
|
||||
)
|
||||
const deployHost = within(menu).getByRole('group', {
|
||||
name: 'SSH host: Deploy host'
|
||||
const deployHost = within(menu).getByRole('menuitem', {
|
||||
name: /Deploy host/u
|
||||
})
|
||||
expect(buildHost).toHaveTextContent('Connecting')
|
||||
expect(buildHost).toHaveTextContent('/srv/api')
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /\/srv\/api/u })).toHaveTextContent('/srv/api')
|
||||
expect(deployHost).toHaveTextContent('Ready')
|
||||
expect(deployHost).toHaveTextContent('/srv/web')
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /\/srv\/web/u })).toHaveTextContent('/srv/web')
|
||||
expect(within(menu).queryByText(/Managed SSH/u)).not.toBeInTheDocument()
|
||||
|
||||
act(() => api.emitConnectionStatus('ready'))
|
||||
await waitFor(() =>
|
||||
expect(buildHost).toHaveTextContent('Ready')
|
||||
)
|
||||
fireEvent.click(buildHost)
|
||||
expect(buildHost).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(within(menu).queryByRole('menuitemradio', { name: /\/srv\/api/u })).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '/srv/api' } })
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /\/srv\/api/u })).toBeInTheDocument()
|
||||
expect(within(menu).queryByRole('menuitemradio', { name: /\/srv\/web/u })).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } })
|
||||
expect(buildHost).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('does not let an older Host snapshot overwrite a newer connection event', async () => {
|
||||
@@ -618,8 +665,8 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const hostGroup = await screen.findByRole('group', {
|
||||
name: 'SSH host: Build host'
|
||||
const hostGroup = await screen.findByRole('menuitem', {
|
||||
name: /Build host/u
|
||||
})
|
||||
expect(hostGroup).toHaveTextContent('Ready')
|
||||
expect(hostGroup).not.toHaveTextContent('Disconnected')
|
||||
@@ -669,7 +716,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Current project' })
|
||||
).toHaveTextContent('Restoring remote Agent…')
|
||||
).toHaveAccessibleDescription('No activity Restoring remote Agent…')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Current project' })
|
||||
)
|
||||
@@ -694,6 +741,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
|
||||
}
|
||||
const props = {
|
||||
...menuProps,
|
||||
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
|
||||
onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
|
||||
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
|
||||
@@ -710,7 +758,8 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
)
|
||||
const { rerender, unmount } = render(view(remote.id))
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
expect(within(trigger).getByRole('status')).toHaveTextContent('Recovery completed')
|
||||
expect(trigger).toHaveAccessibleDescription('No activity Recovery completed')
|
||||
expect(trigger).not.toContainElement(screen.getByRole('status'))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
|
||||
rerender(view(project.id))
|
||||
fireEvent.click(trigger)
|
||||
@@ -729,13 +778,15 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const next = { ...completed, requestId: '00000000-0000-4000-8000-000000000302' }
|
||||
rerender(view(remote.id, { ...next, stage: 'network' }))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(within(trigger).getByRole('status')).toHaveAttribute('aria-busy', 'true')
|
||||
expect(document.getElementById(trigger.getAttribute('aria-describedby')!.split(' ')[1]!)!.querySelector('[role="status"]')).toHaveAttribute('aria-busy', 'true')
|
||||
rerender(view(remote.id, next))
|
||||
expect(screen.getAllByText('Recovery completed')).toHaveLength(2)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
rerender(view(remote.id, { ...next, requestId: 'new-request' }))
|
||||
unmount()
|
||||
// Focusing the shared menu schedules jsdom's zero-delay selectionchange.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -750,6 +801,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const second = { ...remote, id: 'second-project', name: 'Second remote' }
|
||||
const onRetryRecovery = vi.fn(async () => undefined)
|
||||
const props = {
|
||||
...menuProps,
|
||||
activeProjectId: project.id,
|
||||
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
|
||||
onRemoteCommitted: vi.fn(), onSelect: vi.fn(), onRetryRecovery,
|
||||
@@ -807,6 +859,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const onRetryRecovery = vi.fn(async () => undefined)
|
||||
render(
|
||||
<ProjectSwitcher
|
||||
{...menuProps}
|
||||
activeProjectId={remoteProject.id}
|
||||
onArchive={vi.fn(async () => undefined)}
|
||||
onCreate={vi.fn()}
|
||||
@@ -878,7 +931,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
).not.toBeInTheDocument()
|
||||
expect(within(menu).queryByText('Remote project')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('New project'))
|
||||
openCreate('New project')
|
||||
const dialog = screen.getByRole('dialog', { name: 'New project' })
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: 'Managed SSH' })
|
||||
@@ -978,7 +1031,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const api = installRemoteApi()
|
||||
const { onSelectRoot } = renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('New project'))
|
||||
openCreate('New project')
|
||||
const dialog = screen.getByRole('dialog', { name: 'New project' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: 'Managed SSH' })
|
||||
@@ -1017,7 +1070,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('starts from the home directory when the typed path is invalid', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1058,7 +1111,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
}
|
||||
)
|
||||
const { onSelectRoot } = renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1120,7 +1173,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('keeps the outer dialog open and restores trigger focus after cancel and Escape', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1180,7 +1233,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('closes the new-project dialog through its top-right button', () => {
|
||||
renderSwitcher()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
const close = within(dialog).getByRole('button', {
|
||||
name: '关闭新建项目'
|
||||
@@ -1206,7 +1259,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('Permission denied'))
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1249,7 +1302,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it.each(['opencode', 'continue'])('saves %s Execute in one request without extra confirmation checklists', async (provider) => {
|
||||
const api = installRemoteApi()
|
||||
const { onRemoteCommitted } = renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1311,7 +1364,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
})
|
||||
)
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1347,7 +1400,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const api = installRemoteApi()
|
||||
api.save.mockRejectedValueOnce(new Error('Runtime unavailable'))
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1380,7 +1433,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
const api = installRemoteApi()
|
||||
api.save.mockImplementationOnce(() => new Promise(() => undefined))
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1454,7 +1507,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('uses the local validated Host record and defers remote checks to the requested action', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
const dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1531,7 +1584,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
it('never probes remote environments when the project form opens or reopens', async () => {
|
||||
const api = installRemoteApi()
|
||||
renderSwitcher()
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
let dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1546,7 +1599,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
within(dialog).getByRole('button', { name: '取消' })
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新建项目'))
|
||||
openCreate('新建项目')
|
||||
dialog = screen.getByRole('dialog', { name: '新建项目' })
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '托管 SSH' })
|
||||
@@ -1556,7 +1609,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(api.getSnapshot).toHaveBeenCalledTimes(2)
|
||||
expect(api.getSnapshot).toHaveBeenCalledTimes(4)
|
||||
expect(api.getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
expect(api.updateRemoteEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -7,16 +7,14 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
RadioTower,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, memo } from 'react'
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, memo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
@@ -34,7 +32,6 @@ import {
|
||||
} from '../../shared/remote-project-candidate-contracts'
|
||||
import type {
|
||||
SshDirectoryBrowseResult,
|
||||
SshHost,
|
||||
SshHostAgentConnectionState,
|
||||
SshHostsSnapshot
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
@@ -58,12 +55,19 @@ import { ProjectWorkModeFields } from './ProjectWorkModeFields'
|
||||
import { ChannelIcon } from './ChannelIcon'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import { FloatingPortal } from './FloatingPortal'
|
||||
import { WorkspaceMenu } from './WorkspaceMenu'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import type { ConversationStore } from './conversation-store'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
|
||||
type ProjectSwitcherProps = {
|
||||
projects: AssistantProject[]
|
||||
activeProjectId: string
|
||||
conversationStore: ConversationStore
|
||||
activities: ConversationActivity[]
|
||||
visible?: boolean
|
||||
onOpenConversation: (conversationId: string) => void
|
||||
onNewConversation: (projectId: string) => void
|
||||
activityByProjectId?: Record<string, ProjectActivityCountsProps>
|
||||
remoteProjectsEnabled?: boolean
|
||||
runtimeSettings?: RuntimeSettings
|
||||
@@ -94,6 +98,8 @@ type RemoteHostReadiness =
|
||||
| { status: 'ready' }
|
||||
| { status: 'unready' }
|
||||
|
||||
const emptyHosts: SshHostsSnapshot['hosts'] = []
|
||||
|
||||
function ProjectRecoveryStatus({
|
||||
state
|
||||
}: {
|
||||
@@ -215,6 +221,11 @@ function isRemoteAbsolutePath(value: string): boolean {
|
||||
function ProjectSwitcherView({
|
||||
projects,
|
||||
activeProjectId,
|
||||
conversationStore,
|
||||
activities,
|
||||
visible = true,
|
||||
onOpenConversation,
|
||||
onNewConversation,
|
||||
activityByProjectId = {},
|
||||
remoteProjectsEnabled = false,
|
||||
runtimeSettings,
|
||||
@@ -229,6 +240,9 @@ function ProjectSwitcherView({
|
||||
recoveryByProjectId = {}
|
||||
}: ProjectSwitcherProps): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
useLayoutEffect(() => {
|
||||
conversationStore.recordActivityStatuses(activities)
|
||||
}, [activities, conversationStore])
|
||||
const [dialogMode, setDialogMode] = useState<
|
||||
'create' | 'settings'
|
||||
>()
|
||||
@@ -275,18 +289,31 @@ function ProjectSwitcherView({
|
||||
useState<string>()
|
||||
const [remoteDirectoryListing, setRemoteDirectoryListing] =
|
||||
useState<SshDirectoryBrowseResult>()
|
||||
const createButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const directoryPickerTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const directoryPickerRef = useRef<HTMLDivElement>(null)
|
||||
const directoryBrowseRequestRef = useRef(0)
|
||||
const projectPickerRef = useRef<HTMLDivElement>(null)
|
||||
const projectPickerButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const projectPickerMenuRef = useRef<HTMLDivElement>(null)
|
||||
const menuId = useId()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const restoreFocusTarget = useRef<
|
||||
'create' | 'picker' | undefined
|
||||
>(undefined)
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
|
||||
if (!visible && projectMenuOpen) setProjectMenuOpen(false)
|
||||
const closeProjectMenu = useCallback(() => setProjectMenuOpen(false), [])
|
||||
const visibleActivities = useMemo(() => {
|
||||
const visibleIds = new Set(projects.filter((project) => project.kind === 'channel' ||
|
||||
project.executionSpace.kind !== 'ssh' || remoteProjectsEnabled).map((project) => project.id))
|
||||
return activities.filter((activity) => !activity.projectId || visibleIds.has(activity.projectId))
|
||||
}, [activities, projects, remoteProjectsEnabled])
|
||||
const activityCounts = useMemo(() => {
|
||||
const counts = { attention: 0, running: 0, completed: 0 }
|
||||
for (const activity of visibleActivities) {
|
||||
counts[activity.status === 'running' || activity.status === 'completed' ? activity.status : 'attention'] += 1
|
||||
}
|
||||
return counts
|
||||
}, [visibleActivities])
|
||||
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
|
||||
useState<string>()
|
||||
const [dismissedRecoveryByProjectId, setDismissedRecoveryByProjectId] =
|
||||
@@ -331,13 +358,13 @@ function ProjectSwitcherView({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleRecoveryByProjectId = Object.fromEntries(
|
||||
const visibleRecoveryByProjectId = useMemo(() => Object.fromEntries(
|
||||
Object.entries(recoveryByProjectId).filter(
|
||||
([projectId, state]) =>
|
||||
state.stage !== 'completed' ||
|
||||
dismissedRecoveryByProjectId[projectId] !== state.requestId
|
||||
)
|
||||
)
|
||||
), [recoveryByProjectId, dismissedRecoveryByProjectId])
|
||||
const applySshHostsSnapshot = useCallback(
|
||||
(
|
||||
snapshot: SshHostsSnapshot,
|
||||
@@ -419,58 +446,9 @@ function ProjectSwitcherView({
|
||||
const settingsProject = projects.find(
|
||||
(project) => project.id === settingsProjectId
|
||||
)
|
||||
const localProjects = projects.filter(
|
||||
(project) =>
|
||||
project.kind === 'user' &&
|
||||
project.executionSpace.kind === 'local'
|
||||
)
|
||||
const remoteProjects = projects.filter(
|
||||
(project) =>
|
||||
project.kind === 'user' &&
|
||||
project.executionSpace.kind === 'ssh'
|
||||
)
|
||||
const sshHostById = new Map(
|
||||
(sshHosts?.hosts ?? []).map((host) => [host.id, host])
|
||||
)
|
||||
const remoteProjectGroups = [
|
||||
...remoteProjects.reduce(
|
||||
(
|
||||
groups,
|
||||
project
|
||||
) => {
|
||||
if (project.executionSpace.kind !== 'ssh') {
|
||||
return groups
|
||||
}
|
||||
const hostId = project.executionSpace.hostId
|
||||
const current = groups.get(hostId)
|
||||
if (current) {
|
||||
current.projects.push(project)
|
||||
} else {
|
||||
groups.set(hostId, {
|
||||
hostId,
|
||||
host: sshHostById.get(hostId),
|
||||
projects: [project]
|
||||
})
|
||||
}
|
||||
return groups
|
||||
},
|
||||
new Map<
|
||||
string,
|
||||
{
|
||||
hostId: string
|
||||
host?: SshHost
|
||||
projects: AssistantProject[]
|
||||
}
|
||||
>()
|
||||
).values()
|
||||
]
|
||||
const channelProjects = projects.filter(
|
||||
(project) => project.kind === 'channel'
|
||||
)
|
||||
const userProjects = [
|
||||
...localProjects,
|
||||
...(remoteProjectsEnabled ? remoteProjects : [])
|
||||
]
|
||||
const userProjects = useMemo(() => projects.filter((project) =>
|
||||
project.kind === 'user' && (project.executionSpace.kind === 'local' || remoteProjectsEnabled)
|
||||
), [projects, remoteProjectsEnabled])
|
||||
const busy =
|
||||
saving ||
|
||||
remoteSaving ||
|
||||
@@ -741,64 +719,6 @@ function ProjectSwitcherView({
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!projectMenuOpen) return
|
||||
const anchor = projectPickerButtonRef.current
|
||||
const menu = projectPickerMenuRef.current
|
||||
if (!anchor || !menu) return
|
||||
const position = (): void => {
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const width = Math.min(380, window.innerWidth - 32)
|
||||
menu.style.width = `${width}px`
|
||||
menu.style.left = `${Math.max(16, Math.min(rect.left, window.innerWidth - width - 16))}px`
|
||||
menu.style.top = `${Math.max(16, Math.min(rect.bottom + 8, window.innerHeight - menu.offsetHeight - 16))}px`
|
||||
}
|
||||
position()
|
||||
const observer = new ResizeObserver(position)
|
||||
observer.observe(anchor)
|
||||
observer.observe(menu)
|
||||
window.addEventListener('resize', position)
|
||||
window.addEventListener('scroll', position, true)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', position)
|
||||
window.removeEventListener('scroll', position, true)
|
||||
}
|
||||
}, [projectMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectMenuOpen) {
|
||||
return
|
||||
}
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
const items = Array.from(
|
||||
projectPickerMenuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'[role^="menuitem"]'
|
||||
) ?? []
|
||||
)
|
||||
const initialItem =
|
||||
items.find((item) => item.getAttribute('aria-checked') === 'true') ??
|
||||
items[0]
|
||||
initialItem?.focus()
|
||||
})
|
||||
const closeOutside = (event: Event): void => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!projectPickerRef.current?.contains(event.target) &&
|
||||
!projectPickerMenuRef.current?.contains(event.target)
|
||||
) {
|
||||
setProjectMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside)
|
||||
document.addEventListener('focusin', closeOutside)
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame)
|
||||
document.removeEventListener('pointerdown', closeOutside)
|
||||
document.removeEventListener('focusin', closeOutside)
|
||||
}
|
||||
}, [projectMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!directoryPickerOpen) {
|
||||
return
|
||||
@@ -818,14 +738,14 @@ function ProjectSwitcherView({
|
||||
return activateModalFocus(
|
||||
() => dialogRef.current?.querySelector<HTMLElement>('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)') ??
|
||||
dialogRef.current?.querySelector<HTMLElement>('button:not(:disabled)') ?? null,
|
||||
() => restoreFocusTarget.current === 'create' ? createButtonRef.current : projectPickerButtonRef.current
|
||||
() => projectPickerButtonRef.current
|
||||
)
|
||||
}, [dialogMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogMode) {
|
||||
if (restoreFocusTarget.current === 'create') {
|
||||
createButtonRef.current?.focus()
|
||||
projectPickerButtonRef.current?.focus()
|
||||
} else if (restoreFocusTarget.current === 'picker') {
|
||||
projectPickerButtonRef.current?.focus()
|
||||
}
|
||||
@@ -1104,9 +1024,9 @@ function ProjectSwitcherView({
|
||||
}
|
||||
|
||||
const renderProjectMenuItem = (
|
||||
project: AssistantProject,
|
||||
ProjectIcon: LucideIcon
|
||||
project: AssistantProject
|
||||
): React.JSX.Element => {
|
||||
const ProjectIcon: LucideIcon = project.kind === 'channel' ? RadioTower : Folder
|
||||
const selected = project.id === activeProjectId
|
||||
const projectDisplay = getProjectDisplayText(project, t)
|
||||
const recovery =
|
||||
@@ -1140,16 +1060,11 @@ function ProjectSwitcherView({
|
||||
<button
|
||||
aria-checked={selected}
|
||||
onClick={() => {
|
||||
if (!selected || project.executionSpace.kind === 'ssh') {
|
||||
onSelect(project.id)
|
||||
}
|
||||
onSelect(project.id)
|
||||
setProjectMenuOpen(false)
|
||||
requestAnimationFrame(() =>
|
||||
projectPickerButtonRef.current?.focus()
|
||||
)
|
||||
}}
|
||||
role="menuitemradio"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
{project.kind === 'channel' && project.channel ? (
|
||||
@@ -1160,10 +1075,10 @@ function ProjectSwitcherView({
|
||||
<span>
|
||||
<span className="project-switcher__project-heading">
|
||||
<b>{projectDisplay.name}</b>
|
||||
{activityByProjectId[project.id] && (
|
||||
<ProjectActivityCounts {...activityByProjectId[project.id]!} />
|
||||
)}
|
||||
</span>
|
||||
{activityByProjectId[project.id] && (
|
||||
<ProjectActivityCounts {...activityByProjectId[project.id]!} />
|
||||
)}
|
||||
<small>{detail}</small>
|
||||
{recovery && <ProjectRecoveryStatus state={recovery} />}
|
||||
</span>
|
||||
@@ -1181,7 +1096,7 @@ function ProjectSwitcherView({
|
||||
disabled={retryingRecoveryProjectId === project.id}
|
||||
onClick={() => retryRecovery(project.id)}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
@@ -1195,7 +1110,7 @@ function ProjectSwitcherView({
|
||||
className="project-switcher__menu-settings"
|
||||
onClick={() => openProjectSettings(project)}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<Settings aria-hidden="true" size={14} />
|
||||
@@ -1213,10 +1128,15 @@ function ProjectSwitcherView({
|
||||
>
|
||||
<button
|
||||
aria-expanded={projectMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={projectMenuOpen ? menuId : undefined}
|
||||
aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
className="project-switcher__trigger"
|
||||
onClick={() => setProjectMenuOpen((open) => !open)}
|
||||
aria-describedby={`${menuId}-activity${activeProjectRecovery ? ` ${menuId}-recovery` : ''}`}
|
||||
className="project-switcher__trigger project-switcher__control"
|
||||
onClick={(event) => {
|
||||
event.currentTarget.focus()
|
||||
setProjectMenuOpen((open) => !open)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!projectMenuOpen &&
|
||||
@@ -1232,161 +1152,30 @@ function ProjectSwitcherView({
|
||||
title={activeProjectDisplay?.name}
|
||||
type="button"
|
||||
>
|
||||
<Folder aria-hidden="true" size={16} />
|
||||
<span className="project-switcher__trigger-copy">
|
||||
<span>
|
||||
{activeProjectDisplay?.name ??
|
||||
t('projectSwitcher.selector.empty')}
|
||||
</span>
|
||||
{activeProjectRecovery && (
|
||||
<ProjectRecoveryStatus state={activeProjectRecovery} />
|
||||
)}
|
||||
<span className="project-switcher__activity" id={`${menuId}-activity`}>
|
||||
{visibleActivities.length > 0
|
||||
? <ProjectActivityCounts {...activityCounts} /> : t('projectActivity.idle')}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{projectMenuOpen && (
|
||||
<FloatingPortal anchorRef={projectPickerButtonRef}>
|
||||
<div
|
||||
aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
className="project-switcher__menu"
|
||||
onKeyDown={(event) => {
|
||||
const items = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'[role^="menuitem"]'
|
||||
)
|
||||
)
|
||||
const currentIndex = items.indexOf(
|
||||
document.activeElement as HTMLButtonElement
|
||||
)
|
||||
let nextIndex: number | undefined
|
||||
if (event.key === 'ArrowDown') {
|
||||
nextIndex = (currentIndex + 1) % items.length
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
nextIndex =
|
||||
(currentIndex - 1 + items.length) % items.length
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = items.length - 1
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setProjectMenuOpen(false)
|
||||
projectPickerButtonRef.current?.focus()
|
||||
}
|
||||
const nextItem =
|
||||
nextIndex === undefined ? undefined : items.at(nextIndex)
|
||||
if (nextItem) {
|
||||
event.preventDefault()
|
||||
const currentItem = items.at(currentIndex)
|
||||
if (currentItem) {
|
||||
currentItem.tabIndex = -1
|
||||
}
|
||||
nextItem.tabIndex = 0
|
||||
nextItem.focus()
|
||||
}
|
||||
}}
|
||||
ref={projectPickerMenuRef}
|
||||
role="menu"
|
||||
>
|
||||
{localProjects.length > 0 && (
|
||||
<div
|
||||
aria-label={t(
|
||||
'projectSwitcher.selector.userProjects'
|
||||
)}
|
||||
className="project-switcher__group"
|
||||
role="group"
|
||||
>
|
||||
<strong>
|
||||
{t('projectSwitcher.selector.userProjects')}
|
||||
</strong>
|
||||
{localProjects.map((project) =>
|
||||
renderProjectMenuItem(project, Folder)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{remoteProjectsEnabled &&
|
||||
remoteProjectGroups.length > 0 && (
|
||||
<div
|
||||
aria-label={t(
|
||||
'projectSwitcher.selector.remoteProjects'
|
||||
)}
|
||||
className="project-switcher__group"
|
||||
role="group"
|
||||
>
|
||||
<strong>
|
||||
{t('projectSwitcher.selector.remoteProjects')}
|
||||
</strong>
|
||||
{remoteProjectGroups.map(
|
||||
({ hostId, host, projects: hostProjects }) => {
|
||||
const hostName =
|
||||
host?.name ??
|
||||
t(
|
||||
'projectSwitcher.selector.unavailableHost'
|
||||
)
|
||||
const state =
|
||||
agentConnectionStatusByHostId[hostId] ??
|
||||
'disconnected'
|
||||
return (
|
||||
<div
|
||||
aria-label={t(
|
||||
'projectSwitcher.selector.remoteHostGroup',
|
||||
{ host: hostName }
|
||||
)}
|
||||
className="project-switcher__host-group"
|
||||
key={hostId}
|
||||
role="group"
|
||||
>
|
||||
<div className="project-switcher__host-heading">
|
||||
<span
|
||||
title={
|
||||
host
|
||||
? `${host.username}@${host.hostname}:${host.port}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Server aria-hidden="true" size={14} />
|
||||
<b>{hostName}</b>
|
||||
</span>
|
||||
<span
|
||||
className={`status-badge project-switcher__host-status project-switcher__host-status--${state}`}
|
||||
>
|
||||
{t(
|
||||
`projectSwitcher.selector.connectionStates.${state}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{hostProjects.map((project) =>
|
||||
renderProjectMenuItem(project, Folder)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{channelProjects.length > 0 && (
|
||||
<div
|
||||
aria-label={t(
|
||||
'projectSwitcher.selector.channelProjects'
|
||||
)}
|
||||
className="project-switcher__group"
|
||||
role="group"
|
||||
>
|
||||
<strong>
|
||||
{t('projectSwitcher.selector.channelProjects')}
|
||||
</strong>
|
||||
{channelProjects.map((project) =>
|
||||
renderProjectMenuItem(project, RadioTower)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label={t('projectSwitcher.selector.create')}
|
||||
className="icon-button"
|
||||
onClick={() => {
|
||||
{activeProjectRecovery && <span id={`${menuId}-recovery`} className="project-switcher__recovery-feedback">
|
||||
<ProjectRecoveryStatus state={activeProjectRecovery} />
|
||||
</span>}
|
||||
{projectMenuOpen && <WorkspaceMenu
|
||||
id={menuId} projects={projects} activeProjectId={activeProjectId}
|
||||
activities={visibleActivities} conversationStore={conversationStore} remoteProjectsEnabled={remoteProjectsEnabled}
|
||||
hosts={sshHosts?.hosts ?? emptyHosts} connectionStates={agentConnectionStatusByHostId}
|
||||
anchorRef={projectPickerButtonRef} controlsRef={projectPickerRef}
|
||||
renderProject={renderProjectMenuItem} onClose={closeProjectMenu}
|
||||
onNewConversation={onNewConversation} onOpenConversation={onOpenConversation}
|
||||
onCreateProject={() => {
|
||||
setProjectMenuOpen(false)
|
||||
setError(undefined)
|
||||
setConfirmingDelete(false)
|
||||
@@ -1402,12 +1191,8 @@ function ProjectSwitcherView({
|
||||
})
|
||||
restoreFocusTarget.current = 'create'
|
||||
setDialogMode('create')
|
||||
}}
|
||||
ref={createButtonRef}
|
||||
type="button"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
}} />}
|
||||
</div>
|
||||
</div>
|
||||
{dialogMode && createPortal(
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import type { Conversation } from './chat-conversation'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import { createConversationStores } from './conversation-store'
|
||||
import { ProjectSwitcher } from './ProjectSwitcher'
|
||||
import i18n from './i18n'
|
||||
|
||||
const local: AssistantProject = {
|
||||
id: 'local', name: 'Local workspace', description: '', rootPath: 'C:/workspace',
|
||||
executionSpace: { kind: 'local', rootPath: 'C:/workspace' }, defaultWorkMode: 'ask',
|
||||
kind: 'user', status: 'active', createdAt: '2026-10-01T00:00:00Z', updatedAt: '2026-10-01T00:00:00Z'
|
||||
}
|
||||
const other = { ...local, id: 'other', name: 'Other workspace', rootPath: 'C:/other' }
|
||||
const conversation = (id: string, projectId = local.id, updatedAt = 1): Conversation => ({ id, projectId, title: id, updatedAt, messages: [] })
|
||||
|
||||
function setup({ projects = [local, other], conversations = [conversation('Local chat'), conversation('Other chat', other.id)],
|
||||
activities = [] as ConversationActivity[], remoteProjectsEnabled = false } = {}) {
|
||||
const store = createConversationStores(conversations, { flushIntervalMs: 250 }).conversations
|
||||
const actions = { onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(), onRemoteCommitted: vi.fn(),
|
||||
onSelect: vi.fn(), onSelectRoot: vi.fn(), onUpdate: vi.fn(), onOpenConversation: vi.fn(), onNewConversation: vi.fn() }
|
||||
const props = { ...actions, projects, conversationStore: store, activeProjectId: local.id, activities, remoteProjectsEnabled }
|
||||
const renders = vi.fn()
|
||||
const view = render(<Profiler id="workspace" onRender={renders}><ProjectSwitcher {...props} /></Profiler>)
|
||||
return { ...view, actions, store, props, renders }
|
||||
}
|
||||
|
||||
afterEach(async () => { cleanup(); vi.restoreAllMocks(); await i18n.changeLanguage('zh-CN') })
|
||||
|
||||
describe('production workspace menu', () => {
|
||||
it('puts All Projects first in a fixed tab row and filters categories only when activated', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const remote: AssistantProject = { ...other, id: 'remote', name: 'Remote workspace', rootPath: '/srv/workspace',
|
||||
executionSpace: { kind: 'ssh', hostId: 'host', remoteRootPath: '/srv/workspace' } }
|
||||
const channel: AssistantProject = { ...other, id: 'channel', name: 'Team channel', kind: 'channel', channel: 'wecom' }
|
||||
const { actions } = setup({ projects: [local, remote, channel], remoteProjectsEnabled: true,
|
||||
conversations: [conversation('Local chat'), conversation('Remote chat', remote.id)] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const tabs = screen.getByRole('tablist', { name: 'Project categories' })
|
||||
expect(within(tabs).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['All projects', 'Local', 'Remote', 'Channels'])
|
||||
const list = screen.getByRole('menu', { name: 'Current project' })
|
||||
expect(list).not.toContainElement(tabs)
|
||||
expect(Array.from(list.querySelectorAll('.workspace-menu__project-category'), (heading) => heading.textContent))
|
||||
.toEqual(['Local', 'Remote', 'Channels'])
|
||||
const all = within(tabs).getByRole('tab', { name: 'All projects' })
|
||||
fireEvent.click(all)
|
||||
expect(screen.getByRole('region', { name: 'All projects' })).toHaveClass('workspace-menu__activity')
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(3)
|
||||
fireEvent.pointerOver(within(tabs).getByRole('tab', { name: 'Channels' }), { pointerType: 'mouse' })
|
||||
expect(all).toHaveAttribute('aria-selected', 'true')
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(3)
|
||||
|
||||
for (const [label, project] of [['Local', local], ['Remote', remote], ['Channels', channel]] as const) {
|
||||
const tab = within(tabs).getByRole('tab', { name: label })
|
||||
fireEvent.click(tab)
|
||||
expect(tab).toHaveAttribute('aria-selected', 'true')
|
||||
const panel = screen.getByRole('tabpanel', { name: label })
|
||||
expect(tab).toHaveAttribute('aria-controls', panel.id)
|
||||
expect(panel).toContainElement(list)
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(1)
|
||||
expect(within(list).getByRole('menuitemradio', { name: new RegExp(project.name) })).toBeVisible()
|
||||
}
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'workspace' } })
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(2)
|
||||
expect(within(list).getByRole('menuitemradio', { name: /Local workspace/ })).toBeVisible()
|
||||
const remoteRow = within(list).getByRole('menuitemradio', { name: /Remote workspace/ })
|
||||
fireEvent.pointerOver(remoteRow, { pointerType: 'mouse' })
|
||||
const preview = screen.getByRole('region', { name: remote.name })
|
||||
expect(within(preview).getByRole('button', { name: 'Remote chat' })).toBeVisible()
|
||||
expect(within(preview).queryByText(remote.name)).not.toBeInTheDocument()
|
||||
expect(within(tabs).getByRole('tab', { name: 'Channels' })).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } })
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(1)
|
||||
expect(within(list).getByRole('menuitemradio', { name: /Team channel/ })).toBeVisible()
|
||||
fireEvent.pointerOver(all, { pointerType: 'mouse' })
|
||||
expect(screen.getByRole('region', { name: remote.name })).toBeVisible()
|
||||
fireEvent.click(all)
|
||||
expect(within(list).getAllByRole('menuitemradio')).toHaveLength(3)
|
||||
expect(within(screen.getByRole('region', { name: 'All projects' })).getByText(remote.name)).toBeVisible()
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
expect(actions.onOpenConversation).not.toHaveBeenCalled()
|
||||
expect(actions.onNewConversation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps shared tab keyboard selection and omits Remote when it is disabled', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const tabs = screen.getByRole('tablist', { name: 'Project categories' })
|
||||
expect(within(tabs).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['All projects', 'Local', 'Channels'])
|
||||
const all = within(tabs).getByRole('tab', { name: 'All projects' })
|
||||
expect(all).toHaveFocus()
|
||||
fireEvent.keyDown(all, { key: 'ArrowRight' })
|
||||
const localTab = within(tabs).getByRole('tab', { name: 'Local' })
|
||||
expect(localTab).toHaveFocus()
|
||||
expect(localTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(all).toHaveAttribute('tabindex', '-1')
|
||||
fireEvent.keyDown(localTab, { key: 'End' })
|
||||
expect(within(tabs).getByRole('tab', { name: 'Channels' })).toHaveFocus()
|
||||
expect(screen.getByRole('tabpanel', { name: 'Channels' })).not.toContainElement(screen.queryByRole('menuitemradio'))
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
|
||||
expect(all).toHaveFocus()
|
||||
expect(screen.getByRole('region', { name: 'All projects' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('previews without navigation or IPC, keeps current separate, and manages settings independently', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const get = vi.fn()
|
||||
const listSummaries = vi.fn()
|
||||
const original = window.goodbuddy
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { conversations: { get, listSummaries } } })
|
||||
try {
|
||||
const { actions } = setup()
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
fireEvent.click(trigger)
|
||||
const current = screen.getByRole('menuitemradio', { name: /Local workspace/ })
|
||||
const target = screen.getByRole('menuitemradio', { name: /Other workspace/ })
|
||||
fireEvent.pointerOver(target, { pointerType: 'mouse' })
|
||||
const list = screen.getByRole('list', { name: 'Conversations' })
|
||||
expect(within(list).getByRole('button', { name: 'Other chat' })).toBeVisible()
|
||||
expect(within(list).queryByText('Other workspace')).not.toBeInTheDocument()
|
||||
const scopedPane = screen.getByRole('region', { name: 'Other workspace' })
|
||||
expect(scopedPane).toHaveClass('workspace-menu__activity')
|
||||
expect(within(scopedPane).queryByText('Other workspace')).not.toBeInTheDocument()
|
||||
expect(current).toHaveAttribute('aria-checked', 'true')
|
||||
expect(target).toHaveAttribute('aria-checked', 'false')
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
expect(actions.onOpenConversation).not.toHaveBeenCalled()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(listSummaries).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Manage project Other workspace' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Project settings' })).toBeVisible()
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close project settings' }))
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /Other workspace/ }))
|
||||
expect(actions.onSelect).toHaveBeenCalledExactlyOnceWith(other.id)
|
||||
expect(screen.queryByRole('dialog', { name: 'Projects and activity' })).not.toBeInTheDocument()
|
||||
} finally {
|
||||
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: original })
|
||||
}
|
||||
})
|
||||
|
||||
it('opens current-project activity from the unified button and offers all projects inside', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const conversations = Array.from({ length: 12 }, (_, i) => conversation(`Finished ${i}`, local.id, 100 - i))
|
||||
const activities: ConversationActivity[] = conversations.map((row) => ({ conversationId: row.id, projectId: local.id,
|
||||
projectName: local.name, title: row.title, status: 'completed' }))
|
||||
activities.push({ conversationId: 'Running', projectId: other.id, projectName: other.name, title: 'Running chat', status: 'running' })
|
||||
const { actions, rerender, props } = setup({ conversations, activities })
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
expect(screen.getAllByRole('button')).toEqual([trigger])
|
||||
expect(trigger).toHaveAccessibleDescription('1 running 12 completed')
|
||||
fireEvent.click(within(trigger).getByText('12 completed'))
|
||||
const menu = screen.getByRole('dialog', { name: 'Projects and activity' })
|
||||
expect(trigger).toHaveAttribute('aria-controls', menu.id)
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('region', { name: local.name })).toBeVisible()
|
||||
const all = screen.getByRole('tab', { name: 'All projects' })
|
||||
expect(all).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getAllByRole('tab')[0]).toBe(all)
|
||||
const list = screen.getByRole('list', { name: 'Conversations' })
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(10)
|
||||
expect(within(list).queryByText('Running chat')).not.toBeInTheDocument()
|
||||
fireEvent.click(all)
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(11)
|
||||
expect(within(list).getAllByText(local.name)).toHaveLength(10)
|
||||
fireEvent.click(within(screen.getByRole('group', { name: 'Filter conversation status' })).getByRole('button', { name: 'Completed' }))
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(12)
|
||||
fireEvent.click(within(list).getByRole('button', { name: /Finished 0/ }))
|
||||
expect(actions.onOpenConversation).toHaveBeenCalledExactlyOnceWith('Finished 0')
|
||||
rerender(<ProjectSwitcher {...props} activities={activities.filter((row) => row.conversationId !== 'Finished 0')} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
expect(within(screen.getByRole('list', { name: 'Conversations' })).getByRole('button', { name: 'Finished 0' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: local.name })).toBeVisible()
|
||||
})
|
||||
|
||||
it('searches across categories, creates in preview scope, and explains channel restrictions', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const channel: AssistantProject = { ...other, id: 'channel', name: 'Team channel', kind: 'channel', channel: 'wecom' }
|
||||
const { actions } = setup({ projects: [local, other, channel] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local' }))
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'team' } })
|
||||
expect(screen.queryByRole('menuitemradio', { name: /Local workspace/ })).not.toBeInTheDocument()
|
||||
const channelRow = screen.getByRole('menuitemradio', { name: /Team channel/ })
|
||||
fireEvent.pointerOver(channelRow, { pointerType: 'mouse' })
|
||||
expect(screen.getByRole('button', { name: 'New conversation' })).toBeDisabled()
|
||||
expect(screen.getByText(/automatically/i, { selector: '.workspace-menu__channel-note' })).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'C:/other' } })
|
||||
fireEvent.pointerOver(screen.getByRole('menuitemradio', { name: /Other workspace/ }), { pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New conversation' }))
|
||||
expect(actions.onNewConversation).toHaveBeenCalledExactlyOnceWith(other.id)
|
||||
})
|
||||
|
||||
it('windows large project lists while keyboard End reaches the last row and Escape restores focus', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
setup({ projects: [local, ...Array.from({ length: 400 }, (_, i) => ({ ...other, id: `p${i}`, name: `Project ${i}` }))] })
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('menuitemradio').length).toBeLessThan(120)
|
||||
fireEvent.keyDown(screen.getByRole('menu', { name: 'Current project' }), { key: 'End' })
|
||||
expect(screen.getByRole('menuitemradio', { name: /Project 399/ })).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not rerender the menu for message content deltas with unchanged summaries', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const initial = { ...conversation('Streaming chat'), messages: [{ id: 'm', role: 'assistant' as const, content: 'first', createdAt: 1, state: 'streaming' as const }] }
|
||||
const { store, renders } = setup({ conversations: [initial] })
|
||||
act(() => store.rememberOpened(initial.id))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const activityTimes = store.getActivityTimes()
|
||||
renders.mockClear()
|
||||
act(() => store.set((rows) => rows.map((row) => ({ ...row, updatedAt: Date.now(), messages: [{ ...initial.messages[0]!, content: 'streaming text' }] }))))
|
||||
expect(renders).not.toHaveBeenCalled()
|
||||
expect(store.getActivityTimes()).toBe(activityTimes)
|
||||
})
|
||||
|
||||
it('records activity transitions while closed and promotes task-only completions when opened', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const conversations = Array.from({ length: 12 }, (_, i) => conversation(`Recent ${i}`, local.id, 1000 - i))
|
||||
const activity: ConversationActivity = { conversationId: 'task-only', projectId: local.id,
|
||||
projectName: local.name, title: 'Older background task', status: 'running' }
|
||||
const { rerender, props, store } = setup({ conversations, activities: [activity] })
|
||||
rerender(<ProjectSwitcher {...props} activities={[{ ...activity, status: 'completed' }]} />)
|
||||
expect(store.getActivityTimes().get(activity.conversationId)).toBeGreaterThan(1000)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const rows = within(screen.getByRole('list', { name: 'Conversations' })).getAllByRole('button')
|
||||
expect(rows).toHaveLength(10)
|
||||
expect(rows[0]).toHaveTextContent(activity.title)
|
||||
})
|
||||
|
||||
it('keeps a long completed list bounded and lets the keyboard open its last exact ID', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const conversations = Array.from({ length: 300 }, (_, i) => conversation(`Result ${i}`, local.id, 1000 - i))
|
||||
const activities: ConversationActivity[] = conversations.map((row) => ({ conversationId: row.id, projectId: local.id,
|
||||
projectName: local.name, title: row.title, status: 'completed' }))
|
||||
const { actions } = setup({ conversations, activities })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
fireEvent.click(within(screen.getByRole('group', { name: 'Filter conversation status' })).getByRole('button', { name: 'Completed' }))
|
||||
const list = screen.getByRole('list', { name: 'Conversations' })
|
||||
expect(within(list).getAllByRole('button').length).toBeLessThan(120)
|
||||
fireEvent.keyDown(list, { key: 'End' })
|
||||
const last = within(list).getByRole('button', { name: 'Result 299 Completed' })
|
||||
expect(last).toHaveFocus()
|
||||
expect(last.closest('[role="listitem"]')).toHaveAttribute('aria-setsize', '300')
|
||||
fireEvent.click(last)
|
||||
expect(actions.onOpenConversation).toHaveBeenCalledExactlyOnceWith('Result 299')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
import { ChevronDown, Plus, Server, X } from 'lucide-react'
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import type { SshHost, SshHostAgentConnectionState } from '../../shared/ssh-host-contracts'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import type { ConversationStore } from './conversation-store'
|
||||
import { FloatingPortal } from './FloatingPortal'
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
import { useListWindow } from './use-list-window'
|
||||
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
|
||||
import { useWorkspaceConversations, workspaceConversationRows, type WorkspaceFilter } from './workspace-menu-selectors'
|
||||
import './workspace-menu.css'
|
||||
|
||||
type ProjectCategory = 'allProjects' | 'local' | 'remote' | 'channels'
|
||||
type ProjectRow =
|
||||
| { id: string; kind: 'host'; hostId: string; label: string; state: SshHostAgentConnectionState }
|
||||
| { id: string; kind: 'project'; project: AssistantProject }
|
||||
|
||||
export type WorkspaceMenuProps = {
|
||||
projects: AssistantProject[]
|
||||
activeProjectId: string
|
||||
activities: ConversationActivity[]
|
||||
conversationStore: ConversationStore
|
||||
remoteProjectsEnabled: boolean
|
||||
hosts: readonly SshHost[]
|
||||
connectionStates: Record<string, SshHostAgentConnectionState>
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>
|
||||
controlsRef: React.RefObject<HTMLDivElement | null>
|
||||
id: string
|
||||
renderProject: (project: AssistantProject) => ReactNode
|
||||
onClose: () => void
|
||||
onCreateProject: () => void
|
||||
onNewConversation: (projectId: string) => void
|
||||
onOpenConversation: (conversationId: string) => void
|
||||
}
|
||||
|
||||
export function WorkspaceMenu({
|
||||
projects, activeProjectId, activities, conversationStore, remoteProjectsEnabled,
|
||||
hosts, connectionStates, anchorRef, controlsRef, id, renderProject,
|
||||
onClose, onCreateProject, onNewConversation, onOpenConversation
|
||||
}: WorkspaceMenuProps): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const { t: tApp } = useTranslation('app')
|
||||
const titleId = useId()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const projectListRef = useRef<HTMLDivElement>(null)
|
||||
const conversationListRef = useRef<HTMLDivElement>(null)
|
||||
const categoriesRef = useRef<HTMLDivElement>(null)
|
||||
const [category, setCategory] = useState<ProjectCategory>('allProjects')
|
||||
if (category === 'remote' && !remoteProjectsEnabled) setCategory('allProjects')
|
||||
const [scope, setScope] = useState<string | null>(activeProjectId)
|
||||
const [filter, setFilter] = useState<WorkspaceFilter>('all')
|
||||
const [query, setQuery] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const [keyboardProject, setKeyboardProject] = useState<string>()
|
||||
const [keyboardConversation, setKeyboardConversation] = useState<string>()
|
||||
const activityTimes = useSyncExternalStore(conversationStore.subscribeActivityTimes, conversationStore.getActivityTimes)
|
||||
const conversations = useWorkspaceConversations(conversationStore, tApp('conversation.defaultTitle'), activityTimes)
|
||||
const visibleProjects = useMemo(() => projects.filter((project) =>
|
||||
project.kind === 'channel' || project.executionSpace.kind !== 'ssh' || remoteProjectsEnabled
|
||||
), [projects, remoteProjectsEnabled])
|
||||
const projectById = useMemo(() => new Map(visibleProjects.map((project) => [project.id, project])), [visibleProjects])
|
||||
const availableActivities = useMemo(() => activities.filter((activity) => !activity.projectId || projectById.has(activity.projectId)), [activities, projectById])
|
||||
const availableConversations = useMemo(() => conversations.filter((conversation) => {
|
||||
const project = projectById.get(conversation.projectId ?? '')
|
||||
return (!conversation.projectId || project) && (project?.kind !== 'channel' || conversation.channel)
|
||||
}), [conversations, projectById])
|
||||
const categoryTabs = useMemo(() => {
|
||||
const categories: ProjectCategory[] = ['allProjects', 'local', ...(remoteProjectsEnabled ? ['remote' as const] : []), 'channels']
|
||||
return categories.map((id) => ({ id, label: t(`workspaceMenu.${id}`) }))
|
||||
}, [remoteProjectsEnabled, t])
|
||||
const projectRows = useMemo(() => {
|
||||
const hostById = new Map(hosts.map((host) => [host.id, host]))
|
||||
const search = query.trim().toLocaleLowerCase()
|
||||
const matching = visibleProjects.filter((project) => {
|
||||
const host = project.executionSpace.kind === 'ssh' ? hostById.get(project.executionSpace.hostId) : undefined
|
||||
if (search) return `${getProjectDisplayText(project, t).name} ${project.rootPath} ${host?.name ?? ''} ${host?.hostname ?? ''} ${project.channel ?? ''}`.toLocaleLowerCase().includes(search)
|
||||
const kind = project.kind === 'channel' ? 'channels' : project.executionSpace.kind === 'ssh' ? 'remote' : 'local'
|
||||
return category === 'allProjects' || category === kind
|
||||
})
|
||||
const rows: ProjectRow[] = []
|
||||
const addProjects = (items: AssistantProject[]): void => {
|
||||
for (const project of items) rows.push({ id: project.id, kind: 'project', project })
|
||||
}
|
||||
addProjects(matching.filter((project) => project.kind !== 'channel' && project.executionSpace.kind === 'local'))
|
||||
if (remoteProjectsEnabled) {
|
||||
const groups = new Map<string, AssistantProject[]>()
|
||||
for (const project of matching) {
|
||||
if (project.kind === 'channel' || project.executionSpace.kind !== 'ssh') continue
|
||||
const hostId = project.executionSpace.hostId
|
||||
const group = groups.get(hostId) ?? []
|
||||
group.push(project)
|
||||
groups.set(hostId, group)
|
||||
}
|
||||
for (const [hostId, group] of groups) {
|
||||
rows.push({ id: `host:${hostId}`, kind: 'host', hostId,
|
||||
label: hostById.get(hostId)?.name ?? t('projectSwitcher.selector.unavailableHost'),
|
||||
state: connectionStates[hostId] ?? 'disconnected' })
|
||||
if (search || !collapsed.has(hostId)) addProjects(group)
|
||||
}
|
||||
}
|
||||
addProjects(matching.filter((project) => project.kind === 'channel'))
|
||||
return rows
|
||||
}, [visibleProjects, hosts, query, category, t, remoteProjectsEnabled, connectionStates, collapsed])
|
||||
const rows = useMemo(() => workspaceConversationRows(availableConversations, availableActivities, scope, filter, activityTimes), [availableConversations, availableActivities, scope, filter, activityTimes])
|
||||
const projectIds = useMemo(() => projectRows.map((row) => row.id), [projectRows])
|
||||
const conversationIds = useMemo(() => rows.map((row) => row.id), [rows])
|
||||
const projectWindow = useListWindow({ ids: projectIds, scope: `${category}:${query}`, scrollRef: projectListRef,
|
||||
keepIds: [keyboardProject], enabled: true, resetScrollOnScopeChange: true, estimatedRowHeight: 64 })
|
||||
const conversationWindow = useListWindow({ ids: conversationIds, scope: `${scope}:${filter}`, scrollRef: conversationListRef,
|
||||
keepIds: [keyboardConversation], enabled: true, resetScrollOnScopeChange: true, estimatedRowHeight: 56 })
|
||||
const newProject = projectById.get(scope ?? activeProjectId)
|
||||
const scopeName = scope === null ? t('workspaceMenu.allProjects')
|
||||
: projectById.has(scope) ? getProjectDisplayText(projectById.get(scope)!, t).name : t('projectActivity.unassigned')
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const menu = menuRef.current
|
||||
const anchor = anchorRef.current
|
||||
if (!menu || !anchor) return
|
||||
const position = (): void => {
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const width = Math.min(920, window.innerWidth - 32)
|
||||
menu.style.width = `${width}px`
|
||||
menu.style.left = `${Math.max(16, Math.min(rect.left, window.innerWidth - width - 16))}px`
|
||||
menu.style.top = `${Math.max(16, Math.min(rect.bottom + 8, window.innerHeight - menu.offsetHeight - 16))}px`
|
||||
}
|
||||
position()
|
||||
const observer = new ResizeObserver(position)
|
||||
observer.observe(anchor)
|
||||
observer.observe(menu)
|
||||
window.addEventListener('resize', position)
|
||||
window.addEventListener('scroll', position, true)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', position)
|
||||
window.removeEventListener('scroll', position, true)
|
||||
}
|
||||
}, [anchorRef])
|
||||
|
||||
useEffect(() => {
|
||||
const menu = menuRef.current
|
||||
const anchor = anchorRef.current
|
||||
categoriesRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"]')?.focus()
|
||||
const outside = (event: Event): void => {
|
||||
if (!menu?.contains(event.target as Node) && !controlsRef.current?.contains(event.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener('pointerdown', outside)
|
||||
document.addEventListener('focusin', outside)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', outside)
|
||||
document.removeEventListener('focusin', outside)
|
||||
if (document.activeElement === document.body || menu?.contains(document.activeElement)) {
|
||||
anchor?.focus()
|
||||
}
|
||||
}
|
||||
}, [anchorRef, controlsRef, onClose])
|
||||
|
||||
function moveFocus(event: React.KeyboardEvent, list: 'projects' | 'conversations'): void {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
|
||||
const ids = list === 'projects' ? projectIds : conversationIds
|
||||
if (!ids.length) return
|
||||
const current = (event.target as HTMLElement).closest<HTMLElement>('[data-list-window-row]')?.dataset.listWindowRow
|
||||
const index = current ? ids.indexOf(current) : -1
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? ids.length - 1
|
||||
: (index + (event.key === 'ArrowDown' ? 1 : -1) + ids.length) % ids.length
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
flushSync(() => (list === 'projects' ? setKeyboardProject : setKeyboardConversation)(ids[next]))
|
||||
const element = (list === 'projects' ? projectListRef : conversationListRef).current
|
||||
const row = Array.from(element?.querySelectorAll<HTMLElement>('[data-list-window-row]') ?? [])
|
||||
.find((item) => item.dataset.listWindowRow === ids[next])
|
||||
row?.querySelector<HTMLButtonElement>('button')?.focus()
|
||||
row?.scrollIntoView?.({ block: 'nearest' })
|
||||
}
|
||||
|
||||
return <FloatingPortal anchorRef={anchorRef}>
|
||||
<div className="project-switcher__menu workspace-menu" ref={menuRef} id={id} role="dialog" aria-labelledby={titleId}
|
||||
onKeyDown={(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose() }
|
||||
if (event.key === 'ArrowRight' && projectListRef.current?.contains(event.target as Node) && rows.length) {
|
||||
event.preventDefault()
|
||||
flushSync(() => setKeyboardConversation(rows[0]!.id))
|
||||
conversationListRef.current?.querySelector<HTMLButtonElement>('button')?.focus()
|
||||
}
|
||||
if (event.key === 'ArrowLeft' && conversationListRef.current?.contains(event.target as Node)) {
|
||||
event.preventDefault()
|
||||
flushSync(() => setKeyboardProject(scope ?? undefined))
|
||||
const row = Array.from(projectListRef.current?.querySelectorAll<HTMLElement>('[data-list-window-row]') ?? [])
|
||||
.find((item) => item.dataset.listWindowRow === scope)
|
||||
const target = row?.querySelector<HTMLButtonElement>('button') ?? categoriesRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"]')
|
||||
target?.focus()
|
||||
}
|
||||
}}>
|
||||
<header className="workspace-menu__header">
|
||||
<strong id={titleId}>{t('workspaceMenu.title')}</strong>
|
||||
<button type="button" className="icon-button" aria-label={t('workspaceMenu.close')} onClick={onClose}><X size={16} /></button>
|
||||
</header>
|
||||
<div className="workspace-menu__layout">
|
||||
<section className="workspace-menu__projects">
|
||||
<input className="field-control workspace-menu__search" type="search" aria-label={t('workspaceMenu.search')}
|
||||
placeholder={t('workspaceMenu.search')} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<div className="workspace-menu__categories" ref={categoriesRef}>
|
||||
<PageTabs ariaLabel={t('workspaceMenu.categories')} idPrefix={`${id}-categories`} variant="segmented"
|
||||
tabs={categoryTabs} value={category} onChange={(next) => {
|
||||
setCategory(next)
|
||||
if (next === 'allProjects') setScope(null)
|
||||
}} />
|
||||
</div>
|
||||
<div className="workspace-menu__project-panel" role="tabpanel" id={`${id}-categories-panel-${category}`}
|
||||
aria-labelledby={query.trim() ? undefined : `${id}-categories-tab-${category}`}
|
||||
aria-label={query.trim() ? t('workspaceMenu.search') : undefined}>
|
||||
<div className="workspace-menu__project-list" ref={projectListRef} role="menu" aria-label={t('projectSwitcher.selector.ariaLabel')}
|
||||
onScroll={projectWindow.onScroll} onFocus={projectWindow.onFocus} onBlur={projectWindow.onBlur}
|
||||
onKeyDown={(event) => moveFocus(event, 'projects')}>
|
||||
{projectWindow.segments.map((segment) => {
|
||||
if (segment.kind === 'spacer') return <div key={segment.key} data-list-window-spacer aria-hidden="true" style={{ height: segment.height }} />
|
||||
const row = projectRows[segment.index]!
|
||||
const previous = projectRows[segment.index - 1]
|
||||
const rowCategory = row.kind === 'host' ? 'remote' : row.project.kind === 'channel' ? 'channels' : row.project.executionSpace.kind === 'ssh' ? 'remote' : 'local'
|
||||
const previousCategory = !previous ? undefined : previous.kind === 'host' ? 'remote' : previous.project.kind === 'channel' ? 'channels' : previous.project.executionSpace.kind === 'ssh' ? 'remote' : 'local'
|
||||
return <div key={row.id} data-list-window-row={row.id} ref={projectWindow.rowRef(row.id)} role="presentation"
|
||||
data-preview={row.kind === 'project' && scope === row.project.id ? 'true' : undefined}
|
||||
onPointerOver={(event) => {
|
||||
if (row.kind === 'project' && event.pointerType !== 'touch' && (event.target as HTMLElement).closest('[role="menuitemradio"]')) setScope(row.project.id)
|
||||
}} onFocus={(event) => {
|
||||
if (row.kind === 'project' && (event.target as HTMLElement).matches('[role="menuitemradio"]')) setScope(row.project.id)
|
||||
}}>
|
||||
{(category === 'allProjects' || query.trim()) && rowCategory !== previousCategory &&
|
||||
<h3 className="workspace-menu__project-category">{t(`workspaceMenu.${rowCategory}`)}</h3>}
|
||||
{row.kind === 'project' ? renderProject(row.project)
|
||||
: <button type="button" role="menuitem" className="project-switcher__host-heading workspace-menu__host"
|
||||
aria-expanded={Boolean(query.trim()) || !collapsed.has(row.hostId)} onClick={() => setCollapsed((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(row.hostId)) next.delete(row.hostId); else next.add(row.hostId)
|
||||
return next
|
||||
})}>
|
||||
<span><ChevronDown size={12} aria-hidden="true" /><Server size={14} aria-hidden="true" /><b>{row.label}</b></span>
|
||||
<span className={`status-badge project-switcher__host-status project-switcher__host-status--${row.state}`}>
|
||||
{t(`projectSwitcher.selector.connectionStates.${row.state}`)}
|
||||
</span>
|
||||
</button>}
|
||||
</div>
|
||||
})}
|
||||
{!projectRows.length && <p className="workspace-menu__empty">{t('workspaceMenu.noProjects')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="workspace-menu__footer"><button type="button" className="secondary-button" onClick={onCreateProject}>
|
||||
<Plus size={15} aria-hidden="true" />{t('projectSwitcher.selector.create')}
|
||||
</button></footer>
|
||||
</section>
|
||||
<section className="workspace-menu__activity" aria-label={scopeName}>
|
||||
<div className="workspace-menu__toolbar">
|
||||
<SegmentedControl ariaLabel={t('workspaceMenu.filter')} value={filter} onChange={setFilter}
|
||||
options={(['all', 'attention', 'running', 'completed'] as const).map((value) => ({ value, label: t(`workspaceMenu.filters.${value}`) }))} />
|
||||
<button type="button" className="workspace-menu__new" disabled={!newProject || newProject.kind === 'channel'}
|
||||
title={newProject?.kind === 'channel' ? tApp('notices.channelConversationAutomatic') : t('workspaceMenu.newIn', { name: newProject ? getProjectDisplayText(newProject, t).name : '' })}
|
||||
onClick={() => { if (newProject) { onClose(); onNewConversation(newProject.id) } }}>
|
||||
<Plus size={14} aria-hidden="true" />{t('workspaceMenu.newConversation')}
|
||||
</button>
|
||||
</div>
|
||||
{newProject?.kind === 'channel' && <small className="workspace-menu__channel-note">{tApp('notices.channelConversationAutomatic')}</small>}
|
||||
<div className="workspace-menu__conversations" ref={conversationListRef} role="list" aria-label={t('workspaceMenu.conversations')}
|
||||
onScroll={conversationWindow.onScroll} onFocus={conversationWindow.onFocus} onBlur={conversationWindow.onBlur}
|
||||
onKeyDown={(event) => moveFocus(event, 'conversations')}>
|
||||
{conversationWindow.segments.map((segment) => {
|
||||
if (segment.kind === 'spacer') return <div key={segment.key} data-list-window-spacer aria-hidden="true" style={{ height: segment.height }} />
|
||||
const row = rows[segment.index]!
|
||||
const project = projectById.get(row.projectId ?? '')
|
||||
return <div key={row.id} data-list-window-row={row.id} ref={conversationWindow.rowRef(row.id)} role="listitem"
|
||||
aria-posinset={segment.index + 1} aria-setsize={rows.length}>
|
||||
{(segment.index === 0 || rows[segment.index - 1]?.group !== row.group) &&
|
||||
<h3 className="workspace-menu__category">{t(`workspaceMenu.groups.${row.group}`)}</h3>}
|
||||
<button type="button" className="workspace-menu__conversation" onClick={() => { onClose(); onOpenConversation(row.id) }}>
|
||||
<span><strong>{row.title}</strong>{scope === null && <>{' '}<small>{project ? getProjectDisplayText(project, t).name : t('projectActivity.unassigned')}</small></>}</span>{' '}
|
||||
{row.status && <small className={`project-activity__${row.status === 'running' || row.status === 'completed' ? row.status : 'attention'}`}>{t(`projectActivity.status.${row.status}`)}</small>}
|
||||
</button>
|
||||
</div>
|
||||
})}
|
||||
{!rows.length && <p className="workspace-menu__empty">{t('workspaceMenu.noConversations')}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
}
|
||||
@@ -118,6 +118,13 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(platformFeaturesTabs).toBeDefined()
|
||||
expect(platformFeaturesTabs).toMatch(/flex:\s*0 0 auto;/u)
|
||||
})
|
||||
it('keeps workspace category tabs fixed outside the scrolling project panel', () => {
|
||||
const workspaceStylesheet = readFileSync(join(process.cwd(), 'src', 'renderer', 'src', 'workspace-menu.css'), 'utf8')
|
||||
expect(workspaceStylesheet).toMatch(/\.workspace-menu__categories\s*\{[^}]*flex:\s*0 0 auto;/u)
|
||||
expect(workspaceStylesheet).toMatch(/\.workspace-menu__project-panel\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;/u)
|
||||
expect(stylesheet).toMatch(/\.page-tabs\s*\{[^}]*overflow-x:\s*auto;[^}]*flex-wrap:\s*nowrap;/u)
|
||||
})
|
||||
|
||||
it('keeps shared segmented controls from collapsing beside scrollable content', () => {
|
||||
const segmentedStyles = stylesheet.match(
|
||||
/\.segmented-control,\s*\.page-tabs--segmented\s*\{(?<rules>[^}]*)\}/u
|
||||
|
||||
@@ -21,16 +21,18 @@ function latestMessageTime(messages: readonly Message[]): number | undefined {
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function conversationActivityTime(conversation: Conversation): number {
|
||||
const summaryTime = conversation.messageSummary?.latestMessageAt;
|
||||
const messageTime = latestMessageTime(conversation.messages);
|
||||
return (summaryTime === undefined ? messageTime : messageTime === undefined
|
||||
? summaryTime : Math.max(summaryTime, messageTime)) ?? conversation.updatedAt;
|
||||
}
|
||||
|
||||
export function sortConversationsForDisplay(conversations: readonly Conversation[]): Conversation[] {
|
||||
// updatedAt tracks snapshot freshness, including every streaming delta.
|
||||
// Compute stable message times once per conversation, not per comparison.
|
||||
return conversations.map(conversation => {
|
||||
const summaryTime = conversation.messageSummary?.latestMessageAt;
|
||||
const messageTime = latestMessageTime(conversation.messages);
|
||||
const latest = summaryTime === undefined
|
||||
? messageTime
|
||||
: messageTime === undefined ? summaryTime : Math.max(summaryTime, messageTime);
|
||||
return { conversation, time: latest ?? conversation.updatedAt };
|
||||
return { conversation, time: conversationActivityTime(conversation) };
|
||||
}).sort((left, right) =>
|
||||
Number(Boolean(right.conversation.pinned)) - Number(Boolean(left.conversation.pinned)) ||
|
||||
right.time - left.time || left.conversation.id.localeCompare(right.conversation.id),
|
||||
|
||||
@@ -34,6 +34,52 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("conversation store", () => {
|
||||
it("tracks visits and status transitions in session memory without changing persisted snapshots", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const initial = [conversation("old", [], { projectId: "project", updatedAt: 10 })];
|
||||
const store = createConversationStore(initial, createLiveMessageStore());
|
||||
const persisted = vi.fn();
|
||||
const activity = vi.fn();
|
||||
store.subscribe(persisted);
|
||||
store.subscribeActivityTimes(activity);
|
||||
store.recordActivityStatuses([{ conversationId: "old", status: "running" }]);
|
||||
expect(store.getActivityTimes().size).toBe(0);
|
||||
store.rememberOpened("old");
|
||||
expect(store.getActivityTimes().get("old")).toBe(1000);
|
||||
const visited = store.getActivityTimes();
|
||||
store.recordActivityStatuses([{ conversationId: "old", status: "running" }]);
|
||||
expect(store.getActivityTimes()).toBe(visited);
|
||||
store.recordActivityStatuses([{ conversationId: "old", status: "completed" }]);
|
||||
expect(store.getActivityTimes().get("old")).toBe(1001);
|
||||
const completed = store.getActivityTimes();
|
||||
store.recordActivityStatuses([]);
|
||||
expect(store.getActivityTimes()).toBe(completed);
|
||||
expect(store.getState()).toBe(initial);
|
||||
expect(store.getConversation("old")?.updatedAt).toBe(10);
|
||||
expect(persisted).not.toHaveBeenCalled();
|
||||
expect(activity).toHaveBeenCalledTimes(2);
|
||||
store.recordActivityStatuses([{ conversationId: "old", status: "running" }]);
|
||||
store.recordActivityStatuses([]);
|
||||
expect(store.getActivityTimes().get("old")).toBe(1003);
|
||||
expect(createConversationStore(initial, createLiveMessageStore()).getActivityTimes().size).toBe(0);
|
||||
});
|
||||
|
||||
it("restores the last opened conversation per project and falls back after deletion", () => {
|
||||
const older = conversation("older", [], { projectId: "project", updatedAt: 10 });
|
||||
const recent = conversation("recent", [], { projectId: "project", updatedAt: 20 });
|
||||
const other = conversation("other", [], { projectId: "other", updatedAt: 30 });
|
||||
const store = createConversationStore([older, other, recent], createLiveMessageStore());
|
||||
expect(store.projectConversation("project", false)?.id).toBe("recent");
|
||||
store.rememberOpened("older");
|
||||
store.rememberOpened("other");
|
||||
expect(store.projectConversation("project", false)?.id).toBe("older");
|
||||
expect(store.projectConversation("other", false)?.id).toBe("other");
|
||||
expect(store.projectConversation("project", true)).toBeUndefined();
|
||||
store.set([recent, other]);
|
||||
expect(store.projectConversation("project", false)?.id).toBe("recent");
|
||||
});
|
||||
|
||||
it("applies updates synchronously, in dispatch order", () => {
|
||||
const store = createConversationStore([conversation("a")], createLiveMessageStore());
|
||||
store.set((current) => [...current, conversation("b")]);
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Conversation } from "./chat-conversation";
|
||||
import { conversationActivityTime } from "./chat-conversation";
|
||||
import type { ConversationActivity } from "./conversation-activity";
|
||||
import {
|
||||
createLiveMessageStore,
|
||||
type ConversationsUpdate,
|
||||
@@ -40,6 +42,19 @@ export function createConversationStore(
|
||||
let state = initial;
|
||||
const listeners = new Set<Listener>();
|
||||
const conversationListeners = new Map<string, Set<Listener>>();
|
||||
const lastOpenedByProject = new Map<string, string>();
|
||||
let activityTimes: ReadonlyMap<string, number> = new Map();
|
||||
let activityStatuses: ReadonlyMap<string, ConversationActivity['status']> | undefined;
|
||||
const activityListeners = new Set<Listener>();
|
||||
let lastActivityTime = 0;
|
||||
const touchActivity = (ids: readonly string[]): void => {
|
||||
if (!ids.length) return;
|
||||
lastActivityTime = Math.max(Date.now(), lastActivityTime + 1);
|
||||
const next = new Map(activityTimes);
|
||||
for (const id of ids) next.set(id, lastActivityTime);
|
||||
activityTimes = next;
|
||||
for (const listener of [...activityListeners]) listener();
|
||||
};
|
||||
|
||||
const notify = (previous: readonly Conversation[]): void => {
|
||||
if (conversationListeners.size) {
|
||||
@@ -58,6 +73,41 @@ export function createConversationStore(
|
||||
};
|
||||
|
||||
return {
|
||||
rememberOpened(conversationId: string): void {
|
||||
const conversation = state.find((item) => item.id === conversationId);
|
||||
if (!conversation) return;
|
||||
if (conversation?.projectId) lastOpenedByProject.set(conversation.projectId, conversationId);
|
||||
touchActivity([conversationId]);
|
||||
},
|
||||
getActivityTimes(): ReadonlyMap<string, number> {
|
||||
return activityTimes;
|
||||
},
|
||||
subscribeActivityTimes(listener: Listener): () => void {
|
||||
activityListeners.add(listener);
|
||||
return () => { activityListeners.delete(listener); };
|
||||
},
|
||||
recordActivityStatuses(activities: readonly Pick<ConversationActivity, 'conversationId' | 'status'>[]): void {
|
||||
const next = new Map(activities.map((activity) => [activity.conversationId, activity.status]));
|
||||
const changed: string[] = [];
|
||||
// Initial activity is a baseline. Acknowledging a completion is not a new run.
|
||||
if (activityStatuses) for (const id of new Set([...activityStatuses.keys(), ...next.keys()])) {
|
||||
const before = activityStatuses.get(id);
|
||||
const after = next.get(id);
|
||||
if (before !== after && !(before === 'completed' && after === undefined)) changed.push(id);
|
||||
}
|
||||
activityStatuses = next;
|
||||
touchActivity(changed);
|
||||
},
|
||||
projectConversation(projectId: string, channel: boolean, candidates = state): Conversation | undefined {
|
||||
const remembered = lastOpenedByProject.get(projectId);
|
||||
let recent: Conversation | undefined;
|
||||
for (const item of candidates) {
|
||||
if (item.projectId !== projectId || (channel && !item.remote)) continue;
|
||||
if (item.id === remembered) return item;
|
||||
if (!recent || conversationActivityTime(item) > conversationActivityTime(recent)) recent = item;
|
||||
}
|
||||
return recent;
|
||||
},
|
||||
getState(): Conversation[] {
|
||||
return state;
|
||||
},
|
||||
|
||||
@@ -2,6 +2,15 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
|
||||
|
||||
export const workspace = {
|
||||
workspaceMenu: {
|
||||
title: 'Projects and activity', close: 'Close projects and activity', search: 'Search projects',
|
||||
allProjects: 'All projects', local: 'Local', remote: 'Remote', channels: 'Channels', categories: 'Project categories',
|
||||
filter: 'Filter conversation status', conversations: 'Conversations', newConversation: 'New conversation',
|
||||
newIn: 'New conversation in {{name}}', noProjects: 'No matching projects. Try another search.',
|
||||
noConversations: 'No conversations in this scope match the filter.',
|
||||
filters: { all: 'All', attention: 'Attention', running: 'Running', completed: 'Completed' },
|
||||
groups: { attention: 'Needs attention', running: 'Running', recent: 'Recent conversations', completed: 'Completed' }
|
||||
},
|
||||
projectActivity: {
|
||||
title: 'All project activity',
|
||||
idle: 'No activity',
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
export const workspace = {
|
||||
workspaceMenu: {
|
||||
title: '项目与活动', close: '关闭项目与活动', search: '搜索项目',
|
||||
allProjects: '全部项目', local: '本地', remote: '远程', channels: '消息通道', categories: '项目类型',
|
||||
filter: '筛选会话状态', conversations: '会话', newConversation: '新建会话',
|
||||
newIn: '在 {{name}} 中新建会话', noProjects: '没有匹配的项目,请尝试其他关键词。',
|
||||
noConversations: '当前范围内没有符合筛选条件的会话。',
|
||||
filters: { all: '全部', attention: '待处理', running: '运行中', completed: '已完成' },
|
||||
groups: { attention: '待处理', running: '运行中', recent: '最近会话', completed: '已完成' }
|
||||
},
|
||||
projectActivity: {
|
||||
title: '全项目活动',
|
||||
idle: '暂无活动',
|
||||
|
||||
@@ -3334,7 +3334,7 @@ textarea:focus-visible {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: minmax(0, 1fr) 36px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.project-switcher__row > .icon-button {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveConversationActivity } from './conversation-activity'
|
||||
import { sameWorkspaceConversations, workspaceConversationRows, type WorkspaceConversation } from './workspace-menu-selectors'
|
||||
|
||||
const conversations: WorkspaceConversation[] = Array.from({ length: 15 }, (_, index) => ({
|
||||
id: `c${index}`, title: `Conversation ${index}`, projectId: index === 14 ? 'other' : 'project', time: 100 - index, channel: false
|
||||
}))
|
||||
|
||||
describe('workspace conversation summaries', () => {
|
||||
it('uses real attention priority, adds ten non-active recent rows, and does not cap completed', () => {
|
||||
const activities = deriveConversationActivity(conversations.map((row, index) => ({
|
||||
...row, messages: index === 0 ? [{ pendingQuestions: [{}], state: 'streaming' }] : []
|
||||
})), [{ conversationId: 'c0', title: 'task', status: 'running' }], new Set(['c0', 'c1']), [], '', new Set(conversations.slice(2).map((row) => row.id)))
|
||||
const rows = workspaceConversationRows(conversations, activities.activities, 'project', 'all')
|
||||
expect(rows.map((row) => row.id)).toEqual(conversations.slice(0, 12).map((row) => row.id))
|
||||
expect(rows[0]).toMatchObject({ status: 'question', group: 'attention' })
|
||||
expect(workspaceConversationRows(conversations, activities.activities, null, 'attention').map((row) => row.id)).toEqual(['c0'])
|
||||
expect(workspaceConversationRows(conversations, activities.activities, null, 'running').map((row) => row.id)).toEqual(['c1'])
|
||||
expect(workspaceConversationRows(conversations, activities.activities, null, 'completed')).toHaveLength(13)
|
||||
})
|
||||
|
||||
it('keeps viewed completions in recent history after the reminder is acknowledged', () => {
|
||||
const activity = { conversationId: 'c0', projectId: 'project', projectName: 'Project', title: 'Conversation 0', status: 'completed' as const }
|
||||
const before = workspaceConversationRows(conversations, [activity], 'project', 'all')
|
||||
const after = workspaceConversationRows(conversations, [], 'project', 'all')
|
||||
expect(after.map((row) => row.id)).toEqual(before.map((row) => row.id))
|
||||
expect(after[0]?.status).toBeUndefined()
|
||||
expect(workspaceConversationRows(conversations, [], 'project', 'completed')).toEqual([])
|
||||
})
|
||||
|
||||
it('includes task-only activity for exact-ID navigation without requiring hydration', () => {
|
||||
const rows = workspaceConversationRows([], [{ conversationId: 'missing', projectId: 'project', projectName: 'Project', title: 'Background job', status: 'approval' }], 'project', 'all')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ id: 'missing', group: 'attention', status: 'approval' })
|
||||
})
|
||||
|
||||
it('retains equal metadata and notices scope, title and ordering changes', () => {
|
||||
expect(sameWorkspaceConversations(conversations, conversations.map((row) => ({ ...row })))).toBe(true)
|
||||
for (const change of [{ title: 'Changed' }, { time: 101 }, { projectId: 'other' }, { channel: true }]) {
|
||||
expect(sameWorkspaceConversations(conversations, conversations.map((row, index) => index ? row : { ...row, ...change }))).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback } from 'react'
|
||||
import { conversationActivityTime, getConversationDisplayTitle, type Conversation } from './chat-conversation'
|
||||
import { useConversationStoreSelector, type ConversationStore } from './conversation-store'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import { sameArrayItems } from './stable-derived-value'
|
||||
|
||||
export type WorkspaceConversation = {
|
||||
id: string
|
||||
projectId?: string
|
||||
title: string
|
||||
time: number
|
||||
channel: boolean
|
||||
}
|
||||
export type WorkspaceFilter = 'all' | 'attention' | 'running' | 'completed'
|
||||
export type WorkspaceConversationRow = WorkspaceConversation & {
|
||||
status?: ConversationActivity['status']
|
||||
group: 'attention' | 'running' | 'recent' | 'completed'
|
||||
}
|
||||
|
||||
export function useWorkspaceConversations(store: ConversationStore, defaultTitle: string, activityTimes: ReadonlyMap<string, number>): WorkspaceConversation[] {
|
||||
const selector = useCallback((conversations: Conversation[]) => conversations.map((conversation) => ({
|
||||
id: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
title: getConversationDisplayTitle(conversation, defaultTitle),
|
||||
time: Math.max(conversationActivityTime(conversation), activityTimes.get(conversation.id) ?? 0),
|
||||
channel: Boolean(conversation.remote)
|
||||
})), [defaultTitle, activityTimes])
|
||||
return useConversationStoreSelector(store, selector, sameWorkspaceConversations)
|
||||
}
|
||||
|
||||
export function sameWorkspaceConversations(a: WorkspaceConversation[], b: WorkspaceConversation[]): boolean {
|
||||
return sameArrayItems(a, b, (left, right) => left.id === right.id && left.projectId === right.projectId &&
|
||||
left.title === right.title && left.time === right.time && left.channel === right.channel)
|
||||
}
|
||||
|
||||
export function workspaceConversationRows(
|
||||
conversations: readonly WorkspaceConversation[],
|
||||
activities: readonly ConversationActivity[],
|
||||
scope: string | null,
|
||||
filter: WorkspaceFilter,
|
||||
activityTimes?: ReadonlyMap<string, number>
|
||||
): WorkspaceConversationRow[] {
|
||||
const activityById = new Map(activities.map((activity) => [activity.conversationId, activity]))
|
||||
const rows = new Map<string, WorkspaceConversationRow>()
|
||||
for (const conversation of conversations) {
|
||||
if (scope !== null && conversation.projectId !== scope) continue
|
||||
const status = activityById.get(conversation.id)?.status
|
||||
const group = status === 'running' ? 'running' : status && status !== 'completed' ? 'attention' : 'recent'
|
||||
rows.set(conversation.id, { ...conversation, status, group })
|
||||
}
|
||||
// Task activity can arrive before its conversation summary. Navigation already
|
||||
// hydrates that exact ID; preview never needs to fetch the conversation.
|
||||
for (const activity of activities) {
|
||||
if (rows.has(activity.conversationId) || (scope !== null && activity.projectId !== scope)) continue
|
||||
rows.set(activity.conversationId, {
|
||||
id: activity.conversationId, projectId: activity.projectId, title: activity.title,
|
||||
time: activityTimes?.get(activity.conversationId) ?? 0, channel: false, status: activity.status,
|
||||
group: activity.status === 'running' ? 'running' : activity.status === 'completed' ? 'recent' : 'attention'
|
||||
})
|
||||
}
|
||||
const ordered = [...rows.values()].sort((a, b) => b.time - a.time || a.id.localeCompare(b.id))
|
||||
if (filter === 'completed') return ordered.filter((row) => row.status === 'completed').map((row) => ({ ...row, group: 'completed' }))
|
||||
if (filter !== 'all') return ordered.filter((row) => row.group === filter)
|
||||
return [
|
||||
...ordered.filter((row) => row.group === 'attention'),
|
||||
...ordered.filter((row) => row.group === 'running'),
|
||||
...ordered.filter((row) => row.group === 'recent').slice(0, 10)
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
.project-switcher__menu.workspace-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 640px;
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-menu__header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.workspace-menu__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 360px) minmax(0, 1fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-menu__projects,
|
||||
.workspace-menu__activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-menu__projects {
|
||||
background: var(--surface-subtle);
|
||||
border-right: 1px solid var(--border-control);
|
||||
}
|
||||
|
||||
.workspace-menu__search {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: calc(100% - 2 * var(--space-3));
|
||||
margin: var(--space-3) var(--space-3) var(--space-2);
|
||||
width: calc(100% - 2 * var(--space-3));
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workspace-menu__categories {
|
||||
min-width: 0;
|
||||
padding: 0 var(--space-2) var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.workspace-menu__categories .page-tabs {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workspace-menu__project-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-menu [data-preview="true"] [role="menuitemradio"] {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workspace-menu [role="menuitemradio"][aria-checked="true"]:not(:hover) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workspace-menu [data-preview="true"] [role="menuitemradio"][aria-checked="true"]:not(:hover) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.workspace-menu__project-list,
|
||||
.workspace-menu__conversations {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.workspace-menu__category {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workspace-menu__host {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-menu__host[aria-expanded="false"] svg:first-child {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.workspace-menu__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workspace-menu__toolbar .segmented-control {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.workspace-menu__toolbar .segmented-control__option {
|
||||
flex: 0 0 auto;
|
||||
padding-inline: var(--space-2);
|
||||
font-size: var(--font-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-menu__new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
font-size: var(--font-caption);
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.workspace-menu__conversation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
gap: var(--space-3);
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border-radius: var(--radius-control);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workspace-menu__conversation > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-menu__conversation strong,
|
||||
.workspace-menu__conversation small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-menu__conversation strong { font-weight: 500; }
|
||||
.workspace-menu__conversation small { font-size: var(--font-caption); }
|
||||
.workspace-menu__conversation > span > small { color: var(--text-secondary); }
|
||||
.workspace-menu__conversation > small { flex: 0 0 auto; }
|
||||
.workspace-menu__conversation:hover,
|
||||
.workspace-menu__new:not(:disabled):hover { background: var(--accent-subtle); }
|
||||
.workspace-menu__new:disabled { opacity: 0.5; cursor: default; }
|
||||
.workspace-menu__empty { padding: var(--space-4); color: var(--text-secondary); }
|
||||
.workspace-menu__channel-note { padding: var(--space-2) var(--space-3); color: var(--text-secondary); }
|
||||
|
||||
.workspace-menu__footer {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-2);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item .project-activity__counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.workspace-menu__project-category {
|
||||
margin: var(--space-2) var(--space-2) var(--space-1);
|
||||
padding: var(--space-2) 0 var(--space-1);
|
||||
border-top: 1px solid var(--border-default);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.workspace-menu__project-list > :first-child .workspace-menu__project-category {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item {
|
||||
margin-block: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] {
|
||||
box-sizing: border-box;
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] > span {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] > svg:first-child {
|
||||
color: var(--accent);
|
||||
padding: var(--space-2);
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-control);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.project-switcher__trigger.project-switcher__control {
|
||||
box-sizing: border-box;
|
||||
height: 60px;
|
||||
padding-block: var(--space-1);
|
||||
}
|
||||
|
||||
.project-switcher__activity {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.project-switcher__control .project-activity__counts {
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-switcher__control:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.project-switcher__recovery-feedback {
|
||||
display: block;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.workspace-menu__layout { grid-template-columns: minmax(160px, 40%) minmax(0, 1fr); }
|
||||
.workspace-menu__toolbar { padding: var(--space-2); gap: var(--space-1); }
|
||||
.workspace-menu__conversation { flex-direction: column; align-items: flex-start; gap: var(--space-1); }
|
||||
.workspace-menu__conversation > span { max-width: 100%; }
|
||||
.workspace-menu__host { flex-wrap: wrap; }
|
||||
.workspace-menu .project-switcher__menu-settings { opacity: 1; pointer-events: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.workspace-menu__layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.workspace-menu__new { white-space: normal; }
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.workspace-menu .project-switcher__menu-item > [role="menuitemradio"] > svg:first-child { display: none; }
|
||||
}
|
||||
@@ -25,10 +25,11 @@ app.whenReady().then(async () => {
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
database.initialize(directory)
|
||||
const project = database.listProjects()[0]!
|
||||
const conversationId = randomUUID()
|
||||
const resultId = randomUUID()
|
||||
const html = '<html><body><a href="#end">Jump to end</a>' + '<p>Long preview text</p>'.repeat(80) +
|
||||
'<h2 id="end">End</h2><script>window.parent.previewUnsafe=true</script><img src="https://example.invalid/leak"></body></html>'
|
||||
database.saveLocalConversations([{ header: { id: randomUUID(), projectId: project.id, title: 'Overlay paths', updatedAt: Date.now() },
|
||||
database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Overlay paths', updatedAt: Date.now() },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', state: 'complete', createdAt: Date.now(), content: '```html\n' + html + '\n```',
|
||||
attachments: [{ id: randomUUID(), kind: 'text', name: 'review.pdf', size: 20, preview: 'Saved result', resultId }],
|
||||
sourceReferences: [{ libraryId: randomUUID(), libraryName: 'Review', documentName: 'Review source', sourceName: 'Fixture',
|
||||
@@ -155,14 +156,94 @@ app.whenReady().then(async () => {
|
||||
await key('Escape')
|
||||
await wait('!document.querySelector(".html-preview-viewer")')
|
||||
evidence.iframe = true
|
||||
const idleTrigger = await js<number>('document.querySelector(".project-switcher__control").getBoundingClientRect().height')
|
||||
assert.equal(idleTrigger, 60)
|
||||
assert(await js('document.querySelector(".project-switcher__control").matches("button") && document.querySelectorAll(".project-switcher__picker button").length === 1 && !!document.querySelector(".project-switcher__trigger .project-switcher__activity")'))
|
||||
await screenshot('workspace-idle-trigger')
|
||||
database.createTask({ id: randomUUID(), projectId: project.id, conversationId,
|
||||
title: 'Review workspace layout', instructions: 'Layout fixture only', workMode: 'ask', status: 'running' })
|
||||
win.webContents.send(ipcChannels.conversationsChanged)
|
||||
await wait('!!document.querySelector(".project-switcher__control .project-activity__running")')
|
||||
assert.equal(await js('document.querySelector(".project-switcher__control").getBoundingClientRect().height'), idleTrigger)
|
||||
await screenshot('workspace-active-trigger')
|
||||
const workspaceLayouts = []
|
||||
for (const [width, height] of [[1280, 800], [1280, 480], [560, 640]]) {
|
||||
win.setContentSize(width!, height!)
|
||||
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
|
||||
if (width! < 900) {
|
||||
await wait('document.querySelector(".sidebar").inert')
|
||||
await click('.sidebar-toggle')
|
||||
await wait('!document.querySelector(".sidebar").inert')
|
||||
}
|
||||
await js('Promise.all(document.getAnimations().filter(a => a instanceof CSSTransition).map(a => a.finished.catch(() => {})))')
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await js(`document.documentElement.dataset.theme='${theme}'`)
|
||||
await click('.project-switcher__activity')
|
||||
await wait('!!document.querySelector(".workspace-menu")')
|
||||
await js('Promise.all(document.getAnimations().filter(a => a instanceof CSSTransition).map(a => a.finished.catch(() => {})))')
|
||||
const layout = await js<{ bounded: boolean; divider: boolean; compact: boolean; rows: boolean }>(`(() => {
|
||||
const rect = selector => document.querySelector(selector).getBoundingClientRect();
|
||||
const left = rect('.workspace-menu__projects'), input = rect('.workspace-menu__search');
|
||||
const menu = rect('.workspace-menu'), toolbar = rect('.workspace-menu__toolbar');
|
||||
const filters = rect('.workspace-menu__toolbar .segmented-control'), action = rect('.workspace-menu__new');
|
||||
const border = getComputedStyle(document.querySelector('.workspace-menu__projects'));
|
||||
const row = document.querySelector('.workspace-menu [role="menuitemradio"]');
|
||||
const parts = [...row.querySelector('span').children].map(e => e.getBoundingClientRect());
|
||||
return { bounded: input.left >= left.left && input.right <= left.right && menu.left >= 16 && menu.right <= innerWidth - 16 && menu.top >= 16 && menu.bottom <= innerHeight - 16,
|
||||
divider: border.borderRightWidth === '1px' && border.borderRightStyle === 'solid' && border.borderRightColor === getComputedStyle(document.querySelector('.workspace-menu__search')).borderRightColor,
|
||||
compact: toolbar.height < 70 && filters.right <= action.left && Math.abs(filters.top + filters.height / 2 - action.top - action.height / 2) < 2,
|
||||
rows: parts.length >= 3 && parts[0].bottom <= parts[1].top && parts[1].bottom <= parts[2].top };
|
||||
})()`)
|
||||
assert.deepEqual(layout, { bounded: true, divider: true, compact: true, rows: true }, `${width}x${height} ${theme}: ${JSON.stringify(layout)}`)
|
||||
await screenshot(`workspace-${width}x${height}-${theme}`)
|
||||
await key('Escape')
|
||||
await wait('!document.querySelector(".workspace-menu") && document.activeElement.matches(".project-switcher__trigger")')
|
||||
workspaceLayouts.push({ width, height, theme, ...layout })
|
||||
}
|
||||
}
|
||||
evidence.workspaceLayouts = workspaceLayouts
|
||||
evidence.workspaceTriggerHeight = idleTrigger
|
||||
await key('Escape')
|
||||
win.setContentSize(760, 700)
|
||||
await wait('document.querySelector(".sidebar").inert')
|
||||
await click('.sidebar-toggle')
|
||||
await click('.project-switcher .icon-button')
|
||||
await click('.project-switcher__trigger')
|
||||
await wait('!!document.querySelector(".workspace-menu")')
|
||||
assert(await js(`(() => {
|
||||
const menu = document.querySelector('.workspace-menu'), r = menu.getBoundingClientRect();
|
||||
return menu.closest('.floating-portal').parentElement === document.querySelector('.sidebar') &&
|
||||
r.left >= 16 && r.right <= innerWidth - 16 && r.top >= 16 && r.bottom <= innerHeight - 16;
|
||||
})()`))
|
||||
await click('.workspace-menu__categories [role="tab"]:last-child')
|
||||
assert(await js('document.querySelectorAll(".workspace-menu [role=menuitemradio]").length === 0'))
|
||||
await click('.workspace-menu__categories [role="tab"]:first-child')
|
||||
assert(await js(`(() => {
|
||||
const tabs = [...document.querySelectorAll('.workspace-menu__categories [role="tab"]')];
|
||||
return tabs.length === 3 && tabs[0].getAttribute('aria-selected') === 'true' &&
|
||||
tabs.every(tab => tab.getBoundingClientRect().top === tabs[0].getBoundingClientRect().top) &&
|
||||
document.querySelectorAll('.workspace-menu [role="menuitemradio"]').length === 1 &&
|
||||
!document.querySelector('.workspace-menu__scope');
|
||||
})()`))
|
||||
evidence.workspaceCategories = true
|
||||
await js('document.querySelector(".workspace-menu [role=menuitemradio]").focus()')
|
||||
await key('Tab')
|
||||
await wait('document.activeElement.matches(".project-switcher__menu-settings")')
|
||||
await js('Promise.all(document.getAnimations().filter(a => a instanceof CSSTransition).map(a => a.finished.catch(() => {})))')
|
||||
assert.equal(await js('getComputedStyle(document.activeElement).opacity'), '1')
|
||||
await click('.project-switcher__menu-settings')
|
||||
await wait('!!document.querySelector(".project-create-card")')
|
||||
await key('Escape')
|
||||
await wait('!document.querySelector(".project-create-card") && document.activeElement.matches(".project-switcher__trigger")')
|
||||
await click('.project-switcher__trigger')
|
||||
await key('Escape')
|
||||
await wait('!document.querySelector(".workspace-menu") && document.activeElement.matches(".project-switcher__trigger")')
|
||||
assert(await js('!document.querySelector(".sidebar").inert'))
|
||||
await click('.project-switcher__trigger')
|
||||
await click('.workspace-menu__footer button')
|
||||
await wait('!!document.querySelector(".project-create-card") && document.querySelector(".app-shell").inert')
|
||||
await key('Escape')
|
||||
await wait('!document.querySelector(".project-create-card")')
|
||||
assert(await js('!document.querySelector(".sidebar").inert && document.activeElement.matches(".project-switcher .icon-button")'))
|
||||
assert(await js('!document.querySelector(".sidebar").inert && document.activeElement.matches(".project-switcher__trigger")'))
|
||||
await key('Escape')
|
||||
await wait('document.querySelector(".sidebar").inert')
|
||||
evidence.project = true
|
||||
|
||||
Reference in New Issue
Block a user