diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 808a1f94..7507983a 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -307,7 +307,7 @@ - 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。 - 项目选择器与下方默认的新建对话按钮等宽,两行右侧图标按钮均宽 `36px`,与左侧控件间距均为 `8px`;搜索展开时项目选择器宽度保持不变。 - 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。 -- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守 +- 项目选择器使用固定双行单按钮,第一行显示项目名,第二行显示跨项目活动汇总或“暂无活动”;点击任一行打开统一菜单并默认预览当前项目,全部项目范围在菜单内切换。尺寸、焦点、项目计数与菜单遵守 [项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。 活动数量不改变项目菜单的稳定分组和顺序。 - 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整, diff --git a/docs/features/application-tool-navigation/README.md b/docs/features/application-tool-navigation/README.md index 4c7ff47e..7bd0bd22 100644 --- a/docs/features/application-tool-navigation/README.md +++ b/docs/features/application-tool-navigation/README.md @@ -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,其中使用可搜索应用列表。 diff --git a/docs/features/application-tool-navigation/logic-design.md b/docs/features/application-tool-navigation/logic-design.md index 58f41232..637c932f 100644 --- a/docs/features/application-tool-navigation/logic-design.md +++ b/docs/features/application-tool-navigation/logic-design.md @@ -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 或隐藏侧栏关闭菜单,后台活动不自动打开。查看预览不清除完成通知,实际进入可见聊天才按上述规则清除。 diff --git a/docs/features/application-tool-navigation/prd.md b/docs/features/application-tool-navigation/prd.md index 343cadfc..1c8e2693 100644 --- a/docs/features/application-tool-navigation/prd.md +++ b/docs/features/application-tool-navigation/prd.md @@ -146,10 +146,11 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止 ### FR-14 项目活动汇总 用户在任一项目中都能查看跨项目的待处理、运行中与已完成未查看会话,并从活动列表直接进入所选会话。 -汇总入口仅在有上述活动行时显示在项目选择器下方,无活动行时整行隐藏。点击后先显示有活动会话的项目, -悬停或点击项目展开会话子菜单,点击会话进入该条对话。项目菜单保留原有分组与顺序,并显示 -各项目的活动数量;级联菜单内的项目顺序不随各类活动数量变化。 -汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见 +左上角使用一个固定两行的按钮:第一行显示项目名,第二行显示全局活动摘要,无活动时显示“暂无活动”,按钮高度不随活动变化。点击任一行打开同一个锚定工作空间菜单,默认预览当前项目,全部项目范围从菜单内切换;无活动时仍可查看最近会话。 +左栏依次提供“全部项目、本地、远程、消息通道”分类,远程能力关闭时隐藏远程分类及其项目;支持跨分类搜索、远程主机折叠及独立项目设置。项目行按名称、活动数量、目录分行显示,无活动时省略数量行,项目顺序不随活动数量变化。 +悬停或键盘聚焦项目只预览右栏,点击项目直接进入,优先恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目无会话时创建,消息通道无会话时保持空态。点击右栏会话进入确切会话。 +右栏默认依次列待处理、运行中、最近会话,最近会话最多 10 条;已完成未查看在行内标注。“全部、待处理、运行中、已完成”筛选与“新建会话”同排,新建使用预览项目,全部项目范围使用当前项目,消息通道禁用手动新建。左栏底部“新建项目”进入真实项目创建流程。 +活动计数、待处理、运行中及已完成筛选不受最近 10 条限制;菜单不继承侧栏会话搜索条件。计数与排除规则见 [活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。 验收覆盖跨项目后台运行、同会话多任务去重、提问与审批优先、较早的仍在运行或等待处理的 diff --git a/docs/features/application-tool-navigation/progress.md b/docs/features/application-tool-navigation/progress.md index fecf8161..56966d16 100644 --- a/docs/features/application-tool-navigation/progress.md +++ b/docs/features/application-tool-navigation/progress.md @@ -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` 常驻摘要和锚定级联菜单。会话和项目计数继续使用现有 diff --git a/docs/features/application-tool-navigation/technical-design.md b/docs/features/application-tool-navigation/technical-design.md index 95c805ff..d33cb860 100644 --- a/docs/features/application-tool-navigation/technical-design.md +++ b/docs/features/application-tool-navigation/technical-design.md @@ -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 路径验证。 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index cfc16a0b..d96306ca 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -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`,窄窗口仍保留双栏并各自滚动,不使用旧级联返回页。状态筛选可横向滚动,新建按钮保持可见。项目和会话列表均采用共享窗口化渲染,键盘可到达完整列表首尾。 diff --git a/docs/features/application-tool-navigation/user-stories.md b/docs/features/application-tool-navigation/user-stories.md index cd5d3352..1b125e5e 100644 --- a/docs/features/application-tool-navigation/user-stories.md +++ b/docs/features/application-tool-navigation/user-stories.md @@ -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 完成通知保留,直到设置关闭且目标聊天在可见文档中显示;取消设置离开不清除通知 diff --git a/docs/features/application-tool-navigation/workspace-demo.css b/docs/features/application-tool-navigation/workspace-demo.css new file mode 100644 index 00000000..7afdb86c --- /dev/null +++ b/docs/features/application-tool-navigation/workspace-demo.css @@ -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%; } +} diff --git a/docs/features/application-tool-navigation/workspace-demo.html b/docs/features/application-tool-navigation/workspace-demo.html new file mode 100644 index 00000000..ec675865 --- /dev/null +++ b/docs/features/application-tool-navigation/workspace-demo.html @@ -0,0 +1,57 @@ + + + + + + GoodBuddy · 工作空间实验 + + + + +
GoodBuddy / 工作空间实验
+
+ +
+
会话窗口
+
+
OpenCode · Execute
+
+
+ + + +
+
项目设置
+ + + + + + + +
+
+ + + diff --git a/docs/features/application-tool-navigation/workspace-demo.js b/docs/features/application-tool-navigation/workspace-demo.js new file mode 100644 index 00000000..bc757a1f --- /dev/null +++ b/docs/features/application-tool-navigation/workspace-demo.js @@ -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: '', + local: '', + remote: '', + channel: '', + folder: '' +}; +const icon = (name) => ``; +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) => `${symbols[state]} ${labels[state]}`; +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 = '全部' + ['waiting', 'running', 'done'].filter((state) => sessions.some((s) => s.state === state)).map((state) => `${symbols[state]} ${sessions.filter((s) => s.state === state).length} ${labels[state]}`).join(''); + $('activity-trigger').hidden = !sessions.some((s) => labels[s.state]); + $('sessions').innerHTML = sessions.filter((s) => s.project === session.project).map((s) => ``).join(''); + $('conversation').innerHTML = `${escapeHtml(project.location)}${statusLabel(session) ? ` / ${statusLabel(session)}` : ''}

${escapeHtml(session.title)}

${escapeHtml(session.prompt)}
G / GoodBuddy
${escapeHtml(session.answer)}
${session.state === 'waiting' ? `
! ${statusLabel(session)}

