From d9043e6fe583cd51744b2167173ad1c969be7641 Mon Sep 17 00:00:00 2001
From: axb
Date: Sat, 15 Aug 2026 20:27:49 +0800
Subject: [PATCH 1/5] fix(wework): decouple local project spaces from cloud
---
...rk-local-cloud-project-space-decoupling.md | 652 ++++++++++++++++++
wework/e2e/desktop/checkpoints.mjs | 1 +
.../e2e/desktop/modules/workspace-flows.mjs | 2 +-
wework/e2e/desktop/run-checkpoints.mjs | 2 +
.../offline-local-project-space.scenario.mjs | 140 ++++
wework/src/api/backend/backendServices.ts | 24 +-
wework/src/api/hybrid/hybridServices.test.ts | 46 ++
wework/src/api/hybrid/hybridServices.ts | 58 +-
wework/src/api/local/localServices.ts | 81 ++-
.../layout/DesktopWorkbenchLayout.tsx | 23 +-
.../src/features/todo/CloudProjectsHome.tsx | 70 +-
.../features/todo/CloudTodoWorkspace.test.tsx | 160 ++++-
.../src/features/todo/CloudTodoWorkspace.tsx | 533 ++++++++------
wework/src/features/todo/GlobalTodoSearch.tsx | 23 +-
.../features/todo/projectSpaceSelection.ts | 20 +
.../workbench/workbenchCloudDataEvents.ts | 6 +
.../features/workbench/workbenchServices.ts | 21 +
.../WorkspaceTabsContext.test.tsx | 2 +-
.../features/workspace-tabs/workspaceTabs.ts | 2 +-
.../workspace-tabs/workspaceWindow.test.ts | 8 +-
wework/src/pages/AutomationsPage.tsx | 4 +
21 files changed, 1569 insertions(+), 309 deletions(-)
create mode 100644 docs/plans/2026-08-15-wework-local-cloud-project-space-decoupling.md
create mode 100644 wework/e2e/desktop/scenarios/offline-local-project-space.scenario.mjs
diff --git a/docs/plans/2026-08-15-wework-local-cloud-project-space-decoupling.md b/docs/plans/2026-08-15-wework-local-cloud-project-space-decoupling.md
new file mode 100644
index 0000000000..d59191082d
--- /dev/null
+++ b/docs/plans/2026-08-15-wework-local-cloud-project-space-decoupling.md
@@ -0,0 +1,652 @@
+---
+sidebar_position: 99
+---
+
+# Wework 本地与云端项目空间解耦修复计划
+
+## 1. 背景
+
+Wework 的项目空间同时支持本地存储和云端存储:
+
+- 本地项目空间的数据由本地 Executor 持有。
+- 云端项目空间的数据由 Backend 持有。
+- 两类项目可以共用界面和领域类型,但不共享数据可用性,也不应共享请求生命周期。
+
+当前实现把本地和云端项目放进同一个项目数组,并在页面运行时根据裸
+`projectId` 查找项目、推断来源和选择 API。项目列表、项目详情和全局聚合视图又由
+同一个 `CloudTodoWorkspace` 组件管理,导致以下问题:
+
+1. 云端不可用时,本地项目列表需要等待云端请求完成才能结束加载状态。
+2. 打开本地项目详情后,部分子功能仍能通过全局 Hybrid Services 发起云端请求。
+3. 项目路由只保存 `projectId`,没有保存项目存储来源,恢复本地详情时必须先加载多个
+ 数据源。
+4. 项目列表初始化会预加载每个项目的任务和成员,把列表展示与详情请求绑定。
+5. `apiForProjectId()` 在来源未知时使用 `services.deliveryApi` 或第一个可用 API
+ fallback,可能把本地操作错误路由到云端。
+6. 本地项目自动化详情仍收到云端 `projectAutomationApi`、`teamApi` 等服务,云端故障
+ 因而会进入本地详情流程。
+
+这不是单个超时或错误处理缺失的问题,而是项目身份、数据所有权和服务注入边界不完整。
+
+## 2. 修复目标
+
+本次修复需要建立以下硬边界。
+
+### 2.1 项目列表
+
+- 本地项目列表只由本地 API 加载。
+- 本地列表加载完成后立即展示,不等待云端列表。
+- 云端列表作为独立、可选的数据源加载。
+- 云端加载中、失败或断开不能改变本地列表的 loading、error 和可交互状态。
+- 项目列表请求只返回项目摘要,不预加载任务、成员、文件或自动化详情。
+
+### 2.2 项目详情
+
+- 本地项目详情只持有本地项目服务。
+- 本地项目详情的挂载、刷新、切换标签和卸载均不得发起云端请求。
+- 云端项目详情只持有云端项目服务。
+- 项目详情不再根据裸 `projectId` 从混合项目数组中猜测来源。
+- 来源不完整或服务不可用时明确停止请求,不使用其他来源的 API fallback。
+
+### 2.3 全局聚合
+
+- “我的工作”“全局搜索”等确实需要跨来源的功能由独立聚合层负责。
+- 每个来源独立加载、独立失败、独立展示。
+- 本地结果不得等待云端结果。
+- 进入具体项目详情后,不继续运行与该详情无关的跨来源轮询或刷新。
+
+## 3. 非目标
+
+本次修复不做以下工作:
+
+- 不把本地项目迁移到 Backend。
+- 不在云端不可用时提供云端数据的伪本地 fallback。
+- 不通过增加重试次数掩盖错误路由。
+- 不通过缩短云端超时替代数据源解耦。
+- 不改变 Backend 对云端项目空间的授权边界。
+- 不修改现有 E2E 测试来绕过失败;只有日志和调用链证明测试逻辑错误时才调整测试本身。
+
+## 4. 当前错误模型
+
+当前主流程可以概括为:
+
+```text
+CloudTodoWorkspace
+ -> 收集 localApi + cloudApi
+ -> Promise.all 加载两个来源
+ -> 合并为 projects[]
+ -> 只保存 selectedProjectId
+ -> projects.find(project.id === selectedProjectId)
+ -> apiForProjectId(projectId)
+ -> 根据 projects[] 推断 location
+ -> 推断失败时 fallback 到 deliveryApi 或第一个 API
+ -> 把全局 WorkbenchServices 继续传给详情子组件
+```
+
+这个模型存在三个结构性错误:
+
+1. `projectId` 不是完整身份。
+2. 项目来源是运行时推断结果,而不是请求前已知条件。
+3. 详情组件能访问超出自身存储域的服务。
+
+## 5. 目标模型
+
+### 5.1 项目空间身份
+
+所有项目空间选择、路由、缓存和状态索引统一使用完整引用:
+
+```ts
+type ProjectStore = "local" | "backend";
+
+interface ProjectSpaceRef {
+ projectStore: ProjectStore;
+ projectId: string;
+}
+```
+
+统一 key:
+
+```ts
+function projectSpaceKey(ref: ProjectSpaceRef): string {
+ return `${ref.projectStore}:${ref.projectId}`;
+}
+```
+
+以下状态不得继续仅用 `project.id` 索引:
+
+- 当前选中项目
+- 工作区标签页路由
+- 项目任务缓存
+- 项目成员缓存
+- 项目统计缓存
+- 项目菜单状态
+- 重命名、归档和创建任务目标
+- 详情抽屉所属项目
+
+### 5.2 路由
+
+看板路由保存完整项目身份:
+
+```text
+/todo?projectStore=local&projectId=
+/todo?projectStore=backend&projectId=
+```
+
+不再通过加载所有项目来反推 `projectStore`。
+
+旧的、只包含 `projectId` 的持久化标签页不能触发来源猜测或跨来源请求。迁移时应提升工作区
+标签存储版本,并把无法确定来源的旧看板路由恢复到 `/todo` 项目首页。不要保留
+“依次请求本地和云端直到找到项目”的兼容路径。
+
+### 5.3 来源固定的项目上下文
+
+选中项目后创建来源固定的上下文:
+
+```ts
+interface ProjectSpaceContext {
+ ref: ProjectSpaceRef;
+ project: CloudProject;
+ services: ProjectSpaceDetailServices;
+}
+```
+
+详情服务接口按功能声明,而不是传入完整 `WorkbenchServices`:
+
+```ts
+interface ProjectSpaceDetailServices {
+ projectApi: DeliveryApi;
+ projectChatAgentApi?: ProjectChatAgentApi;
+ projectChatClient?: ProjectChatClient;
+ executionApi?: ExecutionListApi;
+ automationApi?: ProjectAutomationRulesApi;
+ deviceApi?: ProjectDeviceApi;
+ modelApi?: ProjectModelApi;
+ teamApi?: ProjectTeamApi;
+}
+```
+
+构造规则:
+
+```text
+projectStore=local
+ -> 只能构造 LocalProjectSpaceDetailServices
+
+projectStore=backend
+ -> 只能构造 CloudProjectSpaceDetailServices
+```
+
+详情组件不再接收整个 Hybrid `WorkbenchServices`,从类型层面移除访问其他存储域的能力。
+
+### 5.4 列表控制器
+
+列表状态按来源拆分:
+
+```ts
+interface ProjectListSourceState {
+ status: "idle" | "loading" | "loaded" | "error";
+ projects: CloudProject[];
+ error: string | null;
+}
+
+interface ProjectListsState {
+ local: ProjectListSourceState;
+ cloud: ProjectListSourceState;
+}
+```
+
+加载顺序:
+
+```text
+挂载项目首页
+ -> 启动本地列表请求
+ -> 本地返回
+ -> 立即展示本地项目
+
+ -> 云端已配置且当前允许加载时,独立启动云端列表请求
+ -> 云端返回后增量展示云端项目
+ -> 云端失败只更新 cloud.error
+```
+
+本地与云端请求之间不得存在 `Promise.all`、共享 loading 或共享 catch/finally。
+
+### 5.5 详情控制器
+
+详情控制器的输入必须是完整 `ProjectSpaceRef`:
+
+```text
+路由 ProjectSpaceRef
+ -> 选择对应来源的列表缓存或 getProject API
+ -> 构造固定来源 ProjectSpaceContext
+ -> 挂载 ProjectSpaceDetail
+ -> 所有详情请求从 context.services 发出
+```
+
+进入本地详情时:
+
+- 不加载云端项目列表。
+- 不调用云端 `listMyWork()`。
+- 不调用云端成员、任务、文件、自动化或执行 API。
+- 不调用会在后台刷新云端目录的混合 model/team/device API。
+- 不为确定项目来源发起探测请求。
+
+### 5.6 全局聚合控制器
+
+跨来源功能使用独立数据源适配器:
+
+```ts
+interface GlobalProjectSource {
+ source: "local" | "cloud";
+ listMyWork(): Promise;
+ search(query: ProjectSearchQuery): Promise;
+}
+```
+
+聚合层分别维护来源状态:
+
+```text
+GlobalMyWork
+ -> local source: loading/loaded/error
+ -> cloud source: idle/loading/loaded/error
+ -> UI 合并已完成来源的结果
+```
+
+项目详情不持有 `GlobalProjectSource[]`。
+
+## 6. 需要删除的错误路径
+
+实施时优先删除以下代码和抽象,再依靠 TypeScript 和测试暴露需要补齐的调用点:
+
+1. 删除详情流程中的 `availableProjectSpaceApis`。
+2. 删除 `apiForProjectId(projectId)`。
+3. 删除项目 API 的 `services.deliveryApi` fallback。
+4. 删除项目 API 的 `availableProjectSpaceApis[0]` fallback。
+5. 删除列表 effect 中为所有项目执行的 `listLoopItems()`。
+6. 删除列表 effect 中为所有项目执行的 `listCloudProjectMembers()`。
+7. 删除本地和云端共用的单一 `loading` 状态。
+8. 删除详情子组件对完整 `WorkbenchServices` 的依赖。
+9. 删除看板路由和页面状态中只保存裸 `projectId` 的路径。
+10. 删除本地项目自动化对 Backend `projectAutomationApi` 的引用。
+11. 删除本地项目自动化对云端 `teamApi.listTeams()` 的引用。
+12. 删除本地详情中 `services.deliveryApi.stopExecution` 优先于来源 API 的路径。
+13. 删除通过请求多个来源来寻找项目的兼容或探测逻辑。
+
+## 7. 分阶段实施计划
+
+### 阶段一:建立完整项目身份
+
+目标:让项目来源在进入页面和发出请求前已经确定。
+
+主要修改:
+
+- 复用并扩展 `projectSpaceRef()`、`projectSpaceKey()`。
+- 将工作区看板标签页的选择状态改为 `ProjectSpaceRef`。
+- 路由增加 `projectStore`。
+- 将 `activeProjectId`、`onActiveProjectChange` 改为完整项目引用。
+- 将项目相关 Map 的 key 改为 `projectSpaceKey`。
+- 提升 Workspace Tabs 持久化结构版本,失效无法确定来源的旧看板详情路由。
+
+预计涉及:
+
+- `wework/src/features/todo/projectSpaceSelection.ts`
+- `wework/src/features/todo/CloudTodoWorkspace.tsx`
+- `wework/src/components/layout/DesktopWorkbenchLayout.tsx`
+- `wework/src/features/workspace-tabs/`
+- `wework/src/lib/navigation.ts`
+
+验收条件:
+
+- 本地和云端存在相同 `projectId` 时仍可分别打开。
+- 恢复本地看板标签页不需要加载云端项目列表。
+- 来源缺失时回到项目首页,不猜测 API。
+
+### 阶段二:拆分项目列表生命周期
+
+目标:本地项目列表独立加载和展示。
+
+主要修改:
+
+- 提取 `useLocalProjectSpaces()`。
+- 提取 `useCloudProjectSpaces()`。
+- 每个 hook 独立维护 status、projects 和 error。
+- 项目首页根据两个来源的已完成结果组合展示。
+- 云端未连接时不启动云端列表 hook。
+- 删除总的 `Promise.all` 和全局 `loading`。
+- 删除项目列表阶段的任务和成员预加载。
+
+预计涉及:
+
+- `wework/src/features/todo/CloudTodoWorkspace.tsx`
+- 新增 `wework/src/features/todo/useLocalProjectSpaces.ts`
+- 新增 `wework/src/features/todo/useCloudProjectSpaces.ts`
+- `wework/src/features/todo/CloudProjectsHome.tsx`
+
+如果拆分后 `CloudTodoWorkspace.tsx` 仍承担列表、详情和所有子功能,应继续拆成:
+
+```text
+ProjectSpacesWorkspace
+ -> ProjectSpacesHome
+ -> ProjectSpaceDetail
+```
+
+验收条件:
+
+- 云端列表 Promise 永不结束时,本地列表仍结束 loading 并可点击。
+- 云端列表失败时,本地项目创建、重命名、归档和打开不受影响。
+- 项目首页初次加载不请求任何项目的任务和成员详情。
+
+### 阶段三:建立来源固定的详情服务
+
+目标:本地详情代码没有发起云端请求的能力。
+
+主要修改:
+
+- 定义 `ProjectSpaceDetailServices`。
+- 分别构造 `createLocalProjectSpaceDetailServices()` 和
+ `createCloudProjectSpaceDetailServices()`。
+- `ProjectSpaceDetail` 只接收 `ProjectSpaceContext`。
+- 看板、文件、成员、管理、任务详情和执行队列均使用 context 中的固定 API。
+- 去掉所有根据项目 ID 重新寻找 API 的逻辑。
+- 去掉详情中的 Hybrid service fallback。
+
+预计涉及:
+
+- `wework/src/features/workbench/workbenchServices.ts`
+- `wework/src/api/local/localServices.ts`
+- `wework/src/api/hybrid/hybridServices.ts`
+- `wework/src/features/todo/CloudTodoWorkspace.tsx`
+- `wework/src/features/todo/CloudFilesView.tsx`
+- `wework/src/features/todo/CloudProjectManageView.tsx`
+- `wework/src/features/todo/TodoEditor.tsx`
+- `wework/src/features/todo/TaskActivityView.tsx`
+- `wework/src/features/todo/ProjectQueueView.tsx`
+
+验收条件:
+
+- 打开本地项目详情后,云端 API mock 的调用次数始终为 0。
+- 切换本地详情中的看板、文件、管理和任务抽屉不会发云端请求。
+- 本地 API 不可用时明确显示本地错误,不尝试云端 API。
+
+### 阶段四:修复本地项目自动化服务
+
+目标:本地项目自动化不再复用 Backend 项目自动化服务。
+
+当前 `projectAutomationApi` 仅由 Backend Services 创建,而本地 Executor 已有通用
+`automationApi`。实施前需要明确本地项目自动化规则的唯一数据所有者,然后采用以下一种
+主路径:
+
+1. 如果本地 Executor 已拥有项目自动化规则,则实现本地
+ `ProjectAutomationRulesApi` 适配器,并从本地详情服务注入。
+2. 如果本地 Executor 当前只支持通用自动化,则把项目自动化 UI 改为使用本地
+ `automationApi` 的项目作用域能力。
+3. 如果本地尚未支持该能力,则在本地项目详情中明确隐藏或禁用该功能,并显示“本地项目
+ 暂不支持”,不得回退到 Backend。
+
+同时拆分自动化所需的辅助目录:
+
+- 本地设备目录只列本地设备。
+- 本地模型目录不得触发云端后台刷新。
+- 本地团队目录只提供本地可执行的团队或代理配置。
+- 本地项目不显示只能由 Backend 管理的 Wegent Team 执行模式。
+
+预计涉及:
+
+- `wework/src/features/todo/ProjectAutomationView.tsx`
+- `wework/src/features/todo/ProjectAutomationRulesSection.tsx`
+- `wework/src/api/projectAutomations.ts`
+- `wework/src/api/local/localServices.ts`
+- `wework/src/api/hybrid/hybridServices.ts`
+- 必要时涉及 `executor/src/` 的本地自动化命令
+
+验收条件:
+
+- 打开本地项目自动化标签时云端 HTTP 和云端 Runtime IPC 调用次数均为 0。
+- 云端断开时本地自动化页面不报云端连接错误。
+- 本地不支持的能力明确不可用,不创建 fallback。
+
+### 阶段五:拆分全局聚合视图
+
+目标:跨来源能力不再污染项目详情生命周期。
+
+主要修改:
+
+- 将 `listMyWork()` 从项目详情组件 effect 中移出。
+- 为本地和云端“我的工作”建立独立 source state。
+- 全局搜索按来源独立请求和合并。
+- 离开全局视图时取消对应请求和轮询。
+- 进入项目详情后只保留当前来源的详情刷新。
+
+预计涉及:
+
+- `wework/src/features/todo/CloudTodoWorkspace.tsx`
+- `wework/src/features/todo/CloudMyWorkView.tsx`
+- `wework/src/components/layout/WorkbenchSearchDialog.tsx`
+- 新增全局聚合 hooks 或 controller
+
+验收条件:
+
+- 云端“我的工作”请求挂起时,本地结果可见且可操作。
+- 本地项目详情挂载期间不产生全局云端聚合请求。
+
+### 阶段六:收口独立自动化页面
+
+目标:`/automations` 页面同样按来源独立展示,避免云端设备失败拖垮本地自动化。
+
+主要修改:
+
+- 删除 Hybrid `automationApi.listAutomations()` 中跨设备 `Promise.all` 的全成全败语义。
+- 本地自动化作为主数据源独立加载。
+- 每个云端设备作为独立数据源加载并记录错误。
+- 自动化 ID 的路由映射同时记录 source 和 device ID。
+- 本地自动化操作不通过云端设备发现来确定路由。
+
+预计涉及:
+
+- `wework/src/api/hybrid/hybridServices.ts`
+- `wework/src/pages/AutomationsPage.tsx`
+- `wework/src/types/automation.ts`
+
+验收条件:
+
+- 云端 Runtime IPC 超时 75 秒时,本地自动化列表仍立即可见。
+- 一个云端设备失败不会让本地或其他设备的自动化列表报全局错误。
+- 对本地自动化的查看、修改、启停和立即运行不触发云端请求。
+
+## 8. 测试计划
+
+### 8.1 单元测试
+
+新增或更新以下测试。
+
+#### 项目身份与路由
+
+- `projectSpaceKey()` 能区分相同 ID 的本地和云端项目。
+- 看板路由能够编码和解析 `projectStore + projectId`。
+- 缺少 `projectStore` 的旧详情路由恢复到项目首页。
+- Workspace Tab 恢复本地项目时不需要云端数据。
+
+#### 项目列表
+
+- 本地列表完成、云端列表 pending:本地项目立即展示。
+- 本地列表完成、云端列表 rejected:本地项目仍可交互。
+- 本地列表 rejected、云端列表完成:分别展示本地错误和云端项目。
+- 未连接云端:云端 API 调用次数为 0。
+- 列表加载期间 `listLoopItems()` 和 `listCloudProjectMembers()` 调用次数为 0。
+
+#### 本地项目详情
+
+为所有云端 API 使用严格 mock:
+
+```ts
+const cloudApi = {
+ listCloudProjects: vi.fn(() => {
+ throw new Error("unexpected cloud request");
+ }),
+ // 其余方法同样在调用时抛错
+};
+```
+
+覆盖:
+
+- 打开本地看板。
+- 刷新本地看板。
+- 打开任务详情。
+- 创建、修改、移动、归档任务。
+- 打开文件页。
+- 打开管理页。
+- 打开自动化页。
+- 打开执行队列。
+- 切换和恢复本地项目标签页。
+
+每个场景均断言云端 HTTP、云端 Delivery API 和云端 Runtime IPC 调用次数为 0。
+
+#### 云端项目详情
+
+- 云端项目仍使用 Backend API。
+- 云端详情不会错误使用本地项目 API。
+- 本地和云端同 ID 项目不会串数据。
+
+#### 自动化
+
+- 本地自动化独立加载。
+- 云端设备请求 pending 或 rejected 不影响本地结果。
+- 本地自动化操作不调用云端 Runtime IPC。
+
+### 8.2 组件集成测试
+
+主要扩展:
+
+- `wework/src/features/todo/CloudTodoWorkspace.test.tsx`
+- `wework/src/components/layout/DesktopWorkbenchLayout.test.tsx`
+- `wework/src/features/workspace-tabs/WorkspaceTabsContext.test.tsx`
+- `wework/src/pages/AutomationsPage.test.tsx`
+- `wework/src/api/hybrid/hybridServices.test.ts`
+
+测试必须使用可控 pending Promise,而不是依赖真实计时:
+
+```ts
+const neverResolvingCloudRequest = new Promise(() => undefined);
+```
+
+核心断言不是“最终会恢复”,而是“云端请求未完成时本地功能已经可用”。
+
+### 8.3 Desktop E2E
+
+按照现有 Desktop E2E runner 增加 CI 覆盖的回归场景,不创建本地专用孤立命令。
+
+测试环境:
+
+- 隔离的真实 Tauri 会话。
+- 本地 Executor 正常。
+- 保存一份有效但当前不可达的云端连接配置,或通过测试云端服务稳定模拟连接后断开。
+- 创建至少一个本地项目和一条本地任务。
+
+场景:
+
+1. 云端不可达时启动 Wework。
+2. 打开项目空间首页。
+3. 断言本地项目在普通步骤超时内可见。
+4. 打开本地项目。
+5. 查看、创建和修改本地任务。
+6. 打开本地项目的文件、管理和自动化标签。
+7. 打开独立自动化页面,确认本地自动化可见。
+8. 检查日志,确认上述本地详情操作期间没有云端项目详情请求。
+
+不得通过重试获得通过结果。出现间歇性失败时必须定位并修复。
+
+### 8.4 真实 Tauri 验证
+
+由于修改 Wework UI、服务路由和本地运行时边界,必须使用
+`pnpm --filter wework ai:verify` 完成真实 Tauri 验证。
+
+至少验证:
+
+- 冷启动。
+- 云端断开启动。
+- 本地列表。
+- 本地详情。
+- 本地任务编辑。
+- 本地自动化。
+- 从本地详情返回项目首页。
+- 重新加载和标签页恢复。
+
+保留 `app.log`、`executor.log` 和 Tauri 日志作为请求边界证据。
+
+## 9. 诊断日志
+
+为验证请求边界,可在项目空间 API 入口增加结构化诊断日志,但不得记录 token 或项目内容:
+
+```ts
+console.info("[ProjectSpace] request", {
+ source: "local",
+ operation: "listLoopItems",
+ projectStore: ref.projectStore,
+ projectId: ref.projectId,
+});
+```
+
+测试和真实 Tauri 验证应能够根据日志确认:
+
+- 本地详情只出现 `source=local`。
+- 云端列表失败不会阻塞本地列表完成事件。
+- 项目来源在请求发出前已经确定。
+
+日志用于确认边界,不用于替代类型约束和自动化测试。
+
+## 10. 风险与处理
+
+### 10.1 Workspace Tab 持久化兼容
+
+旧标签页没有 `projectStore`。不要通过同时请求本地和云端来恢复,避免重新引入耦合。通过存储
+版本迁移将旧详情标签恢复到项目首页。
+
+### 10.2 项目 ID 冲突
+
+所有缓存和 UI 状态改用 `projectSpaceKey` 后,必须检查项目卡片 key、详情抽屉、菜单、统计和
+任务缓存,防止残留裸 ID。
+
+### 10.3 本地自动化能力不完整
+
+先确认 Executor 的实际能力。缺少能力时应明确禁用,不得调用 Backend 作为隐式实现。
+
+### 10.4 组件拆分范围
+
+`CloudTodoWorkspace.tsx` 已同时承担过多职责。实施过程中应优先删除混合路径并拆分组件,
+避免继续在大组件内增加条件分支。
+
+### 10.5 云端连接状态
+
+本次解耦不能依赖 `isConnected` 完全准确。即使缓存状态暂时仍是 connected,只要请求边界
+正确,云端故障也只能影响云端数据源。连接状态后续可以独立改进,但不能成为本地正确性的
+前提。
+
+## 11. 完成标准
+
+同时满足以下条件后才算完成:
+
+1. 项目身份统一为 `projectStore + projectId`。
+2. 本地和云端项目列表拥有独立状态和请求生命周期。
+3. 项目列表不再预加载所有项目详情。
+4. `apiForProjectId()` 和跨来源 API fallback 被删除。
+5. 本地项目详情组件无法访问云端项目服务。
+6. 本地项目详情所有功能的云端 API 调用次数为 0。
+7. 本地项目自动化不使用 Backend 项目自动化 API。
+8. 全局聚合视图按来源独立加载和失败。
+9. 独立自动化页面中本地数据不等待云端设备。
+10. 聚焦单元测试、组件测试、Desktop E2E 和真实 Tauri 验证全部通过。
+11. 云端不可达时,本地项目列表和详情在正常交互时间内可用。
+12. 没有通过重试、fallback、静默 catch 或跳过测试掩盖失败。
+
+## 12. 建议提交顺序
+
+为降低评审和回归风险,建议按以下顺序提交:
+
+1. `refactor(wework): scope project space identity by store`
+2. `refactor(wework): split local and cloud project list loading`
+3. `refactor(wework): inject source-bound project detail services`
+4. `fix(wework): keep local project automation offline`
+5. `refactor(wework): isolate cross-source project aggregations`
+6. `fix(wework): load local automations independently from cloud`
+7. `test(wework): cover offline local project boundaries`
+
+每个提交都应保持可编译,并运行对应的聚焦测试。最终提交前运行 Wework 的完整测试和真实
+Tauri 验证。
diff --git a/wework/e2e/desktop/checkpoints.mjs b/wework/e2e/desktop/checkpoints.mjs
index b6369e0377..10766543bf 100644
--- a/wework/e2e/desktop/checkpoints.mjs
+++ b/wework/e2e/desktop/checkpoints.mjs
@@ -4,6 +4,7 @@ export const DESKTOP_CHECKPOINTS = [
'telemetry-consent',
'automation-lifecycle',
'project-automation',
+ 'offline-local-project-space',
'plugin-auto-update',
'model-routing',
'permission-modes',
diff --git a/wework/e2e/desktop/modules/workspace-flows.mjs b/wework/e2e/desktop/modules/workspace-flows.mjs
index 52651e540c..688110e2e3 100644
--- a/wework/e2e/desktop/modules/workspace-flows.mjs
+++ b/wework/e2e/desktop/modules/workspace-flows.mjs
@@ -749,7 +749,7 @@ async function verifyWorkspaceTabIsolation(control) {
'workspace-tabs-isolation-05-detached-window.png'
)
- const sourceStorageKey = 'wework.workspaceTabs.v2:main'
+ const sourceStorageKey = 'wework.workspaceTabs.v3:main'
const sourceTabRemovalStartedAt = Date.now()
let sourceTabs = []
while (Date.now() - sourceTabRemovalStartedAt < DEFAULT_STEP_TIMEOUT_MS) {
diff --git a/wework/e2e/desktop/run-checkpoints.mjs b/wework/e2e/desktop/run-checkpoints.mjs
index 7bfbcfbe85..3f94678f2f 100644
--- a/wework/e2e/desktop/run-checkpoints.mjs
+++ b/wework/e2e/desktop/run-checkpoints.mjs
@@ -22,12 +22,14 @@ const CHECKPOINT_SCENARIO_MODULES = {
'runtime-task-queue': './scenarios/runtime-task-queue.scenario.mjs',
'split-workbench': './scenarios/split-workbench.scenario.mjs',
'project-automation': './scenarios/project-automation.scenario.mjs',
+ 'offline-local-project-space': './scenarios/offline-local-project-space.scenario.mjs',
}
const SCENARIO_ONLY_CHECKPOINTS = new Set([
'change-request-status',
'claude-runtime',
'local-file-preview',
'local-harness',
+ 'offline-local-project-space',
'runtime-task-queue',
'split-workbench',
'temporary-chat',
diff --git a/wework/e2e/desktop/scenarios/offline-local-project-space.scenario.mjs b/wework/e2e/desktop/scenarios/offline-local-project-space.scenario.mjs
new file mode 100644
index 0000000000..c8d80f853a
--- /dev/null
+++ b/wework/e2e/desktop/scenarios/offline-local-project-space.scenario.mjs
@@ -0,0 +1,140 @@
+import assert from 'node:assert/strict'
+
+const PROJECT_NAME = '离线本地项目空间'
+const TASK_NAME = '离线本地任务'
+const UPDATED_TASK_NAME = '离线本地任务(已更新)'
+
+function json(response, status, body) {
+ response.writeHead(status, { 'content-type': 'application/json' })
+ response.end(JSON.stringify(body))
+}
+
+export function createDesktopScenario({ uiTimeoutMs }) {
+ let cloudProjectListFailures = 0
+ const cloudDetailRequests = []
+
+ return {
+ async handleHttp(request, response, url) {
+ if (request.method === 'GET' && url.pathname === '/api/v1/cloud-projects') {
+ cloudProjectListFailures += 1
+ json(response, 503, { detail: 'Desktop E2E cloud project service is unavailable' })
+ return true
+ }
+ if (url.pathname.startsWith('/api/v1/cloud-projects/')) {
+ cloudDetailRequests.push(`${request.method} ${url.pathname}`)
+ }
+ return false
+ },
+
+ async verify(control) {
+ await control.command('waitFor', '[data-testid="workspace-tab-add"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid="workspace-tab-add"]')
+ await control.command('waitFor', '[data-testid="workspace-tab-add-menu"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid="workspace-tab-add-board"]')
+ await control.command('waitFor', '[data-testid="cloud-todo-workspace"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('waitFor', '[data-testid="cloud-project-add"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+
+ await control.command('click', '[data-testid="cloud-project-add"]')
+ await control.command('waitFor', '[data-testid="cloud-project-name"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('fill', '[data-testid="cloud-project-name"]', {
+ value: PROJECT_NAME,
+ })
+ await control.command('click', '[data-testid="cloud-project-location-local"]')
+ await control.command('click', '[data-testid="cloud-project-task-provider-local"]')
+ await control.command('clickWhenEnabled', '[data-testid="cloud-project-create-confirm"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+
+ await control.command('waitFor', '[data-testid="cloud-todo-column-inbox"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid="cloud-todo-add"]')
+ await control.command('waitFor', '[data-testid="cloud-todo-create-panel"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('fill', '[data-testid="cloud-todo-title"]', {
+ value: TASK_NAME,
+ })
+ await control.command('clickWhenEnabled', '[data-testid="cloud-todo-create-confirm"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('waitFor', '[data-testid^="cloud-todo-card-"]', {
+ text: TASK_NAME,
+ timeoutMs: uiTimeoutMs,
+ })
+ const boardSnapshot = JSON.parse(await control.command('snapshot', 'body'))
+ const taskCardTestId = boardSnapshot.testIds.find(
+ testId =>
+ testId.startsWith('cloud-todo-card-') &&
+ ![
+ 'cloud-todo-card-add-child-',
+ 'cloud-todo-card-assignee-',
+ 'cloud-todo-card-archive-',
+ 'cloud-todo-card-drop-',
+ 'cloud-todo-card-menu-',
+ 'cloud-todo-card-more-',
+ ].some(prefix => testId.startsWith(prefix))
+ )
+ assert.ok(taskCardTestId, 'The newly created local task card was not present in the board')
+ await control.command('click', `[data-testid="${taskCardTestId}"]`)
+ await control.command('waitFor', '[data-testid="cloud-todo-detail"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('fill', '[data-testid="cloud-todo-detail-title"]', {
+ value: UPDATED_TASK_NAME,
+ })
+ await control.command('clickWhenEnabled', '[data-testid="cloud-todo-save"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid="cloud-todo-detail-close"]')
+ await control.command('waitFor', `[data-testid="${taskCardTestId}"]`, {
+ text: UPDATED_TASK_NAME,
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid="cloud-project-files-view"]')
+ await control.command('waitFor', '[data-testid="cloud-files-upload"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid="cloud-project-manage-view"]')
+ await control.command('waitFor', '[data-testid="cloud-project-members-toggle"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid="cloud-project-automation-view"]')
+ await control.command('waitFor', '[data-testid="project-automation-view"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid^="workspace-tab-select-task-"]')
+ await control.command('waitFor', '[data-testid="automation-button"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+ await control.command('click', '[data-testid="automation-button"]')
+ await control.command('waitFor', '[data-testid="create-automation-button"]', {
+ timeoutMs: uiTimeoutMs,
+ })
+
+ assert.ok(
+ cloudProjectListFailures > 0,
+ 'The scenario did not exercise an unavailable cloud project list'
+ )
+ assert.deepEqual(
+ cloudDetailRequests,
+ [],
+ `Local project details unexpectedly called cloud APIs: ${cloudDetailRequests.join(', ')}`
+ )
+ },
+
+ diagnostics() {
+ return { cloudDetailRequests, cloudProjectListFailures }
+ },
+ }
+}
diff --git a/wework/src/api/backend/backendServices.ts b/wework/src/api/backend/backendServices.ts
index f1a2676158..5b0448f131 100644
--- a/wework/src/api/backend/backendServices.ts
+++ b/wework/src/api/backend/backendServices.ts
@@ -69,9 +69,14 @@ export function createBackendWorkbenchServices(
getToken: resolveToken,
})
+ const teamApi = createTeamApi(client)
+ const modelApi = createModelApi(client)
+ const projectChatAgentApi = createProjectChatAgentApi(client)
+ const projectAutomationApi = createProjectAutomationApi(client)
+
return {
- teamApi: createTeamApi(client),
- modelApi: createModelApi(client),
+ teamApi,
+ modelApi,
skillApi: createSkillApi(client),
projectApi,
gitApi: createGitApi(client),
@@ -83,6 +88,17 @@ export function createBackendWorkbenchServices(
cloud: deliveryApi,
defaultLocation: 'cloud',
},
+ projectSpaceDetailServices: {
+ cloud: {
+ deliveryApi,
+ projectChatClient,
+ projectChatAgentApi,
+ projectAutomationApi,
+ deviceApi,
+ modelApi,
+ teamApi,
+ },
+ },
imSessionApi: createImSessionApi(client),
runtimeWorkApi,
attachmentApi: createAttachmentApi({
@@ -100,8 +116,8 @@ export function createBackendWorkbenchServices(
userApi: createUserApi(client),
socketClient,
projectChatClient,
- projectChatAgentApi: createProjectChatAgentApi(client),
- projectAutomationApi: createProjectAutomationApi(client),
+ projectChatAgentApi,
+ projectAutomationApi,
workspaceSessionApi: {
startProjectTerminal: projectApi.startTerminalSession,
startProjectCodeServer: projectApi.startCodeServerSession,
diff --git a/wework/src/api/hybrid/hybridServices.test.ts b/wework/src/api/hybrid/hybridServices.test.ts
index 7cb6c8a9ed..b31a4a4268 100644
--- a/wework/src/api/hybrid/hybridServices.test.ts
+++ b/wework/src/api/hybrid/hybridServices.test.ts
@@ -1447,6 +1447,52 @@ describe('createHybridWorkbenchServices', () => {
expect(services.workspaceSessionApi).toBe(mocks.cloudWorkspaceSessionApi)
})
+ it('returns local automations without waiting for an unresponsive cloud executor', async () => {
+ mocks.cloudListDevices.mockResolvedValue([
+ {
+ device_id: 'cloud-device',
+ device_type: 'cloud',
+ status: 'online',
+ },
+ ])
+ mocks.localAutomationApi.listAutomations.mockResolvedValue({
+ items: [
+ {
+ id: 'local-automation',
+ source: 'local',
+ version: 1,
+ name: 'Local automation',
+ description: '',
+ prompt: 'Run locally',
+ schedule: { type: 'interval', value: 1, unit: 'hours' },
+ timezone: 'UTC',
+ enabled: true,
+ conversationMode: 'independent',
+ notificationPolicy: 'all_runs',
+ taskRequest: { deviceId: 'local-device' },
+ createdAt: '2026-08-15T00:00:00Z',
+ updatedAt: '2026-08-15T00:00:00Z',
+ },
+ ],
+ })
+ mocks.cloudRuntimeIpcRequest.mockImplementation(method =>
+ method === 'runtime.automations.list'
+ ? new Promise(() => undefined)
+ : Promise.resolve({ items: [] })
+ )
+ const services = createServices()
+ await services.cloudBackgroundApi?.listDevices?.()
+
+ const response = await services.automationApi?.listAutomations()
+
+ expect(response?.items.map(item => item.id)).toEqual(['local-automation'])
+ expect(mocks.cloudRuntimeIpcRequest).toHaveBeenCalledWith(
+ 'runtime.automations.list',
+ {},
+ 'cloud-device'
+ )
+ })
+
it('routes cloud automations to the selected remote executor', async () => {
mocks.cloudRuntimeIpcRequest.mockImplementation(async (method, params) => {
if (method === 'runtime.automations.create') {
diff --git a/wework/src/api/hybrid/hybridServices.ts b/wework/src/api/hybrid/hybridServices.ts
index 793b929e55..b41f79e83d 100644
--- a/wework/src/api/hybrid/hybridServices.ts
+++ b/wework/src/api/hybrid/hybridServices.ts
@@ -15,6 +15,7 @@ import type { WorkbenchServices } from '@/features/workbench/workbenchServices'
import {
notifyWorkbenchCloudArchivesChanged,
notifyWorkbenchCloudSearchResults,
+ notifyWorkbenchAutomationsChanged,
notifyWorkbenchModelsChanged,
} from '@/features/workbench/workbenchCloudDataEvents'
import { requestCloudModelCatalogSync } from '@/features/model-settings/cloudModelCatalogSyncRequest'
@@ -336,6 +337,8 @@ export function createHybridWorkbenchServices(
})
const cloudRuntimeApis = new Map>()
const cloudAutomationApis = new Map>()
+ const rememberedCloudAutomations = new Map()
+ const cloudAutomationRequests = new Map>()
const automationDevices = new Map()
const localDeviceIds = new Set([LOCAL_DEVICE_ID])
const localRuntimeInstanceIds = new Set()
@@ -1008,6 +1011,29 @@ export function createHybridWorkbenchServices(
const rememberAutomationRoutes = (deviceId: string, automations: Automation[]) => {
automations.forEach(automation => automationDevices.set(automation.id, deviceId))
}
+ const refreshCloudAutomationsInBackground = () => {
+ rememberedCloudDevices.filter(isUsableDevice).forEach(device => {
+ const deviceId = device.device_id
+ if (cloudAutomationRequests.has(deviceId)) return
+ const request = automationApiForDevice(deviceId)
+ .listAutomations()
+ .then(response => {
+ rememberAutomationRoutes(deviceId, response.items)
+ rememberedCloudAutomations.set(deviceId, response.items)
+ notifyWorkbenchAutomationsChanged()
+ })
+ .catch(error => {
+ console.warn('[Wework] Failed to refresh cloud automations in background', {
+ deviceId,
+ error,
+ })
+ })
+ .finally(() => {
+ cloudAutomationRequests.delete(deviceId)
+ })
+ cloudAutomationRequests.set(deviceId, request)
+ })
+ }
const automationMutationDeviceId = (data: { taskRequest?: RuntimeTaskCreateRequest }) => {
const deviceId = data.taskRequest?.deviceId?.trim()
if (!deviceId) throw new Error('Automation target device is required')
@@ -1023,17 +1049,12 @@ export function createHybridWorkbenchServices(
}
const automationApi: NonNullable = {
async listAutomations() {
- const cloudDeviceIds = rememberedCloudDevices
- .filter(device => isUsableDevice(device))
- .map(device => device.device_id)
- const deviceIds = [LOCAL_DEVICE_ID, ...cloudDeviceIds]
- const responses = await Promise.all(
- deviceIds.map(deviceId => automationApiForDevice(deviceId).listAutomations())
- )
- responses.forEach((response, index) =>
- rememberAutomationRoutes(deviceIds[index], response.items)
- )
- return { items: responses.flatMap(response => response.items) }
+ const localResponse = await localServices.automationApi!.listAutomations()
+ rememberAutomationRoutes(LOCAL_DEVICE_ID, localResponse.items)
+ refreshCloudAutomationsInBackground()
+ return {
+ items: [...localResponse.items, ...Array.from(rememberedCloudAutomations.values()).flat()],
+ }
},
async getAutomation(automationId) {
const deviceId = await automationDeviceId(automationId)
@@ -1077,16 +1098,7 @@ export function createHybridWorkbenchServices(
const deviceId = await automationDeviceId(automationId)
return automationApiForDevice(deviceId).listAutomationRuns(automationId)
}
- const deviceIds = [
- LOCAL_DEVICE_ID,
- ...rememberedCloudDevices
- .filter(device => isUsableDevice(device))
- .map(device => device.device_id),
- ]
- const responses = await Promise.all(
- deviceIds.map(deviceId => automationApiForDevice(deviceId).listAutomationRuns())
- )
- return { items: responses.flatMap(response => response.items) }
+ return localServices.automationApi!.listAutomationRuns()
},
}
@@ -1149,6 +1161,10 @@ export function createHybridWorkbenchServices(
cloud: cloudProjectSpaceApi,
defaultLocation: 'cloud',
},
+ projectSpaceDetailServices: {
+ local: localServices.projectSpaceDetailServices?.local,
+ cloud: cloudServices.projectSpaceDetailServices?.cloud,
+ },
teamApi: {
// Wegent Teams are backend CRDs. The local service only exposes the
// synthetic id=0 workbench Team, which is valid as the local default but
diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts
index 92369ac722..5fcfe1d020 100644
--- a/wework/src/api/local/localServices.ts
+++ b/wework/src/api/local/localServices.ts
@@ -3175,42 +3175,44 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben
})
const aitableApi = createLocalAITableApi(request)
const dwsApi = createDwsApi(request)
+ const teamApi = {
+ listTeams: async () => [LOCAL_WORKBENCH_TEAM],
+ getDefaultWorkbenchTeam: async () => LOCAL_WORKBENCH_TEAM,
+ }
+ const modelApi = {
+ listModels: async () => {
+ let codexOfficialModels: CodexOfficialModel[]
+ let codexOfficialError: string | null
+ let codexAuthConfigured: boolean
+ try {
+ await ensureStatus()
+ const [codexOfficialResult, nextCodexAuthConfigured] = await Promise.all([
+ requestLocalCodexOfficialModels(request).then(
+ value => ({ value, error: null }),
+ error => ({
+ value: null,
+ error: error instanceof Error ? error.message : String(error),
+ })
+ ),
+ loadLocalCodexAuthConfigured(request),
+ ])
+ codexOfficialModels = codexOfficialResult.value?.models ?? []
+ codexOfficialError = codexOfficialResult.error
+ codexAuthConfigured = nextCodexAuthConfigured
+ } catch (error) {
+ codexOfficialModels = []
+ codexOfficialError = error instanceof Error ? error.message : String(error)
+ codexAuthConfigured = false
+ }
+ return {
+ data: localRuntimeModels(codexOfficialModels, codexOfficialError, codexAuthConfigured),
+ }
+ },
+ }
return {
- teamApi: {
- listTeams: async () => [LOCAL_WORKBENCH_TEAM],
- getDefaultWorkbenchTeam: async () => LOCAL_WORKBENCH_TEAM,
- },
- modelApi: {
- listModels: async () => {
- let codexOfficialModels: CodexOfficialModel[]
- let codexOfficialError: string | null
- let codexAuthConfigured: boolean
- try {
- await ensureStatus()
- const [codexOfficialResult, nextCodexAuthConfigured] = await Promise.all([
- requestLocalCodexOfficialModels(request).then(
- value => ({ value, error: null }),
- error => ({
- value: null,
- error: error instanceof Error ? error.message : String(error),
- })
- ),
- loadLocalCodexAuthConfigured(request),
- ])
- codexOfficialModels = codexOfficialResult.value?.models ?? []
- codexOfficialError = codexOfficialResult.error
- codexAuthConfigured = nextCodexAuthConfigured
- } catch (error) {
- codexOfficialModels = []
- codexOfficialError = error instanceof Error ? error.message : String(error)
- codexAuthConfigured = false
- }
- return {
- data: localRuntimeModels(codexOfficialModels, codexOfficialError, codexAuthConfigured),
- }
- },
- },
+ teamApi,
+ modelApi,
skillApi: {
listSkills: async () => [],
getTeamSkills: async () => ({ skills: [], preload_skills: [] }),
@@ -3255,6 +3257,17 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben
local: deliveryApi,
defaultLocation: 'local',
},
+ projectSpaceDetailServices: {
+ local: {
+ deliveryApi,
+ projectChatClient: localProjectChatClient,
+ projectChatAgentApi: localProjectChatAgentApi,
+ loopItemExecutionApi: localLoopItemExecutionApi,
+ deviceApi,
+ modelApi,
+ teamApi,
+ },
+ },
runtimeWorkApi,
automationApi,
attachmentApi: createLocalAttachmentApi(),
diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx
index 9d875e0ccf..78a12e7998 100644
--- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx
+++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx
@@ -6,6 +6,7 @@ import { useAuth } from '@/features/auth/useAuth'
import type {
IMPrivateSession,
ProjectWithTasks,
+ RuntimeProjectSpaceRef,
RuntimeTaskAddress,
RuntimeIMNotificationSettingsResponse,
} from '@/types/api'
@@ -90,6 +91,13 @@ function boardRouteParam(contentRoute: string, name: string): string | null {
return new URLSearchParams(contentRoute.slice(searchIndex + 1)).get(name)
}
+function boardRouteProjectRef(contentRoute: string): RuntimeProjectSpaceRef | null {
+ const projectId = boardRouteParam(contentRoute, 'projectId')
+ const projectStore = boardRouteParam(contentRoute, 'projectStore')
+ if (!projectId || (projectStore !== 'local' && projectStore !== 'backend')) return null
+ return { projectId, projectStore }
+}
+
interface DesktopWorkbenchLayoutProps {
routeActive?: boolean
}
@@ -923,9 +931,9 @@ export function DesktopWorkbenchLayout({ routeActive = true }: DesktopWorkbenchL
runtimeWork={state.runtimeWork}
services={services}
onOpenRuntimeTask={openProjectSpaceRuntimeTask}
- activeProjectId={
+ activeProjectRef={
workspaceTabs?.activeTab.kind === 'board'
- ? boardRouteParam(workspaceTabs.activeTab.contentRoute, 'projectId')
+ ? boardRouteProjectRef(workspaceTabs.activeTab.contentRoute)
: undefined
}
focusedItemId={
@@ -935,12 +943,12 @@ export function DesktopWorkbenchLayout({ routeActive = true }: DesktopWorkbenchL
}
onFocusedItemHandled={() => {
if (!workspaceTabs || workspaceTabs.activeTab.kind !== 'board') return
- const projectId = boardRouteParam(
- workspaceTabs.activeTab.contentRoute,
- 'projectId'
- )
+ const projectRef = boardRouteProjectRef(workspaceTabs.activeTab.contentRoute)
const params = new URLSearchParams()
- if (projectId) params.set('projectId', projectId)
+ if (projectRef) {
+ params.set('projectStore', projectRef.projectStore)
+ params.set('projectId', projectRef.projectId)
+ }
workspaceTabs.updateActiveTab({
contentRoute: `/todo${params.size ? `?${params.toString()}` : ''}`,
})
@@ -955,6 +963,7 @@ export function DesktopWorkbenchLayout({ routeActive = true }: DesktopWorkbenchL
return
}
const params = new URLSearchParams()
+ params.set('projectStore', project.project_store)
params.set('projectId', project.id)
workspaceTabs.updateActiveTab({
title: project.name,
diff --git a/wework/src/features/todo/CloudProjectsHome.tsx b/wework/src/features/todo/CloudProjectsHome.tsx
index 0a200cc6bc..672bbd461c 100644
--- a/wework/src/features/todo/CloudProjectsHome.tsx
+++ b/wework/src/features/todo/CloudProjectsHome.tsx
@@ -7,15 +7,24 @@ import { useTranslation } from '@/hooks/useTranslation'
import { copyTextToClipboard } from '@/lib/clipboard'
import { cn } from '@/lib/utils'
import { CloudTodoModal as Modal } from './CloudTodoModal'
+import { projectSpaceKey } from './projectSpaceSelection'
import { memberAvatarClasses, memberNameById } from './todoShared'
export interface ProjectsHomeProject {
id: string
name: string
location: 'local' | 'cloud'
+ project_store: 'local' | 'backend'
updated_at: string
}
+function projectKey(project: ProjectsHomeProject): string {
+ return projectSpaceKey({
+ projectStore: project.project_store,
+ projectId: project.id,
+ })
+}
+
interface CloudProjectsHomeProps {
projects: ProjectsHomeProject[]
projectCounts: Record
@@ -24,8 +33,8 @@ interface CloudProjectsHomeProps {
myWork: CloudMyWorkItem[]
searchQuery: string
onCreateProject: () => void
- onSelectProject: (projectId: string) => void
- onManageProject: (projectId: string) => void
+ onSelectProject: (project: ProjectsHomeProject) => void
+ onManageProject: (project: ProjectsHomeProject) => void
onSelectItem: (item: CloudMyWorkItem) => void
onOpenMyWork: () => void
}
@@ -200,7 +209,14 @@ export function CloudProjectsHome({
[projects, query]
)
- const allItems = useMemo(() => Object.values(projectItems).flat(), [projectItems])
+ const allItemEntries = useMemo(
+ () =>
+ Object.entries(projectItems).flatMap(([spaceKey, items]) =>
+ items.map(item => ({ item, spaceKey }))
+ ),
+ [projectItems]
+ )
+ const allItems = useMemo(() => allItemEntries.map(entry => entry.item), [allItemEntries])
const completedCount = useMemo(
() => allItems.filter(item => item.status === 'completed').length,
[allItems]
@@ -223,10 +239,10 @@ export function CloudProjectsHome({
const recentActivity = useMemo(
() =>
- [...allItems]
- .sort((a, b) => b.updated_at.localeCompare(a.updated_at))
+ [...allItemEntries]
+ .sort((a, b) => b.item.updated_at.localeCompare(a.item.updated_at))
.slice(0, MAX_RECENT_ACTIVITY),
- [allItems]
+ [allItemEntries]
)
const myTodos = useMemo(
() => myWork.filter(item => item.status !== 'completed').slice(0, MAX_MY_TODOS),
@@ -261,8 +277,8 @@ export function CloudProjectsHome({
completed: t('todo.status_completed', '已完成'),
}
- const projectNameById = useMemo(
- () => new Map(projects.map(project => [String(project.id), project.name])),
+ const projectByKey = useMemo(
+ () => new Map(projects.map(project => [projectKey(project), project])),
[projects]
)
@@ -313,12 +329,12 @@ export function CloudProjectsHome({
{t('todo.home_no_activity', '暂无动态')}
) : (
- recentActivity.map((item, itemIndex) => {
- const projectId = String(item.cloud_project_id)
+ recentActivity.map(({ item, spaceKey }, itemIndex) => {
+ const project = projectByKey.get(spaceKey)
const actorName =
- memberNameById(projectMembers[projectId] ?? [], item.assignee_user_id) ??
+ memberNameById(projectMembers[spaceKey] ?? [], item.assignee_user_id) ??
item.created_by_user_name ??
- memberNameById(projectMembers[projectId] ?? [], item.created_by_user_id) ??
+ memberNameById(projectMembers[spaceKey] ?? [], item.created_by_user_id) ??
t('todo.home_activity_someone', '有人')
const actionLabel =
item.status === 'completed'
@@ -326,9 +342,11 @@ export function CloudProjectsHome({
: t('todo.home_activity_updated', '更新了任务')
return (