${escapeHtml(session.detail)}

${session.question ? ' ' : ' '}
` : session.state === 'running' ? `
${badge('running')}

${escapeHtml(session.detail)} 切换到其他工作空间后仍会继续。

` : ''}`; + $('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) => `${symbols[state]} ${list.filter((s) => s.state === state).length} 个${labels[state]}`).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 `
`; + }; + 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]) => ``).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) => `
${projectButton(p)}${kinds[p.kind]}${p.host ? ` · ${escapeHtml(p.host)}` : ''}
`).join('') + : projectTab === 'remote' + ? hosts.map((host) => `
${host.name}${host.status}${shown.filter((p) => p.host === host.name).map(projectButton).join('')}
`).join('') + : shown.map(projectButton).join(''); + // Keep hovered project nodes stable while the right-hand list changes. + if ($('desktops').dataset.markup !== projectMarkup) { + $('desktops').innerHTML = projectMarkup || '
无匹配项目
'; + $('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) => ``).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 `

${state === 'recent' ? '最近会话' : badge(state)} ${group.length}

${group.map((s) => ``).join('')}
`; + }).join('') || '
暂无会话
'; + 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(); diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index a8670d14..01dc637c 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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(); + 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('.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(".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(); + 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(); + 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(); + 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(); + 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(".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(); - 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(); - 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(".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('[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(); - 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(); - 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() 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) => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b4f6306b..e6eb82ff 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 => { 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 { -
undefined) render( 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() 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( { 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( 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() }) diff --git a/src/renderer/src/ProjectSwitcher.tsx b/src/renderer/src/ProjectSwitcher.tsx index 29da9a72..422a027e 100644 --- a/src/renderer/src/ProjectSwitcher.tsx +++ b/src/renderer/src/ProjectSwitcher.tsx @@ -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 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() const [remoteDirectoryListing, setRemoteDirectoryListing] = useState() - const createButtonRef = useRef(null) const directoryPickerTriggerRef = useRef(null) const directoryPickerRef = useRef(null) const directoryBrowseRequestRef = useRef(0) const projectPickerRef = useRef(null) const projectPickerButtonRef = useRef(null) - const projectPickerMenuRef = useRef(null) + const menuId = useId() const dialogRef = useRef(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() 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( - '[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('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)') ?? dialogRef.current?.querySelector('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({ - {projectMenuOpen && ( - -
{ - const items = Array.from( - event.currentTarget.querySelectorAll( - '[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 && ( -
- - {t('projectSwitcher.selector.userProjects')} - - {localProjects.map((project) => - renderProjectMenuItem(project, Folder) - )} -
- )} - {remoteProjectsEnabled && - remoteProjectGroups.length > 0 && ( -
- - {t('projectSwitcher.selector.remoteProjects')} - - {remoteProjectGroups.map( - ({ hostId, host, projects: hostProjects }) => { - const hostName = - host?.name ?? - t( - 'projectSwitcher.selector.unavailableHost' - ) - const state = - agentConnectionStatusByHostId[hostId] ?? - 'disconnected' - return ( -
-
- - - - {t( - `projectSwitcher.selector.connectionStates.${state}` - )} - -
- {hostProjects.map((project) => - renderProjectMenuItem(project, Folder) - )} -
- ) - } - )} -
- )} - {channelProjects.length > 0 && ( -
- - {t('projectSwitcher.selector.channelProjects')} - - {channelProjects.map((project) => - renderProjectMenuItem(project, RadioTower) - )} -
- )} -
-
- )} -
- + }} />} + {dialogMode && createPortal(
({ 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() + 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( 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() + 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') + }) +}) diff --git a/src/renderer/src/WorkspaceMenu.tsx b/src/renderer/src/WorkspaceMenu.tsx new file mode 100644 index 00000000..b7507396 --- /dev/null +++ b/src/renderer/src/WorkspaceMenu.tsx @@ -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 + anchorRef: React.RefObject + controlsRef: React.RefObject + 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(null) + const projectListRef = useRef(null) + const conversationListRef = useRef(null) + const categoriesRef = useRef(null) + const [category, setCategory] = useState('allProjects') + if (category === 'remote' && !remoteProjectsEnabled) setCategory('allProjects') + const [scope, setScope] = useState(activeProjectId) + const [filter, setFilter] = useState('all') + const [query, setQuery] = useState('') + const [collapsed, setCollapsed] = useState>(() => new Set()) + const [keyboardProject, setKeyboardProject] = useState() + const [keyboardConversation, setKeyboardConversation] = useState() + 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() + 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('[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('[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('[data-list-window-row]') ?? []) + .find((item) => item.dataset.listWindowRow === ids[next]) + row?.querySelector('button')?.focus() + row?.scrollIntoView?.({ block: 'nearest' }) + } + + return + + +} diff --git a/src/renderer/src/WorkspacePrimitives.test.tsx b/src/renderer/src/WorkspacePrimitives.test.tsx index b7340ac9..25e068db 100644 --- a/src/renderer/src/WorkspacePrimitives.test.tsx +++ b/src/renderer/src/WorkspacePrimitives.test.tsx @@ -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*\{(?[^}]*)\}/u diff --git a/src/renderer/src/chat-conversation.ts b/src/renderer/src/chat-conversation.ts index 88b94dcc..42013adb 100644 --- a/src/renderer/src/chat-conversation.ts +++ b/src/renderer/src/chat-conversation.ts @@ -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), diff --git a/src/renderer/src/conversation-store.test.tsx b/src/renderer/src/conversation-store.test.tsx index 256781a6..e8abe749 100644 --- a/src/renderer/src/conversation-store.test.tsx +++ b/src/renderer/src/conversation-store.test.tsx @@ -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")]); diff --git a/src/renderer/src/conversation-store.ts b/src/renderer/src/conversation-store.ts index fbeac8f6..e4f275ff 100644 --- a/src/renderer/src/conversation-store.ts +++ b/src/renderer/src/conversation-store.ts @@ -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(); const conversationListeners = new Map>(); + const lastOpenedByProject = new Map(); + let activityTimes: ReadonlyMap = new Map(); + let activityStatuses: ReadonlyMap | undefined; + const activityListeners = new Set(); + 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 { + return activityTimes; + }, + subscribeActivityTimes(listener: Listener): () => void { + activityListeners.add(listener); + return () => { activityListeners.delete(listener); }; + }, + recordActivityStatuses(activities: readonly Pick[]): 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; }, diff --git a/src/renderer/src/i18n/locales/en-US/workspace.ts b/src/renderer/src/i18n/locales/en-US/workspace.ts index 86cc1f7b..b45cfa36 100644 --- a/src/renderer/src/i18n/locales/en-US/workspace.ts +++ b/src/renderer/src/i18n/locales/en-US/workspace.ts @@ -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', diff --git a/src/renderer/src/i18n/locales/zh-CN/workspace.ts b/src/renderer/src/i18n/locales/zh-CN/workspace.ts index 1fa0d8c7..dd6cd063 100644 --- a/src/renderer/src/i18n/locales/zh-CN/workspace.ts +++ b/src/renderer/src/i18n/locales/zh-CN/workspace.ts @@ -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: '暂无活动', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 464d8d19..e2d155c7 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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 { diff --git a/src/renderer/src/workspace-menu-selectors.test.ts b/src/renderer/src/workspace-menu-selectors.test.ts new file mode 100644 index 00000000..b7e3a78d --- /dev/null +++ b/src/renderer/src/workspace-menu-selectors.test.ts @@ -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) + } + }) +}) diff --git a/src/renderer/src/workspace-menu-selectors.ts b/src/renderer/src/workspace-menu-selectors.ts new file mode 100644 index 00000000..fcf5d425 --- /dev/null +++ b/src/renderer/src/workspace-menu-selectors.ts @@ -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): 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 +): WorkspaceConversationRow[] { + const activityById = new Map(activities.map((activity) => [activity.conversationId, activity])) + const rows = new Map() + 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) + ] +} diff --git a/src/renderer/src/workspace-menu.css b/src/renderer/src/workspace-menu.css new file mode 100644 index 00000000..d1ddca5a --- /dev/null +++ b/src/renderer/src/workspace-menu.css @@ -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; } +} diff --git a/tests/support/overlay-paths-main.ts b/tests/support/overlay-paths-main.ts index 3cd4e5ee..5d58bae7 100644 --- a/tests/support/overlay-paths-main.ts +++ b/tests/support/overlay-paths-main.ts @@ -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 = 'Jump to end' + '

Long preview text

'.repeat(80) + '

End

' - 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('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