From b4e7cca6d078362d239bb4bbe3bfd7453bb83f06 Mon Sep 17 00:00:00 2001 From: axb Date: Tue, 4 Aug 2026 17:31:47 +0800 Subject: [PATCH 1/7] feat(project-space): link local tasks to project spaces --- backend/app/api/endpoints/cloud_projects.py | 33 ++ backend/app/api/endpoints/deliveries.py | 106 +++++ backend/app/schemas/cloud_project.py | 4 + backend/app/schemas/delivery.py | 33 ++ .../app/services/cloud_projects/service.py | 63 ++- backend/app/services/loop_items/service.py | 18 + backend/tests/api/test_cloud_projects_api.py | 84 ++++ docs/en/wework/projects.md | 8 + docs/en/wework/tasks.md | 6 + docs/zh/wework/projects.md | 8 + docs/zh/wework/tasks.md | 6 + wework/e2e/desktop/task-flow.e2e.mjs | 89 ++++ wework/src/api/deliveries.ts | 68 ++- wework/src/api/local/localDelivery.test.ts | 162 +++++++ wework/src/api/local/localDelivery.ts | 175 +++++++- wework/src/components/chat/ChatInput.tsx | 3 + .../chat/composer/AddContextMenu.test.tsx | 59 +++ .../chat/composer/AddContextMenu.tsx | 225 +++++++--- .../chat/composer/ComposerToolbar.tsx | 11 + .../chat/composer/ProjectChatComposer.tsx | 5 + .../components/layout/DesktopSidebar.test.tsx | 52 +++ .../src/components/layout/DesktopSidebar.tsx | 4 + .../layout/DesktopWorkbenchLayout.tsx | 6 + .../layout/DesktopWorkbenchMain.tsx | 411 +++++++++++++++--- .../projects/LocalProjectEditDialog.test.tsx | 64 ++- .../projects/LocalProjectEditDialog.tsx | 97 ++++- .../features/todo/CloudProjectManageView.tsx | 161 ++++++- .../src/features/todo/CloudTodoWorkspace.tsx | 1 + .../features/todo/TodoBindingPicker.test.tsx | 101 ++++- .../src/features/todo/TodoBindingPicker.tsx | 144 ++++-- .../todo/projectSpaceLocalBindings.test.ts | 113 +++++ .../todo/projectSpaceLocalBindings.ts | 166 +++++++ .../features/workbench/WorkbenchProvider.tsx | 36 ++ wework/src/i18n/locales/en/common.json | 19 + wework/src/i18n/locales/zh-CN/common.json | 19 + 35 files changed, 2346 insertions(+), 214 deletions(-) create mode 100644 wework/src/features/todo/projectSpaceLocalBindings.test.ts create mode 100644 wework/src/features/todo/projectSpaceLocalBindings.ts diff --git a/backend/app/api/endpoints/cloud_projects.py b/backend/app/api/endpoints/cloud_projects.py index 3d71c8d1b3..e6c28418d7 100644 --- a/backend/app/api/endpoints/cloud_projects.py +++ b/backend/app/api/endpoints/cloud_projects.py @@ -29,6 +29,7 @@ CloudProjectUpdate, LocalBindingCreate, LocalBindingResponse, + LocalBindingUpdate, ) from app.services.cloud_files import cloud_file_service from app.services.cloud_projects import cloud_project_service @@ -133,6 +134,38 @@ def list_local_bindings( return [LocalBindingResponse.model_validate(binding) for binding in bindings] +@router.patch( + "/{project_id}/local-bindings/{binding_id}", + response_model=LocalBindingResponse, +) +def update_local_binding( + project_id: int, + binding_id: int, + values: LocalBindingUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> LocalBindingResponse: + binding = cloud_project_service.update_local_binding( + db, project_id, binding_id, current_user.id, values + ) + return LocalBindingResponse.model_validate(binding) + + +@router.delete( + "/{project_id}/local-bindings/{binding_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_local_binding( + project_id: int, + binding_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + cloud_project_service.delete_local_binding( + db, project_id, binding_id, current_user.id + ) + + @router.get("/{project_id}/members", response_model=list[CloudProjectMemberResponse]) def list_cloud_project_members( project_id: int, diff --git a/backend/app/api/endpoints/deliveries.py b/backend/app/api/endpoints/deliveries.py index a2965aaa65..0fdd54ff4b 100644 --- a/backend/app/api/endpoints/deliveries.py +++ b/backend/app/api/endpoints/deliveries.py @@ -45,6 +45,9 @@ LoopItemUpdate, MyWorkItemResponse, MyWorkListResponse, + RuntimeTaskTrack, + RuntimeTaskTrackingStatusUpdate, + RuntimeTaskTrackResponse, ) from app.services.cloud_projects import cloud_project_service from app.services.delivery import delivery_service @@ -194,6 +197,109 @@ def bind_cloud_project_task( return LoopItemTaskBindingResponse.model_validate(binding) +def _tracked_item_response( + db: Session, + item_id: str, + current_user: User, +) -> LoopItemResponse: + if external_loop_item_provider.is_external_item(db, item_id): + return LoopItemResponse.model_validate( + external_loop_item_provider.get(db, item_id, current_user.id) + ) + item = loop_item_service.get(db, item_id, current_user.id) + return _loop_item_response(db, item, current_user) + + +@router.post( + "/cloud-projects/{project_id}/tasks/track", + response_model=RuntimeTaskTrackResponse, + status_code=status.HTTP_201_CREATED, +) +def track_cloud_project_task( + project_id: int, + values: RuntimeTaskTrack, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> RuntimeTaskTrackResponse: + existing = loop_item_service.find_active_task_binding( + db, current_user.id, values.device_id, values.task_id + ) + if existing is not None and existing.loop_item_id: + if str(existing.cloud_project_id) != str(project_id): + raise HTTPException( + status.HTTP_409_CONFLICT, + "Runtime task is already tracked by another project", + ) + return RuntimeTaskTrackResponse( + item=_tracked_item_response(db, existing.loop_item_id, current_user), + binding=LoopItemTaskBindingResponse.model_validate(existing), + ) + + project = cloud_project_service.get(db, project_id, current_user.id) + created = loop_item_provider_router.create( + db, + project, + current_user, + LoopItemCreate( + title=values.task_title, + description=values.description, + status="in_progress", + ), + ) + item_id = str(created.values["id"]) + external_loop_item_provider.ensure_shadow(db, item_id, current_user.id) + binding = loop_item_service.bind_task( + db, item_id, values.binding(), current_user.id + ) + return RuntimeTaskTrackResponse( + item=LoopItemResponse.model_validate(created.values), + binding=LoopItemTaskBindingResponse.model_validate(binding), + ) + + +@router.patch( + "/runtime-tasks/cloud-context/tracking-status", + response_model=LoopItemResponse | None, +) +def update_runtime_task_tracking_status( + values: RuntimeTaskTrackingStatusUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> LoopItemResponse | None: + binding = loop_item_service.find_active_task_binding( + db, current_user.id, values.device_id, values.task_id + ) + if binding is None or not binding.loop_item_id: + return None + item = _tracked_item_response(db, binding.loop_item_id, current_user) + next_status: str | None = None + if values.execution_status == "running" and item.status in { + "inbox", + "pending", + "in_review", + }: + next_status = "in_progress" + elif values.execution_status == "succeeded" and item.status == "in_progress": + next_status = "in_review" + if next_status is None: + return item + if external_loop_item_provider.is_external_item(db, item.id): + updated = external_loop_item_provider.update( + db, + item.id, + current_user.id, + LoopItemUpdate(version=item.version, status=next_status), + ) + return LoopItemResponse.model_validate(updated) + stored = loop_item_service.update( + db, + item.id, + current_user.id, + LoopItemUpdate(version=item.version, status=next_status), + ) + return _loop_item_response(db, stored, current_user) + + @router.delete("/runtime-tasks/cloud-context", status_code=status.HTTP_204_NO_CONTENT) def unbind_runtime_task_cloud_context( values: LoopItemTaskBind, diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py index 56711fbb7d..001b45ab00 100644 --- a/backend/app/schemas/cloud_project.py +++ b/backend/app/schemas/cloud_project.py @@ -228,6 +228,10 @@ class LocalBindingCreate(BaseModel): is_default: bool = False +class LocalBindingUpdate(BaseModel): + is_default: bool | None = None + + class LocalBindingResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/delivery.py b/backend/app/schemas/delivery.py index dd166eeb1b..2888ff82e7 100644 --- a/backend/app/schemas/delivery.py +++ b/backend/app/schemas/delivery.py @@ -234,6 +234,39 @@ def normalize_unlinked_at(cls, value: object) -> object: return LoopItemResponse.normalize_unset_datetime(value) +class RuntimeTaskTrack(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + device_id: str = Field(alias="deviceId", min_length=1, max_length=100) + task_id: str = Field(alias="taskId", min_length=1, max_length=255) + task_title: str = Field(alias="taskTitle", min_length=1, max_length=255) + description: str = "" + backend_task_id: int | None = Field(default=None, alias="backendTaskId") + + def binding(self) -> LoopItemTaskBind: + return LoopItemTaskBind( + deviceId=self.device_id, + taskId=self.task_id, + taskTitle=self.task_title, + backendTaskId=self.backend_task_id, + ) + + +class RuntimeTaskTrackingStatusUpdate(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + device_id: str = Field(alias="deviceId", min_length=1, max_length=100) + task_id: str = Field(alias="taskId", min_length=1, max_length=255) + execution_status: Literal["running", "succeeded", "failed", "cancelled"] = Field( + alias="executionStatus" + ) + + +class RuntimeTaskTrackResponse(BaseModel): + item: LoopItemResponse + binding: LoopItemTaskBindingResponse + + class CloudTaskContextResponse(LoopItemTaskBindingResponse): project: CloudProjectResponse loop_item: LoopItemResponse | None = None diff --git a/backend/app/services/cloud_projects/service.py b/backend/app/services/cloud_projects/service.py index de82068703..f304200cd1 100644 --- a/backend/app/services/cloud_projects/service.py +++ b/backend/app/services/cloud_projects/service.py @@ -27,6 +27,7 @@ CloudProjectMemberUpdate, CloudProjectUpdate, LocalBindingCreate, + LocalBindingUpdate, default_board_statuses, normalize_provider_config, ) @@ -291,14 +292,16 @@ def add_local_binding( raise HTTPException(status.HTTP_404_NOT_FOUND, "Local project not found") if values.is_default: db.query(CloudProjectLocalBinding).filter( - CloudProjectLocalBinding.cloud_project_id == cloud_project_id, CloudProjectLocalBinding.user_id == user_id, + CloudProjectLocalBinding.local_project_id == values.local_project_id, CloudProjectLocalBinding.device_id == values.device_id, ).update({"is_default": False}) binding = CloudProjectLocalBinding( cloud_project_id=cloud_project_id, user_id=user_id, - **values.model_dump(), + local_project_id=values.local_project_id, + device_id=values.device_id, + is_default=values.is_default, ) db.add(binding) try: @@ -311,6 +314,62 @@ def add_local_binding( db.refresh(binding) return binding + def update_local_binding( + self, + db: Session, + cloud_project_id: int, + binding_id: int, + user_id: int, + values: LocalBindingUpdate, + ) -> CloudProjectLocalBinding: + require_cloud_project_role(db, cloud_project_id, user_id, BaseRole.Developer) + binding = ( + db.query(CloudProjectLocalBinding) + .filter( + CloudProjectLocalBinding.id == binding_id, + CloudProjectLocalBinding.cloud_project_id == cloud_project_id, + CloudProjectLocalBinding.user_id == user_id, + ) + .with_for_update() + .first() + ) + if binding is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Local binding not found") + if values.is_default is True: + db.query(CloudProjectLocalBinding).filter( + CloudProjectLocalBinding.user_id == user_id, + CloudProjectLocalBinding.local_project_id == binding.local_project_id, + CloudProjectLocalBinding.device_id == binding.device_id, + CloudProjectLocalBinding.id != binding.id, + ).update({"is_default": False}) + if values.is_default is not None: + binding.is_default = values.is_default + db.commit() + db.refresh(binding) + return binding + + def delete_local_binding( + self, + db: Session, + cloud_project_id: int, + binding_id: int, + user_id: int, + ) -> None: + require_cloud_project_role(db, cloud_project_id, user_id, BaseRole.Developer) + binding = ( + db.query(CloudProjectLocalBinding) + .filter( + CloudProjectLocalBinding.id == binding_id, + CloudProjectLocalBinding.cloud_project_id == cloud_project_id, + CloudProjectLocalBinding.user_id == user_id, + ) + .first() + ) + if binding is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Local binding not found") + db.delete(binding) + db.commit() + def list_local_bindings( self, db: Session, cloud_project_id: int, user_id: int ) -> list[CloudProjectLocalBinding]: diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py index 31eff9a567..c91c1b3429 100644 --- a/backend/app/services/loop_items/service.py +++ b/backend/app/services/loop_items/service.py @@ -785,6 +785,24 @@ def find_cloud_context( item = db.get(LoopItem, binding.loop_item_id) if binding.loop_item_id else None return binding, project, item + def find_active_task_binding( + self, + db: Session, + user_id: int, + device_id: str, + task_id: str, + ) -> LoopItemTaskBinding | None: + return ( + db.query(LoopItemTaskBinding) + .filter( + LoopItemTaskBinding.task_user_id == user_id, + LoopItemTaskBinding.device_id == device_id, + LoopItemTaskBinding.task_id == task_id, + loop_datetime_is_unset(LoopItemTaskBinding.unlinked_at), + ) + .first() + ) + def unbind_cloud_context( self, db: Session, values: LoopItemTaskBind, user_id: int ) -> None: diff --git a/backend/tests/api/test_cloud_projects_api.py b/backend/tests/api/test_cloud_projects_api.py index 7fadd6006d..e66beca7a8 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -833,6 +833,90 @@ def test_cloud_project_can_link_local_workspace( assert bindings.status_code == 200 assert bindings.json()[0]["device_id"] == "desktop-1" + second_project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={"project_key": "second", "name": "Second collaboration"}, + ).json() + second_binding = test_client.post( + f"/api/v1/cloud-projects/{second_project['id']}/local-bindings", + headers=_auth(test_token), + json={ + "local_project_id": local_project.id, + "device_id": "desktop-1", + "is_default": True, + }, + ) + assert second_binding.status_code == 201 + first_bindings = test_client.get( + f"/api/v1/cloud-projects/{cloud_project['id']}/local-bindings", + headers=_auth(test_token), + ) + assert first_bindings.json()[0]["is_default"] is False + assert second_binding.json()["is_default"] is True + removed = test_client.delete( + ( + f"/api/v1/cloud-projects/{second_project['id']}/local-bindings/" + f"{second_binding.json()['id']}" + ), + headers=_auth(test_token), + ) + assert removed.status_code == 204 + assert ( + test_client.get( + f"/api/v1/cloud-projects/{second_project['id']}/local-bindings", + headers=_auth(test_token), + ).json() + == [] + ) + + +def test_explicit_project_selection_tracks_runtime_task_idempotently( + test_client: TestClient, + test_db: Session, + test_user: User, + test_token: str, +) -> None: + project = test_client.post( + "/api/v1/cloud-projects", + headers=_auth(test_token), + json={"project_key": "track", "name": "Tracked project"}, + ).json() + payload = { + "deviceId": "desktop-1", + "taskId": "runtime-task-1", + "taskTitle": "Implement explicit task tracking", + "description": "Created from the Wework task composer.", + } + tracked = test_client.post( + f"/api/v1/cloud-projects/{project['id']}/tasks/track", + headers=_auth(test_token), + json=payload, + ) + assert tracked.status_code == 201 + assert tracked.json()["item"]["status"] == "in_progress" + item_id = tracked.json()["item"]["id"] + + retried = test_client.post( + f"/api/v1/cloud-projects/{project['id']}/tasks/track", + headers=_auth(test_token), + json=payload, + ) + assert retried.status_code == 201 + assert retried.json()["item"]["id"] == item_id + + reviewed = test_client.patch( + "/api/v1/runtime-tasks/cloud-context/tracking-status", + headers=_auth(test_token), + json={ + "deviceId": "desktop-1", + "taskId": "runtime-task-1", + "executionStatus": "succeeded", + }, + ) + assert reviewed.status_code == 200 + assert reviewed.json()["status"] == "in_review" + def test_todo_lifecycle_and_multiple_local_tasks( test_client: TestClient, diff --git a/docs/en/wework/projects.md b/docs/en/wework/projects.md index 6557c40b7f..ea84327776 100644 --- a/docs/en/wework/projects.md +++ b/docs/en/wework/projects.md @@ -16,6 +16,14 @@ If a connected cloud device and the current local Wework executor refer to the s To create a Git project, select a device, repository, default branch, and destination. If repositories are unavailable, check the Git connection and token permissions in Settings. +## Link a project space + +After enabling **Settings → General → Experimental features**, open **Edit project** for a local project and configure **Automatically join project space**. A local project is the code and execution workspace, while a project space is the task-tracking and collaboration board. Linking them does not move or copy project files, and neither resource replaces the other. + +New conversations started in that local project inherit the selected project space. Before the first message is sent, the composer shows **Add to board · Project space name**. Sending creates a task in the selected local or cloud project space and links the conversation. Repeated synchronization of the same conversation does not create duplicate board tasks. + +Linking local projects from the project-space management page only controls where that space is available. Automatic joining remains a per-local-project setting. Use the composer's **+** menu to select, replace, or remove the project space for an individual conversation before sending. + ## Create a project from the composer Open the project selector above a new-conversation composer to create a blank project or add an existing folder. After creation, the project appears in both the sidebar and the composer and immediately becomes the workspace for the current new conversation. diff --git a/docs/en/wework/tasks.md b/docs/en/wework/tasks.md index 060071e615..8339e3ff6f 100644 --- a/docs/en/wework/tasks.md +++ b/docs/en/wework/tasks.md @@ -12,6 +12,12 @@ Add files, images, code locations, or an Appshot to the composer when they clari After you send a local image, Wework keeps its preview in the message. The image remains available when you reopen Wework or return to the conversation after switching away. If the original local file is deleted, the preview cannot be restored. +## Add a conversation to a project-space board + +After enabling Experimental features, open the composer's **+** menu and select **Project space**. The selected destination appears as **Add to board · Project space name** below the composer so you can confirm it before sending. Sending the first message creates the corresponding board task and links the conversation. A project space inherited from the local-project automatic-join setting appears through the same control. + +For an existing task, open the right-side **Environment** panel and select **Link project space**. You can link the current project or task to a local or cloud project space, or quickly create a task in that space. Local-space operations remain on the current device; cloud-space operations use shared cloud data. + ## Models and devices The model provides the AI capability; the device determines where files and commands run. Local models run on the local device. Cloud models and devices require a Wegent connection. diff --git a/docs/zh/wework/projects.md b/docs/zh/wework/projects.md index 5d475f1c29..a34b7026ab 100644 --- a/docs/zh/wework/projects.md +++ b/docs/zh/wework/projects.md @@ -16,6 +16,14 @@ sidebar_position: 3 从项目列表移除本机项目不会删除磁盘目录。 +## 关联项目空间 + +开启 **设置 → 通用 → 实验性功能** 后,可以在本地项目的 **编辑项目** 对话框中设置 **自动加入项目空间**。本地项目是代码与执行工作区,项目空间是任务跟进与协作看板;两者是关联关系,不会互相替代,也不会移动或复制项目文件。 + +为本地项目选择项目空间后,从该项目开启的新对话会继承这个默认值。发送第一条消息前,输入框底部会显示 **加入看板 · 项目空间名称**;发送后,Wework 会在对应的本地或云端项目空间中创建任务并关联当前对话。重复同步同一对话不会创建重复看板任务。 + +项目空间管理页中的本地项目关联只决定该空间可以在哪些本地项目中使用。是否自动加入仍由每个本地项目的设置决定;也可以在发送前通过输入框的 **+** 菜单为当前对话临时选择、切换或取消项目空间。 + ## 从输入区创建项目 在新对话输入框上方打开项目选择器,可以新建空白项目或添加现有目录。创建完成后,新项目会同时出现在左侧项目列表和输入框上方,并立即成为当前新对话的项目工作区。 diff --git a/docs/zh/wework/tasks.md b/docs/zh/wework/tasks.md index c040f9b612..03f9e5c88b 100644 --- a/docs/zh/wework/tasks.md +++ b/docs/zh/wework/tasks.md @@ -26,6 +26,12 @@ sidebar_position: 4 输入 `/` 可以选择可用 Skill。插件安装后,其 Skills、命令或工具会出现在对应任务能力中。 +## 将对话加入项目空间看板 + +开启实验性功能后,可以通过输入框的 **+** 菜单选择 **项目空间**。选中的空间会以 **加入看板 · 项目空间名称** 显示在输入框底部,让你在发送前确认本次对话将进入哪个看板。发送第一条消息后,Wework 会创建对应看板任务并关联当前对话;本地项目设置的自动加入空间也会通过同一控件明确显示。 + +对于已经启动的任务,可以在右侧 **环境** 面板中选择 **关联项目空间**,将当前项目或当前任务关联到本地、云端项目空间,或者在空间中快速新建任务。选择本地空间时操作保存在当前设备;选择云端空间时使用云端协作数据。 + ## 选择模型和设备 模型决定任务使用的 AI 能力,设备决定代码和命令在哪里执行。本地模型只能用于本机设备;云端模型和设备需要先连接 Wegent。 diff --git a/wework/e2e/desktop/task-flow.e2e.mjs b/wework/e2e/desktop/task-flow.e2e.mjs index ca6f8332d6..9b9b5e02e1 100644 --- a/wework/e2e/desktop/task-flow.e2e.mjs +++ b/wework/e2e/desktop/task-flow.e2e.mjs @@ -3461,6 +3461,85 @@ async function verifyWorkspaceDocumentTabs(control) { await captureVerificationScreenshot(control, 'workspace-tabs-02-task-restored.png') } +async function configureDefaultProjectSpaceAssociation(control, localProjectId) { + const taskTabTestId = await control.command( + 'getAttribute', + '[data-tab-kind="task"][aria-selected="true"]', + { value: 'data-testid' } + ) + assert.ok(taskTabTestId, 'The active task tab identity was unavailable before association setup') + + await control.command('click', '[data-testid^="workspace-tab-select-board-"]') + await control.command('waitFor', '[data-testid="cloud-todo-workspace"]', { + timeoutMs: WORKBENCH_READY_TIMEOUT_MS, + }) + const boardSnapshot = JSON.parse(await control.command('snapshot', 'body')) + const createSelector = boardSnapshot.testIds.includes('cloud-projects-home-create') + ? '[data-testid="cloud-projects-home-create"]' + : '[data-testid="cloud-project-add"]' + await control.command('click', createSelector) + await control.command('waitFor', '[data-testid="cloud-project-name"]', { + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('fill', '[data-testid="cloud-project-name"]', { + value: 'Task Follow-up Board', + }) + 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"]') + await control.command('waitFor', '[data-testid="cloud-project-manage-view"]', { + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await captureVerificationScreenshot(control, 'project-space-created-for-local-project.png') + await control.command('click', `[data-testid="${taskTabTestId}"]`) + await control.command('waitFor', ACTIVE_COMPOSER_SELECTOR, { + timeoutMs: WORKBENCH_READY_TIMEOUT_MS, + }) + await control.command('click', `[data-testid="project-menu-${localProjectId}"]`) + await control.command('click', `[data-testid="edit-project-${localProjectId}"]`) + await control.command('waitFor', '[data-testid="local-project-edit-dialog"]', { + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('select', '[data-testid="local-project-auto-join-space-select"]', { + by: 'label', + value: 'Task Follow-up Board', + }) + await control.command('clickWhenEnabled', '[data-testid="save-local-project-button"]') + await control.command('waitFor', '[data-testid="project-space-context-pill"]', { + text: '加入看板 · Task Follow-up Board', + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('click', '[data-testid="add-context-button"]') + await control.command('waitFor', '[data-testid="add-project-space-context-button"]', { + text: 'Task Follow-up Board', + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('click', '[data-testid="add-project-space-context-button"]') + await control.command('waitFor', '[data-testid^="add-context-cloud-project-space-"]', { + text: 'Task Follow-up Board', + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('press', 'body', { key: 'Escape' }) + return taskTabTestId +} + +async function verifyExplicitlyTrackedTask(control, taskTabTestId) { + await control.command('click', '[data-testid^="workspace-tab-select-board-"]') + await control.command('waitFor', '[data-testid="cloud-project-board-view"]', { + timeoutMs: WORKBENCH_READY_TIMEOUT_MS, + }) + await control.command('click', '[data-testid="cloud-project-board-view"]') + await control.command('waitFor', '[data-testid="cloud-todo-column-in_review"]', { + text: 'WEWORK_DESKTOP_E2E_TASK', + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await captureVerificationScreenshot(control, 'project-space-selected-task-in-review.png') + await control.command('click', `[data-testid="${taskTabTestId}"]`) + await control.command('waitFor', ACTIVE_COMPOSER_SELECTOR, { + timeoutMs: WORKBENCH_READY_TIMEOUT_MS, + }) +} + function workspaceTabIds(snapshot, kind) { return snapshot.testIds.filter(testId => testId.startsWith(`workspace-tab-${kind}-`)) } @@ -12419,6 +12498,12 @@ last_updated = "2026-07-30T00:00:00Z"` timeoutMs: DEFAULT_STEP_TIMEOUT_MS, }) + let associatedTaskTabTestId = null + if (shouldRunDesktopCheckpoint('core-task-flow')) { + phase = 'project-space-default-association-setup' + associatedTaskTabTestId = await configureDefaultProjectSpaceAssociation(control, projectId) + } + if (GUIDANCE_SCROLL_ONLY) { phase = 'guidance-scroll' await verifyForegroundGuidanceScroll({ composerSelector, control }) @@ -12624,6 +12709,10 @@ last_updated = "2026-07-30T00:00:00Z"` taskRowTestId, userText: TASK_PROMPT, }) + if (associatedTaskTabTestId) { + phase = 'project-space-selected-task-tracked' + await verifyExplicitlyTrackedTask(control, associatedTaskTabTestId) + } if (MESSAGE_RESTORATION_ONLY) { await verifyFollowUpMessageRestoration({ composerSelector, diff --git a/wework/src/api/deliveries.ts b/wework/src/api/deliveries.ts index 1d6eec5475..447511d8c3 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -1,6 +1,9 @@ import { invoke } from '@tauri-apps/api/core' import type { HttpClient } from './http' import type { RuntimeTaskAddress } from '@/types/api' + +export const PROJECT_SPACE_LOCAL_BINDINGS_CHANGED_EVENT = + 'wework:project-space-local-bindings-changed' import { openLocalFile } from '@/lib/local-terminal' import { isTauriRuntime } from '@/lib/runtime-environment' @@ -216,7 +219,31 @@ export interface CloudMyWorkItem extends CloudLoopItem { has_active_task: boolean } +export function createProjectTaskTrackingSingleFlight() { + const requests = new Map>() + + return ( + projectId: CloudProjectIdInput, + task: RuntimeTaskAddress, + create: () => Promise<{ item: CloudLoopItem }> + ): Promise<{ item: CloudLoopItem }> => { + const key = `${projectId}:${task.deviceId}:${task.taskId}` + const existing = requests.get(key) + if (existing) return existing + + const request = create() + requests.set(key, request) + const clear = () => { + if (requests.get(key) === request) requests.delete(key) + } + void request.then(clear, clear) + return request + } +} + export function createDeliveryApi(client: HttpClient) { + const trackProjectTaskOnce = createProjectTaskTrackingSingleFlight() + return { listCloudProjects(): Promise<{ items: CloudProject[] }> { return client.get('/v1/cloud-projects') @@ -419,6 +446,29 @@ export function createDeliveryApi(client: HttpClient) { ...(taskTitle ? { taskTitle } : {}), }) }, + trackProjectTask( + projectId: CloudProjectIdInput, + task: RuntimeTaskAddress, + taskTitle: string, + description: string + ): Promise<{ item: CloudLoopItem }> { + return trackProjectTaskOnce(projectId, task, () => + client.post(`/v1/cloud-projects/${projectId}/tasks/track`, { + ...task, + taskTitle, + description, + }) + ) + }, + updateTaskTrackingStatus( + task: RuntimeTaskAddress, + executionStatus: 'running' | 'succeeded' | 'failed' | 'cancelled' + ): Promise { + return client.patch('/v1/runtime-tasks/cloud-context/tracking-status', { + ...task, + executionStatus, + }) + }, unbindCloudContext(task: RuntimeTaskAddress): Promise { return client.delete('/v1/runtime-tasks/cloud-context', task) }, @@ -458,10 +508,26 @@ export function createDeliveryApi(client: HttpClient) { }, addLocalBinding( projectId: CloudProjectIdInput, - data: { local_project_id: number; device_id?: string; is_default?: boolean } + data: { + local_project_id: number + device_id?: string + is_default?: boolean + } ): Promise { return client.post(`/v1/cloud-projects/${projectId}/local-bindings`, data) }, + updateLocalBinding( + projectId: CloudProjectIdInput, + bindingId: string, + data: { + is_default?: boolean + } + ): Promise { + return client.patch(`/v1/cloud-projects/${projectId}/local-bindings/${bindingId}`, data) + }, + deleteLocalBinding(projectId: CloudProjectIdInput, bindingId: string): Promise { + return client.delete(`/v1/cloud-projects/${projectId}/local-bindings/${bindingId}`) + }, listCloudFiles(projectId: CloudProjectIdInput): Promise<{ items: CloudProjectFile[] }> { return client.get(`/v1/cloud-projects/${projectId}/files`) }, diff --git a/wework/src/api/local/localDelivery.test.ts b/wework/src/api/local/localDelivery.test.ts index a259a3f396..78f3f321bf 100644 --- a/wework/src/api/local/localDelivery.test.ts +++ b/wework/src/api/local/localDelivery.test.ts @@ -324,6 +324,168 @@ describe('local delivery API', () => { }) }) + test('stores project-space associations with one default per local project', async () => { + window.localStorage.clear() + const request = vi.fn() + const api = createLocalDeliveryApi(request) + + const first = await api.addLocalBinding('project-1', { + local_project_id: 91, + is_default: true, + }) + const second = await api.addLocalBinding('project-2', { + local_project_id: 91, + is_default: true, + }) + + await expect(api.listLocalBindings('project-1')).resolves.toEqual([ + expect.objectContaining({ id: first.id, is_default: false }), + ]) + await expect(api.listLocalBindings('project-2')).resolves.toEqual([ + expect.objectContaining({ id: second.id, is_default: true }), + ]) + + const updated = await api.updateLocalBinding('project-1', first.id, { + is_default: true, + }) + expect(updated.is_default).toBe(true) + await expect(api.listLocalBindings('project-2')).resolves.toEqual([ + expect.objectContaining({ id: second.id, is_default: false }), + ]) + await api.deleteLocalBinding('project-2', second.id) + await expect(api.listLocalBindings('project-2')).resolves.toEqual([]) + }) + + test('tracks concurrent calls for the same runtime task only once', async () => { + const trackedTask = { ...taskRecord, status: 'in_progress' } + const request = vi.fn(async (method: string) => { + if (method === 'runtime_tasks.context') throw new Error('Task context not found') + if (method === 'todos.create') return trackedTask + if (method === 'todos.bind') return { id: 'binding-1' } + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + const trackProjectTask = api.trackProjectTask + + const first = trackProjectTask( + 'project-1', + { deviceId: 'local-device', taskId: 'runtime-1' }, + 'Runtime task', + 'Track only this explicitly selected task' + ) + const second = trackProjectTask( + 'project-1', + { deviceId: 'local-device', taskId: 'runtime-1' }, + 'Runtime task changed during rendering', + 'Updated description' + ) + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ + item: expect.objectContaining({ id: 'LOCAL-1', status: 'in_progress' }), + }), + expect.objectContaining({ + item: expect.objectContaining({ id: 'LOCAL-1', status: 'in_progress' }), + }), + ]) + + expect(request).toHaveBeenCalledTimes(3) + expect(request).toHaveBeenCalledWith('runtime_tasks.context', { + device_id: 'local-device', + task_id: 'runtime-1', + }) + expect(request).toHaveBeenCalledWith('todos.create', { + project_id: 'project-1', + todo: { + title: 'Runtime task', + description: 'Track only this explicitly selected task', + status: 'in_progress', + priority: 'none', + parent_id: null, + tags: [], + }, + }) + expect(request).toHaveBeenCalledWith('todos.bind', { + project_id: 'project-1', + item_id: 'LOCAL-1', + task: { + deviceId: 'local-device', + taskId: 'runtime-1', + taskTitle: 'Runtime task', + }, + }) + }) + + test('allows retrying project tracking after a failed request', async () => { + const trackedTask = { ...taskRecord, status: 'in_progress' } + let createAttempts = 0 + const request = vi.fn(async (method: string) => { + if (method === 'runtime_tasks.context') throw new Error('Task context not found') + if (method === 'todos.create') { + createAttempts += 1 + if (createAttempts === 1) throw new Error('Temporary create failure') + return trackedTask + } + if (method === 'todos.bind') return { id: 'binding-1' } + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + + await expect( + api.trackProjectTask( + 'project-1', + { deviceId: 'local-device', taskId: 'runtime-1' }, + 'Runtime task', + 'Retry tracking' + ) + ).rejects.toThrow('Temporary create failure') + + await expect( + api.trackProjectTask( + 'project-1', + { deviceId: 'local-device', taskId: 'runtime-1' }, + 'Runtime task', + 'Retry tracking' + ) + ).resolves.toMatchObject({ item: { id: 'LOCAL-1' } }) + }) + + test('updates tracking status when the API method is called without object context', async () => { + const trackedTask = { ...taskRecord, status: 'in_progress' } + const reviewedTask = { ...trackedTask, status: 'in_review', version: 2 } + const request = vi.fn(async (method: string) => { + if (method === 'runtime_tasks.context') { + return { + id: 'binding-1', + cloud_project_id: 'project-1', + loop_item_id: 'LOCAL-1', + task_user_id: 0, + device_id: 'local-device', + task_id: 'runtime-1', + task_title: 'Runtime task', + backend_task_id: null, + linked_at: '2026-07-27T00:00:00Z', + } + } + if (method === 'projects.list') return [projectRecord] + if (method === 'todos.get') return trackedTask + if (method === 'todos.update') return reviewedTask + throw new Error(`Unexpected method: ${method}`) + }) + const api = createLocalDeliveryApi(request) + const updateTaskTrackingStatus = api.updateTaskTrackingStatus + + await expect( + updateTaskTrackingStatus({ deviceId: 'local-device', taskId: 'runtime-1' }, 'succeeded') + ).resolves.toMatchObject({ id: 'LOCAL-1', status: 'in_review' }) + + expect(request).toHaveBeenCalledWith('todos.update', { + project_id: 'project-1', + task_id: 'LOCAL-1', + todo: { version: 1, status: 'in_review' }, + }) + }) + test('routes local files, attachments, and deliveries through executor IPC', async () => { const delivery = { id: 'delivery-1', diff --git a/wework/src/api/local/localDelivery.ts b/wework/src/api/local/localDelivery.ts index 18a56e46c8..11c2f5c6a2 100644 --- a/wework/src/api/local/localDelivery.ts +++ b/wework/src/api/local/localDelivery.ts @@ -1,16 +1,18 @@ import { convertFileSrc } from '@tauri-apps/api/core' -import type { - CloudLoopItemAttachment, - CloudLoopItem, - CloudProject, - CloudProjectFile, - CloudProjectId, - CloudProjectMember, - Delivery, - DeliveryAsset, - DeliveryCreateInput, - DeliveryDetail, +import { + createProjectTaskTrackingSingleFlight, + type CloudLoopItemAttachment, + type CloudLoopItem, + type CloudProject, + type CloudProjectFile, + type CloudProjectId, + type CloudProjectLocalBinding, + type CloudProjectMember, + type Delivery, + type DeliveryAsset, + type DeliveryCreateInput, + type DeliveryDetail, } from '@/api/deliveries' import type { WorkbenchServices } from '@/features/workbench/workbenchServices' import { openLocalFile } from '@/lib/local-terminal' @@ -304,6 +306,21 @@ export function createLocalDeliveryApi( request: LocalRequest ): NonNullable { const taskProjects = new Map() + const trackProjectTaskOnce = createProjectTaskTrackingSingleFlight() + const bindingStorageKey = 'wework.local-project-space-bindings' + + const readLocalBindings = (): CloudProjectLocalBinding[] => { + try { + const parsed = JSON.parse(window.localStorage.getItem(bindingStorageKey) ?? '[]') + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } + } + + const writeLocalBindings = (bindings: CloudProjectLocalBinding[]) => { + window.localStorage.setItem(bindingStorageKey, JSON.stringify(bindings)) + } function rememberTasks(projectId: CloudProjectId, records: LocalLoopItemRecord[]) { for (const record of records) taskProjects.set(record.id, projectId) @@ -525,6 +542,59 @@ export function createLocalDeliveryApi( task: { ...task, ...(taskTitle ? { taskTitle } : {}) }, }) }, + async trackProjectTask( + projectId: CloudProjectId, + task: RuntimeTaskAddress, + taskTitle: string, + description: string + ) { + return trackProjectTaskOnce(projectId, task, async () => { + try { + const existing = await request('runtime_tasks.context', { + device_id: task.deviceId, + task_id: task.taskId, + }) + if (existing.loop_item_id) { + return { item: await api.getLoopItem(existing.loop_item_id) } + } + } catch { + // Missing context is the expected first-run path. + } + const item = await api.createLoopItem(projectId, { + title: taskTitle, + description, + status: 'in_progress', + }) + await api.bindTask(item.id, task, taskTitle) + return { item } + }) + }, + async updateTaskTrackingStatus( + task: RuntimeTaskAddress, + executionStatus: 'running' | 'succeeded' | 'failed' | 'cancelled' + ) { + let binding: LocalTaskBindingRecord + try { + binding = await request('runtime_tasks.context', { + device_id: task.deviceId, + task_id: task.taskId, + }) + } catch { + return null + } + if (!binding.loop_item_id) return null + const item = await api.getLoopItem(binding.loop_item_id) + const nextStatus = + executionStatus === 'running' && + (item.status === 'inbox' || item.status === 'pending' || item.status === 'in_review') + ? 'in_progress' + : executionStatus === 'succeeded' && item.status === 'in_progress' + ? 'in_review' + : null + return nextStatus + ? api.updateLoopItem(item.id, { version: item.version, status: nextStatus }) + : item + }, async unbindCloudContext(task: RuntimeTaskAddress) { await request('runtime_tasks.unbind', { device_id: task.deviceId, @@ -537,6 +607,40 @@ export function createLocalDeliveryApi( task_id: task.taskId, }) }, + async updateLocalBinding( + projectId: CloudProjectId, + bindingId: string, + data: { is_default?: boolean } + ) { + const bindings = readLocalBindings() + const binding = bindings.find( + candidate => candidate.id === bindingId && candidate.cloud_project_id === projectId + ) + if (!binding) throw new Error('Local binding not found') + if (data.is_default) { + for (const candidate of bindings) { + if ( + candidate.id !== binding.id && + candidate.local_project_id === binding.local_project_id && + candidate.device_id === binding.device_id + ) { + candidate.is_default = false + } + } + } + if (data.is_default !== undefined) binding.is_default = data.is_default + binding.updated_at = new Date().toISOString() + writeLocalBindings(bindings) + return binding + }, + async deleteLocalBinding(projectId: CloudProjectId, bindingId: string) { + const bindings = readLocalBindings() + const nextBindings = bindings.filter( + candidate => candidate.id !== bindingId || candidate.cloud_project_id !== projectId + ) + if (nextBindings.length === bindings.length) throw new Error('Local binding not found') + writeLocalBindings(nextBindings) + }, async findLoopItemForTask(task: RuntimeTaskAddress) { const binding = await request('runtime_tasks.context', { device_id: task.deviceId, @@ -562,13 +666,58 @@ export function createLocalDeliveryApi( loop_item: loopItem, } }, - listLocalBindings: async () => [], + async listLocalBindings(projectId: CloudProjectId) { + return readLocalBindings().filter(binding => binding.cloud_project_id === projectId) + }, listCloudProjectMembers: async (): Promise => [], addCloudProjectMember: async () => unsupported('Project members'), updateCloudProjectMember: async () => unsupported('Project members'), removeCloudProjectMember: async () => unsupported('Project members'), searchCloudProjectUsers: async () => ({ users: [], total: 0 }), - addLocalBinding: async () => unsupported('Local bindings'), + async addLocalBinding( + projectId: CloudProjectId, + data: { + local_project_id: number + device_id?: string + is_default?: boolean + } + ) { + const bindings = readLocalBindings() + if ( + bindings.some( + binding => + binding.cloud_project_id === projectId && + binding.local_project_id === data.local_project_id && + binding.device_id === (data.device_id ?? null) + ) + ) { + throw new Error('Local project is already linked') + } + if (data.is_default) { + for (const candidate of bindings) { + if ( + candidate.local_project_id === data.local_project_id && + candidate.device_id === (data.device_id ?? null) + ) { + candidate.is_default = false + } + } + } + const now = new Date().toISOString() + const binding: CloudProjectLocalBinding = { + id: crypto.randomUUID(), + cloud_project_id: projectId, + local_project_id: data.local_project_id, + user_id: 0, + device_id: data.device_id ?? null, + is_default: data.is_default ?? false, + created_at: now, + updated_at: now, + } + bindings.push(binding) + writeLocalBindings(bindings) + return binding + }, async listCloudFiles(projectId: CloudProjectId) { const records = await request('files.list', { project_id: projectId, diff --git a/wework/src/components/chat/ChatInput.tsx b/wework/src/components/chat/ChatInput.tsx index 6f7f4893ad..e3eb17936b 100644 --- a/wework/src/components/chat/ChatInput.tsx +++ b/wework/src/components/chat/ChatInput.tsx @@ -135,6 +135,7 @@ export interface ChatInputProps { cloudProjectCandidates?: ComposerCloudMentionCandidate[] cloudSpaceEnabled?: boolean onSelectCloudProject?: (project: CloudProject) => void + selectedCloudProjectId?: CloudProject['id'] isStreaming?: boolean onPause?: () => void showWorkspaceMenu?: boolean @@ -257,6 +258,7 @@ export function ChatInput({ cloudProjectCandidates, cloudSpaceEnabled, onSelectCloudProject, + selectedCloudProjectId, isStreaming = false, onPause, showWorkspaceMenu, @@ -410,6 +412,7 @@ export function ChatInput({ cloudProjectCandidates, cloudSpaceEnabled, onSelectCloudProject, + selectedCloudProjectId, } const errorBanner = error ? (
{ test('renders its menu in a body portal so composer containers cannot clip it', () => { render() @@ -86,4 +119,30 @@ describe('AddContextMenu', () => { expect(screen.queryByTestId('add-context-menu')).not.toBeInTheDocument() expect(trigger).toHaveFocus() }) + + test('selects a linked project space from the add-context menu', () => { + const onSelectCloudProject = vi.fn() + render( + + ) + + fireEvent.click(screen.getByTestId('add-context-button')) + expect(screen.getByTestId('add-project-space-context-button')).toHaveTextContent(project.name) + fireEvent.click(screen.getByTestId('add-project-space-context-button')) + + const option = screen.getByTestId('add-context-cloud-project-space-space-1') + expect(option).toHaveAttribute('aria-checked', 'true') + expect(option).toHaveTextContent('自动加入') + fireEvent.click(option) + + expect(onSelectCloudProject).toHaveBeenCalledOnce() + expect(onSelectCloudProject).toHaveBeenCalledWith(project) + expect(screen.queryByTestId('add-context-menu')).not.toBeInTheDocument() + }) }) diff --git a/wework/src/components/chat/composer/AddContextMenu.tsx b/wework/src/components/chat/composer/AddContextMenu.tsx index e13c1c2ac3..3703b3df7f 100644 --- a/wework/src/components/chat/composer/AddContextMenu.tsx +++ b/wework/src/components/chat/composer/AddContextMenu.tsx @@ -1,8 +1,19 @@ -import { ClipboardList, Eye, Paperclip, Plus, Target } from 'lucide-react' +import { + ArrowLeft, + Check, + ClipboardList, + Eye, + LayoutDashboard, + Paperclip, + Plus, + Target, +} from 'lucide-react' import type { ChangeEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import type { CloudProject } from '@/api/deliveries' import { useTranslation } from '@/hooks/useTranslation' +import type { ComposerCloudMentionCandidate } from './composerMentionCandidates' import { useAnchoredPortalMenu } from './useAnchoredPortalMenu' import { useOutsideClick } from './useOutsideClick' @@ -14,6 +25,9 @@ interface AddContextMenuProps { onConfigureSupervisor?: () => void supervisorEnabled?: boolean supervisorPending?: boolean + cloudProjectCandidates?: ComposerCloudMentionCandidate[] + selectedCloudProjectId?: CloudProject['id'] + onSelectCloudProject?: (project: CloudProject) => void } export function AddContextMenu({ @@ -24,6 +38,9 @@ export function AddContextMenu({ onConfigureSupervisor, supervisorEnabled = false, supervisorPending = false, + cloudProjectCandidates = [], + selectedCloudProjectId, + onSelectCloudProject, }: AddContextMenuProps) { const { t } = useTranslation('common') const containerRef = useRef(null) @@ -31,7 +48,11 @@ export function AddContextMenu({ const menuRef = useRef(null) const triggerRef = useRef(null) const [open, setOpen] = useState(false) - const closeMenu = useCallback(() => setOpen(false), []) + const [view, setView] = useState<'root' | 'project-spaces'>('root') + const closeMenu = useCallback(() => { + setOpen(false) + setView('root') + }, []) const outsideRefs = useMemo(() => [menuRef], []) const menuLayout = useAnchoredPortalMenu(open, triggerRef, menuRef) @@ -86,6 +107,11 @@ export function AddContextMenu({ onConfigureSupervisor?.() }, [onConfigureSupervisor]) + const selectableProjectSpaces = cloudProjectCandidates.filter( + (candidate): candidate is ComposerCloudMentionCandidate & { project: CloudProject } => + candidate.enabled && Boolean(candidate.project) + ) + return (
- - {onSetPlanMode && ( - - )} - {onSetGoal && ( - - )} - {onConfigureSupervisor && ( - + {view === 'project-spaces' ? ( + <> + +
+ {selectableProjectSpaces.map(candidate => { + const selected = candidate.project.id === selectedCloudProjectId + return ( + + ) + })} + + ) : ( + <> + + {selectableProjectSpaces.length > 0 && onSelectCloudProject && ( + + )} + {onSetPlanMode && ( + + )} + {onSetGoal && ( + + )} + {onConfigureSupervisor && ( + + )} + )}
, document.body diff --git a/wework/src/components/chat/composer/ComposerToolbar.tsx b/wework/src/components/chat/composer/ComposerToolbar.tsx index 1039edd6aa..ef41f360d9 100644 --- a/wework/src/components/chat/composer/ComposerToolbar.tsx +++ b/wework/src/components/chat/composer/ComposerToolbar.tsx @@ -1,10 +1,12 @@ import { ArrowUp, ChevronDown, ClipboardList, Clock3, CornerDownRight, Zap } from 'lucide-react' import { useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react' +import type { CloudProject } from '@/api/deliveries' import { ActionMenu } from '@/components/common/ActionMenu' import type { ComposerSubmitOptions } from './ComposerTextarea' import { useTranslation } from '@/hooks/useTranslation' import type { ModelOptions, RuntimeContextUsage, UnifiedModel } from '@/types/api' import { AddContextMenu } from './AddContextMenu' +import type { ComposerCloudMentionCandidate } from './composerMentionCandidates' import { ComposerModePill, GoalDraftPill } from './GoalDraftPill' import { ContextUsageIndicator } from './ContextUsageIndicator' import { ModelSelector } from './ModelSelector' @@ -45,6 +47,9 @@ interface ComposerToolbarProps { onQuickPhraseSelect: (phrase: QuickPhrase) => void onSubmit: (options?: ComposerSubmitOptions) => void leadingContext?: ReactNode + cloudProjectCandidates?: ComposerCloudMentionCandidate[] + selectedCloudProjectId?: CloudProject['id'] + onSelectCloudProject?: (project: CloudProject) => void } const COMPACT_TOOLBAR_WIDTH = 475 @@ -83,6 +88,9 @@ export function ComposerToolbar({ onQuickPhraseSelect, onSubmit, leadingContext, + cloudProjectCandidates, + selectedCloudProjectId, + onSelectCloudProject, }: ComposerToolbarProps) { const { t } = useTranslation('common') const toolbarRef = useRef(null) @@ -126,6 +134,9 @@ export function ComposerToolbar({ onConfigureSupervisor={onConfigureSupervisor} supervisorEnabled={supervisorEnabled} supervisorPending={supervisorPending} + cloudProjectCandidates={cloudProjectCandidates} + selectedCloudProjectId={selectedCloudProjectId} + onSelectCloudProject={onSelectCloudProject} /> {leadingContext} diff --git a/wework/src/components/chat/composer/ProjectChatComposer.tsx b/wework/src/components/chat/composer/ProjectChatComposer.tsx index 04dfd292e3..55d3311bbb 100644 --- a/wework/src/components/chat/composer/ProjectChatComposer.tsx +++ b/wework/src/components/chat/composer/ProjectChatComposer.tsx @@ -64,6 +64,7 @@ interface ProjectChatComposerProps { cloudProjectCandidates?: ComposerCloudMentionCandidate[] cloudSpaceEnabled?: boolean onSelectCloudProject?: (project: CloudProject) => void + selectedCloudProjectId?: CloudProject['id'] planModeActive?: boolean onSetPlanMode?: () => void onClearPlanMode?: () => void @@ -123,6 +124,7 @@ export function ProjectChatComposer({ cloudProjectCandidates, cloudSpaceEnabled, onSelectCloudProject, + selectedCloudProjectId, planModeActive = false, onSetPlanMode, onClearPlanMode, @@ -376,6 +378,9 @@ export function ProjectChatComposer({ onQuickPhraseSelect={handleQuickPhraseSelect} onSubmit={options => onSubmit(value, options)} leadingContext={toolbarLeadingContext} + cloudProjectCandidates={cloudProjectCandidates} + selectedCloudProjectId={selectedCloudProjectId} + onSelectCloudProject={onSelectCloudProject} />
diff --git a/wework/src/components/layout/DesktopSidebar.test.tsx b/wework/src/components/layout/DesktopSidebar.test.tsx index 9a4263217c..26afd696fe 100644 --- a/wework/src/components/layout/DesktopSidebar.test.tsx +++ b/wework/src/components/layout/DesktopSidebar.test.tsx @@ -20,6 +20,7 @@ import { RuntimeTaskLifecycleProvider, RuntimeTaskLifecycleStore, } from '@/features/workbench/runtimeTaskLifecycle' +import type { ProjectSpaceBindingApi } from '@/features/todo/projectSpaceLocalBindings' const experimentalFeatures = vi.hoisted(() => ({ enabled: true })) @@ -3344,6 +3345,57 @@ describe('DesktopSidebar', () => { }) }) + test('hides automatic project-space joining when experimental features are disabled', async () => { + experimentalFeatures.enabled = false + const user = userEvent.setup() + const projectSpaceApi = { + listCloudProjects: vi.fn().mockResolvedValue({ items: [] }), + listLocalBindings: vi.fn().mockResolvedValue([]), + } as unknown as ProjectSpaceBindingApi + + renderSidebar({ + projects: [], + runtimeWork: { + projects: [ + { + project: { + id: 7, + key: 'project:7', + name: 'Wegent', + source: 'local_project', + stateDeviceId: 'local-device', + roots: [{ kind: 'local', path: '/Users/alice/dev/Wegent' }], + }, + totalTasks: 0, + deviceWorkspaces: [ + { + id: 91, + deviceId: 'local-device', + deviceName: 'Local Mac', + deviceStatus: 'online', + available: true, + workspacePath: '/Users/alice/dev/Wegent', + workspaceKind: 'workspace', + workspaceSource: 'local', + tasks: [], + }, + ], + }, + ], + chats: [], + totalTasks: 0, + }, + projectSpaceBindingApis: [projectSpaceApi], + onUpdateLocalRuntimeProject: vi.fn().mockResolvedValue(undefined), + }) + + await user.click(screen.getByTestId('project-menu-7')) + await user.click(screen.getByTestId('edit-project-7')) + + expect(screen.queryByTestId('local-project-auto-join-space-select')).not.toBeInTheDocument() + expect(projectSpaceApi.listCloudProjects).not.toHaveBeenCalled() + }) + test('creates a permanent worktree from a runtime project', async () => { const user = userEvent.setup() const onCreatePermanentWorktree = vi.fn().mockResolvedValue(undefined) diff --git a/wework/src/components/layout/DesktopSidebar.tsx b/wework/src/components/layout/DesktopSidebar.tsx index 467f1b820a..f708f0a427 100644 --- a/wework/src/components/layout/DesktopSidebar.tsx +++ b/wework/src/components/layout/DesktopSidebar.tsx @@ -158,6 +158,7 @@ import { } from '@/features/workbench/runtimeSidebarDiagnostics' import { formatRelativeSidebarTime, useSidebarRelativeTimeRefresh } from './runtimeSidebarTime' import { useResizableSidebar } from './useResizableSidebar' +import type { ProjectSpaceBindingApi } from '@/features/todo/projectSpaceLocalBindings' interface DesktopSidebarProps { user: UserProfile | null @@ -250,6 +251,7 @@ interface DesktopSidebarProps { onGetDeviceHomeDirectory: (deviceId: string) => Promise onListDeviceDirectories: (deviceId: string, path: string) => Promise onCreateDeviceDirectory: (deviceId: string, path: string) => Promise + projectSpaceBindingApis?: ProjectSpaceBindingApi[] onOpenSettings: (options?: OpenSettingsOptions) => void onLogout: () => void } @@ -2583,6 +2585,7 @@ export function DesktopSidebar({ onGetDeviceHomeDirectory, onListDeviceDirectories, onCreateDeviceDirectory, + projectSpaceBindingApis, onOpenSettings, onLogout, collapsed = false, @@ -4132,6 +4135,7 @@ export function DesktopSidebar({ onGetDeviceHomeDirectory={onGetDeviceHomeDirectory} onListDeviceDirectories={onListDeviceDirectories} onCreateDeviceDirectory={onCreateDeviceDirectory} + projectSpaceApis={experimentalFeaturesEnabled ? projectSpaceBindingApis : undefined} onClose={() => setEditingLocalProject(null)} onSave={data => onUpdateLocalRuntimeProject diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index ef4f1d0ad5..a0b62ea705 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -35,6 +35,7 @@ import { useWorkbenchShellEventHandlers } from './workbenchShellEvents' import { EMPTY_RUNTIME_TASK_REMINDERS } from '@/features/workbench/runtimeTaskReminders' import { CloudTodoWorkspace } from '@/features/todo/CloudTodoWorkspace' import { resolveLocalTodoProjects } from '@/features/todo/localTodoProjects' +import { projectSpaceBindingApis } from '@/features/todo/projectSpaceLocalBindings' import { WorkbenchBackground } from '@/features/appearance' import { isTauriRuntime } from '@/lib/runtime-environment' import { useResizableSidebar } from './useResizableSidebar' @@ -118,6 +119,10 @@ export function DesktopWorkbenchLayout({ routeActive = true }: DesktopWorkbenchL () => resolveLocalTodoProjects(state.projects, state.runtimeWork), [state.projects, state.runtimeWork] ) + const availableProjectSpaceBindingApis = useMemo( + () => projectSpaceBindingApis(services), + [services] + ) const workspaceTabs = useOptionalWorkspaceTabs() const initialPath = stripAppBasePath(window.location.pathname) const [currentPath, setCurrentPath] = useState(initialPath) @@ -608,6 +613,7 @@ export function DesktopWorkbenchLayout({ routeActive = true }: DesktopWorkbenchL onGetDeviceHomeDirectory={onGetDeviceHomeDirectory} onListDeviceDirectories={onListDeviceDirectories} onCreateDeviceDirectory={onCreateDeviceDirectory} + projectSpaceBindingApis={availableProjectSpaceBindingApis} onOpenSettings={options => { setAutoOpenAddCloudDeviceDialog(Boolean(options?.autoOpenAddCloudDeviceDialog)) setSettingsOpen(true) diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index 39d64109e7..228a887dec 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -1,6 +1,13 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { ArrowLeftRight, ChevronRight, MessageCircle, MessageSquareWarning } from 'lucide-react' +import { + ArrowLeftRight, + ChevronRight, + LayoutDashboard, + MessageCircle, + MessageSquareWarning, +} from 'lucide-react' import type { ProjectChatControls } from '@/components/chat/ChatInput' +import { ComposerModePill } from '@/components/chat/composer/GoalDraftPill' import type { ComposerCloudMentionCandidate } from '@/components/chat/composer/composerMentionCandidates' import type { AssistantPlanOpenRequest } from '@/components/chat/AssistantPlanCard' import { RequestUserInputCard } from '@/components/chat/RequestUserInputCard' @@ -10,15 +17,26 @@ import { useAppPreferencesState } from '@/features/app-preferences/useAppPrefere import { useWorkbench, useWorkbenchPaneContext } from '@/features/workbench/useWorkbench' import { getPopoutComposerPlaceholder } from '@/features/workbench/popoutWorkspaceContext' import { DeliveryDialog } from '@/features/delivery/DeliveryDialog' -import type { CloudLoopItem, CloudProject } from '@/api/deliveries' +import { + PROJECT_SPACE_LOCAL_BINDINGS_CHANGED_EVENT, + type CloudLoopItem, + type CloudProject, + type CloudProjectLocalBinding, +} from '@/api/deliveries' import { TodoBindingPicker } from '@/features/todo/TodoBindingPicker' +import { + cacheAutoJoinProjectSpace, + findProjectSpaceContextForTask, + projectSpaceBindingApis, + readCachedAutoJoinProjectSpace, +} from '@/features/todo/projectSpaceLocalBindings' import { hydrateLocalWorkItems, loadLocalWorkItems, saveLocalWorkItems, type LocalWorkItem, } from '@/features/todo/todoModel' -import type { WorkspaceSessionApi } from '@/features/workbench/workbenchServices' +import type { WorkbenchServices, WorkspaceSessionApi } from '@/features/workbench/workbenchServices' import { useTranslation } from '@/hooks/useTranslation' import { findWorkbenchDevice, @@ -202,10 +220,47 @@ function cloudItemAsLocalWorkItem( } } +function cloudProjectAdditionalContext( + project: CloudProject | null, + item: CloudLoopItem | null +): RuntimeAdditionalContext | undefined { + if (!project) return undefined + const projectReference = `cloud://projects/${project.id}` + const todoReference = item ? `${projectReference}/todos/${item.id}` : null + const scope = item + ? [ + `Current cloud project: ${project.name} (id=${project.id}).`, + `Current task: ${item.id} — ${item.title}.`, + item.description ? `Task description: ${item.description}` : null, + `Current task reference: ${todoReference}.`, + ] + : [ + `Current cloud project: ${project.name} (id=${project.id}).`, + 'No specific task is selected.', + `Current project reference: ${projectReference}.`, + ] + return { + cloudCollaboration: { + kind: 'application', + value: [ + ...scope.filter((line): line is string => Boolean(line)), + 'When the user refers to “this project” or “this task”, use this current cloud context.', + 'Use the wegent_delivery MCP tools to inspect task details, shared files, and deliveries when needed. Do not ask for an id that is already provided here.', + ].join('\n'), + }, + } +} + interface PendingTodoBinding { project: CloudProject item: CloudLoopItem | null target: RuntimeTaskAddress | null + description: string +} + +interface PendingAutoJoinResolution { + target: RuntimeTaskAddress | null + description: string } let pendingTodoBinding: PendingTodoBinding | null = null @@ -532,6 +587,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const { t: tChat } = useTranslation('chat') const currentRuntimeTask = pane.currentRuntimeTask const currentProject = pane.currentProject + const localProjectId = currentProject?.id + const localProjectDeviceId = + currentProject?.config?.execution?.deviceId ?? currentProject?.config?.device_id const paneKey = getWorkbenchPaneKey(pane) const [turnNavigationPortalTarget, setTurnNavigationPortalTarget] = useState(null) @@ -545,6 +603,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ }, []) const paneSession = useWorkbenchPaneSession({ currentRuntimeTask }) const sendPaneInput = paneSession.send + const todoBindingApis = useMemo(() => projectSpaceBindingApis(services), [services]) + const pendingAutoJoinResolutionRef = useRef(null) const [deliveryItem, setDeliveryItem] = useState | null>(null) const [boundCloudProject, setBoundCloudProject] = useState(null) const [boundCloudItem, setBoundCloudItem] = useState(null) @@ -562,11 +622,22 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const [pendingTodoItem, setPendingTodoItemState] = useState(() => pendingTodoForTask(currentRuntimeTask) ) - const [pendingCloudProject, setPendingCloudProject] = useState(() => - pendingProjectForTask(currentRuntimeTask) + const [pendingCloudProject, setPendingCloudProject] = useState( + () => + pendingProjectForTask(currentRuntimeTask) ?? + (!currentRuntimeTask && localProjectId + ? readCachedAutoJoinProjectSpace(localProjectId, localProjectDeviceId) + : null) ) const [todoBindingError, setTodoBindingError] = useState(null) const [cloudProjects, setCloudProjects] = useState([]) + const [defaultCloudProjectId, setDefaultCloudProjectId] = useState( + null + ) + const [dismissedDefaultCloudProjectKey, setDismissedDefaultCloudProjectKey] = useState< + string | null + >(null) + const [localBindingRevision, setLocalBindingRevision] = useState(0) const [cloudActionNotice, setCloudActionNotice] = useState(null) const [cloudMentionState, setCloudMentionState] = useState<{ todoId: string @@ -588,38 +659,14 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ ) const composerCloudProject = currentRuntimeTask ? boundCloudProject : pendingCloudProject const composerTodoItem = currentRuntimeTask ? boundCloudItem : pendingTodoItem - const cloudAdditionalContext = useMemo(() => { - if (!composerCloudProject) return undefined - const projectReference = `cloud://projects/${composerCloudProject.id}` - const todoReference = composerTodoItem - ? `${projectReference}/todos/${composerTodoItem.id}` - : null - const scope = composerTodoItem - ? [ - `Current cloud project: ${composerCloudProject.name} (id=${composerCloudProject.id}).`, - `Current task: ${composerTodoItem.id} — ${composerTodoItem.title}.`, - composerTodoItem.description ? `Task description: ${composerTodoItem.description}` : null, - `Current task reference: ${todoReference}.`, - ] - : [ - `Current cloud project: ${composerCloudProject.name} (id=${composerCloudProject.id}).`, - 'No specific task is selected.', - `Current project reference: ${projectReference}.`, - ] - return { - cloudCollaboration: { - kind: 'application', - value: [ - ...scope.filter((line): line is string => Boolean(line)), - 'When the user refers to “this project” or “this task”, use this current cloud context.', - 'Use the wegent_delivery MCP tools to inspect task details, shared files, and deliveries when needed. Do not ask for an id that is already provided here.', - ].join('\n'), - }, - } - }, [composerCloudProject, composerTodoItem]) + const defaultCloudProjectSelectionKey = `${paneKey}:${currentProject?.id ?? 'none'}` + const cloudAdditionalContext = useMemo( + () => cloudProjectAdditionalContext(composerCloudProject, composerTodoItem), + [composerCloudProject, composerTodoItem] + ) const setPendingCloudContext = useCallback( (project: CloudProject | null, item: CloudLoopItem | null) => { - pendingTodoBinding = project ? { project, item, target: null } : null + pendingTodoBinding = project ? { project, item, target: null, description: '' } : null setPendingCloudProject(project) setPendingTodoItemState(item) }, @@ -648,14 +695,46 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const submitPaneInput = useCallback( (value?: string, options?: { guideWhenBusy?: boolean; interruptWhenBusy?: boolean }) => { const supervisorConfig = currentRuntimeTask ? null : pendingSupervisorConfig + const description = value ?? paneSession.input + const cachedProject = + !currentRuntimeTask && + localProjectId && + dismissedDefaultCloudProjectKey !== defaultCloudProjectSelectionKey + ? readCachedAutoJoinProjectSpace(localProjectId, localProjectDeviceId) + : null + const submissionProject = currentRuntimeTask ? null : (pendingCloudProject ?? cachedProject) + const submissionItem = + submissionProject?.id === pendingCloudProject?.id ? pendingTodoItem : null + if (!currentRuntimeTask) { + setPendingCloudContext(submissionProject, submissionItem) + pendingAutoJoinResolutionRef.current = + !submissionProject && + Boolean(localProjectId) && + dismissedDefaultCloudProjectKey !== defaultCloudProjectSelectionKey && + projectSpaceBindingApis(services).length > 0 + ? { target: null, description } + : null + } + if (pendingTodoBinding) { + pendingTodoBinding = { ...pendingTodoBinding, description } + } return sendPaneInput(value, { ...options, - additionalContext: cloudAdditionalContext, + additionalContext: + cloudProjectAdditionalContext(submissionProject, submissionItem) ?? + cloudAdditionalContext, + cloudProjectId: submissionProject?.id, initialSupervisor: supervisorConfig, onRuntimeTaskCreated: address => { if (pendingTodoBinding) { pendingTodoBinding = { ...pendingTodoBinding, target: address } } + if (pendingAutoJoinResolutionRef.current) { + pendingAutoJoinResolutionRef.current = { + ...pendingAutoJoinResolutionRef.current, + target: address, + } + } }, onRuntimeTaskReady: () => { if (supervisorConfig) { @@ -664,7 +743,21 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ }, }) }, - [cloudAdditionalContext, currentRuntimeTask, pendingSupervisorConfig, sendPaneInput] + [ + cloudAdditionalContext, + currentRuntimeTask, + defaultCloudProjectSelectionKey, + dismissedDefaultCloudProjectKey, + localProjectDeviceId, + localProjectId, + paneSession.input, + pendingCloudProject, + pendingTodoItem, + pendingSupervisorConfig, + sendPaneInput, + services, + setPendingCloudContext, + ] ) const setTaskSupervisor = useCallback( @@ -721,6 +814,14 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ [currentRuntimeTask, runtimeWorkApi, submitPaneInput, t] ) + const projectSpaceApiFor = useCallback( + (project: CloudProject): NonNullable | undefined => + project.project_store === 'local' || project.task_provider === 'dingtalk_aitable' + ? (services?.projectSpaceApis?.local ?? services?.deliveryApi) + : (services?.projectSpaceApis?.cloud ?? services?.deliveryApi), + [services?.deliveryApi, services?.projectSpaceApis?.cloud, services?.projectSpaceApis?.local] + ) + useEffect(() => { let active = true if (!currentRuntimeTask) { @@ -734,9 +835,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ active = false } } - if (services?.deliveryApi) { - void services.deliveryApi - .findCloudContextForTask(currentRuntimeTask) + const contextApis = projectSpaceBindingApis(services) + if (contextApis.length > 0) { + void findProjectSpaceContextForTask(contextApis, currentRuntimeTask) .then(context => { if (!active) return setBoundCloudProject(context.project) @@ -774,26 +875,42 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ return () => { active = false } - }, [currentRuntimeTask, services?.deliveryApi, state.user?.id]) + }, [currentRuntimeTask, services, state.user?.id]) useEffect(() => { - if (!currentRuntimeTask || !pendingCloudProject || !services?.deliveryApi) return + const projectToBind = pendingCloudProject ?? pendingTodoBinding?.project ?? null + const itemToBind = pendingTodoItem ?? pendingTodoBinding?.item ?? null + if (!currentRuntimeTask || !projectToBind) return + const pendingBinding = pendingTodoBinding + if ( + pendingBinding?.target && + (pendingBinding.target.deviceId !== currentRuntimeTask.deviceId || + pendingBinding.target.taskId !== currentRuntimeTask.taskId) + ) { + return + } + const api = projectSpaceApiFor(projectToBind) + if (!api) return + const bindingTaskTitle = + truncateRuntimeTaskTitle(pendingBinding?.description) || + t('workbench.untitled_task', '未命名任务') let active = true - const bindingRequest = pendingTodoItem - ? services.deliveryApi.bindTask(pendingTodoItem.id, currentRuntimeTask, runtimeTaskTitle) - : services.deliveryApi.bindProjectTask( - pendingCloudProject.id, + const bindingRequest = itemToBind + ? api + .bindTask(itemToBind.id, currentRuntimeTask, bindingTaskTitle) + .then(() => ({ item: itemToBind })) + : api.trackProjectTask( + projectToBind.id, currentRuntimeTask, - runtimeTaskTitle + bindingTaskTitle, + pendingBinding?.description ?? '' ) void bindingRequest - .then(() => { + .then(({ item }) => { if (!active) return - setBoundCloudProject(pendingCloudProject) - setBoundCloudItem(pendingTodoItem) - setDeliveryItem( - pendingTodoItem ? cloudItemAsLocalWorkItem(pendingTodoItem, currentRuntimeTask) : null - ) + setBoundCloudProject(projectToBind) + setBoundCloudItem(item) + setDeliveryItem(cloudItemAsLocalWorkItem(item, currentRuntimeTask)) pendingTodoBinding = null setPendingCloudContext(null, null) }) @@ -812,8 +929,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ currentRuntimeTask, pendingCloudProject, pendingTodoItem, - runtimeTaskTitle, - services?.deliveryApi, + projectSpaceApiFor, setPendingCloudContext, t, ]) @@ -907,31 +1023,155 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ ? cloudMentionState.candidates : [] - // Accessible cloud projects power the @ 项目空间 entry even when the current - // session is not bound to a cloud project yet. + useEffect(() => { + const refresh = () => { + setLocalBindingRevision(revision => revision + 1) + if ( + currentRuntimeTask || + !localProjectId || + dismissedDefaultCloudProjectKey === defaultCloudProjectSelectionKey + ) { + return + } + const cachedProject = readCachedAutoJoinProjectSpace(localProjectId, localProjectDeviceId) + if (cachedProject) { + setPendingCloudContext(cachedProject, null) + } else if (pendingCloudProject?.id === defaultCloudProjectId) { + setPendingCloudContext(null, null) + } + } + window.addEventListener(PROJECT_SPACE_LOCAL_BINDINGS_CHANGED_EVENT, refresh) + return () => window.removeEventListener(PROJECT_SPACE_LOCAL_BINDINGS_CHANGED_EVENT, refresh) + }, [ + currentRuntimeTask, + defaultCloudProjectId, + defaultCloudProjectSelectionKey, + dismissedDefaultCloudProjectKey, + localProjectDeviceId, + localProjectId, + pendingCloudProject?.id, + setPendingCloudContext, + ]) + + // A local project association limits the project spaces offered to new tasks. + // The default association is a preselection only; the selected task is still + // the sole unit that enters the project board. useEffect(() => { let active = true - const api = services?.deliveryApi - if (!api) { + const apis = [ + services?.projectSpaceApis?.local, + services?.projectSpaceApis?.cloud, + services?.deliveryApi, + ].filter( + (api, index, candidates): api is NonNullable => + Boolean(api) && candidates.indexOf(api) === index + ) + if (!apis.length) { queueMicrotask(() => { - if (active) setCloudProjects([]) + if (active) { + setCloudProjects([]) + setDefaultCloudProjectId(null) + } }) return () => { active = false } } - void api - .listCloudProjects() - .then(result => { - if (active) setCloudProjects(result.items) + const deviceId = localProjectDeviceId + type AssociatedProject = { + project: CloudProject + binding: CloudProjectLocalBinding | null + } + void Promise.allSettled( + apis.map(async (api): Promise => { + const result = await api.listCloudProjects() + if (!localProjectId) { + return result.items.map(project => ({ project, binding: null })) + } + const candidates = await Promise.all( + result.items.map(async project => { + const bindings = await api.listLocalBindings(project.id) + const binding = + bindings.find( + candidate => + candidate.local_project_id === localProjectId && candidate.device_id === deviceId + ) ?? + bindings.find( + candidate => candidate.local_project_id === localProjectId && !candidate.device_id + ) ?? + null + return { project, binding } + }) + ) + return candidates.filter(candidate => Boolean(candidate.binding)) + }) + ) + .then(results => { + if (!active) return + const candidates = results.flatMap(result => + result.status === 'fulfilled' ? result.value : [] + ) + const uniqueProjects = candidates.filter( + (candidate, index) => + candidates.findIndex( + other => + other.project.id === candidate.project.id && + other.project.project_store === candidate.project.project_store + ) === index + ) + const defaultProject = uniqueProjects.find(candidate => candidate.binding?.is_default) + setCloudProjects(uniqueProjects.map(candidate => candidate.project)) + setDefaultCloudProjectId(defaultProject?.project.id ?? null) + if (defaultProject && localProjectId) { + cacheAutoJoinProjectSpace(localProjectId, deviceId, defaultProject.project) + } + const pendingAutoJoin = pendingAutoJoinResolutionRef.current + const pendingTargetMatchesCurrentTask = + pendingAutoJoin?.target && + currentRuntimeTask && + pendingAutoJoin.target.deviceId === currentRuntimeTask.deviceId && + pendingAutoJoin.target.taskId === currentRuntimeTask.taskId + if ( + defaultProject && + !pendingCloudProject && + dismissedDefaultCloudProjectKey !== defaultCloudProjectSelectionKey && + (!currentRuntimeTask || pendingTargetMatchesCurrentTask) + ) { + pendingTodoBinding = { + project: defaultProject.project, + item: null, + target: pendingAutoJoin?.target ?? null, + description: pendingAutoJoin?.description ?? '', + } + pendingAutoJoinResolutionRef.current = null + setPendingCloudProject(defaultProject.project) + setPendingTodoItemState(null) + } else if (!defaultProject && pendingAutoJoin) { + pendingAutoJoinResolutionRef.current = null + } }) .catch(() => { - if (active) setCloudProjects([]) + if (active) { + setCloudProjects([]) + setDefaultCloudProjectId(null) + } }) return () => { active = false } - }, [services?.deliveryApi]) + }, [ + currentRuntimeTask, + defaultCloudProjectSelectionKey, + dismissedDefaultCloudProjectKey, + localBindingRevision, + localProjectDeviceId, + localProjectId, + pendingCloudProject, + services?.deliveryApi, + services?.projectSpaceApis?.cloud, + services?.projectSpaceApis?.local, + setPendingCloudContext, + ]) const cloudProjectMentionCandidates = useMemo( () => cloudProjects.map(project => { @@ -941,6 +1181,10 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ key: `cloud-project-space:${project.id}`, title: project.name, description: project.description || project.project_key || undefined, + statusLabel: + project.id === defaultCloudProjectId + ? t('workbench.project_space_auto_join', '自动加入') + : undefined, metaLabel: t('workbench.mention_cloud_space', '云空间'), testId: `cloud-project-space-${String(project.id).replace(/[^a-zA-Z0-9_-]/g, '-')}`, enabled: true, @@ -957,7 +1201,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ project, } }), - [cloudProjects, t] + [cloudProjects, defaultCloudProjectId, t] ) const bindComposerCloudProject = useCallback( (project: CloudProject, notice: string) => { @@ -966,7 +1210,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ setPendingCloudContext(project, null) return } - const api = services?.deliveryApi + const api = projectSpaceApiFor(project) if (!api) return void api .bindProjectTask(project.id, currentRuntimeTask, runtimeTaskTitle) @@ -983,10 +1227,11 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ ) }) }, - [currentRuntimeTask, runtimeTaskTitle, services?.deliveryApi, setPendingCloudContext, t] + [currentRuntimeTask, projectSpaceApiFor, runtimeTaskTitle, setPendingCloudContext, t] ) const handleSelectCloudProject = useCallback( (project: CloudProject) => { + setDismissedDefaultCloudProjectKey(null) bindComposerCloudProject( project, t('workbench.cloud_project_bound_notice', { name: project.name }) @@ -994,6 +1239,26 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ }, [bindComposerCloudProject, t] ) + const pendingProjectSpaceContext = + !currentRuntimeTask && pendingCloudProject ? ( + { + setDismissedDefaultCloudProjectKey(defaultCloudProjectSelectionKey) + setPendingCloudContext(null, null) + }} + title={t( + 'workbench.project_space_context_pending_title', + '发送后会在该项目空间的看板中创建任务' + )} + /> + ) : null const activeDeliveryItem = currentRuntimeTask && @@ -2589,6 +2854,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ experimentalFeaturesEnabled && Boolean(services?.deliveryApi) } onSelectCloudProject={handleSelectCloudProject} + selectedCloudProjectId={composerCloudProject?.id} + toolbarLeadingContext={pendingProjectSpaceContext} isStreaming={paneIsBusy} onPause={pauseCurrentResponse} onCompactContext={compactCurrentContext} @@ -2750,6 +3017,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ experimentalFeaturesEnabled && Boolean(services?.deliveryApi) } onSelectCloudProject={handleSelectCloudProject} + selectedCloudProjectId={composerCloudProject?.id} + toolbarLeadingContext={pendingProjectSpaceContext} isStreaming={paneIsBusy} onPause={pauseCurrentResponse} onCompactContext={compactCurrentContext} @@ -3009,9 +3278,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ onDelivered={() => void finishLocalDelivery()} /> )} - {todoBindingPickerOpen && services?.deliveryApi && ( + {todoBindingPickerOpen && todoBindingApis.length > 0 && ( ({ @@ -88,4 +90,64 @@ describe('LocalProjectEditDialog', () => { await userEvent.click(screen.getByTestId('delete-local-project-button')) expect(onDelete).toHaveBeenCalledTimes(1) }) + + test('configures whether new conversations automatically join a project space', async () => { + const localProjectId = runtimeProjectUiId(projectWork.project) + const binding = { + id: 'binding-1', + cloud_project_id: 'space-1', + local_project_id: localProjectId, + device_id: 'local-device', + is_default: true, + created_at: '2026-08-04T00:00:00Z', + updated_at: '2026-08-04T00:00:00Z', + } + const updateLocalBinding = vi.fn().mockResolvedValue({ ...binding, is_default: false }) + const projectSpaceApi = { + listCloudProjects: vi.fn().mockResolvedValue({ + items: [ + { + id: 'space-1', + public_id: 'space-public-1', + project_key: 'FOLLOWUP', + name: 'Task Follow-up Board', + description: '', + project_store: 'local', + task_provider: 'local', + provider_config: {}, + created_by_user_id: 1, + status: 'active', + tags: [], + version: 1, + created_at: '2026-08-04T00:00:00Z', + updated_at: '2026-08-04T00:00:00Z', + }, + ], + }), + listLocalBindings: vi.fn().mockResolvedValue([binding]), + updateLocalBinding, + addLocalBinding: vi.fn(), + } as unknown as ProjectSpaceBindingApi + + render( + + ) + + const select = await screen.findByTestId('local-project-auto-join-space-select') + await waitFor(() => expect(select).toHaveValue('local:space-1')) + await userEvent.selectOptions(select, '') + await userEvent.click(screen.getByTestId('save-local-project-button')) + + expect(updateLocalBinding).toHaveBeenCalledWith('space-1', 'binding-1', { + is_default: false, + }) + }) }) diff --git a/wework/src/components/projects/LocalProjectEditDialog.tsx b/wework/src/components/projects/LocalProjectEditDialog.tsx index 7d49cb397f..bed709d6d3 100644 --- a/wework/src/components/projects/LocalProjectEditDialog.tsx +++ b/wework/src/components/projects/LocalProjectEditDialog.tsx @@ -1,10 +1,18 @@ -import { Folder, FolderPlus, Loader2, X } from 'lucide-react' -import { useMemo, useState } from 'react' +import { Folder, FolderPlus, LayoutDashboard, Loader2, X } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { shouldUseNativeProjectDirectoryPicker } from '@/e2e/automation' +import { + cacheAutoJoinProjectSpace, + loadProjectSpaceBindingOptions, + saveAutoJoinProjectSpace, + type ProjectSpaceBindingApi, + type ProjectSpaceBindingOption, +} from '@/features/todo/projectSpaceLocalBindings' import { useEscapeKey } from '@/hooks/useEscapeKey' import { useTranslation } from '@/hooks/useTranslation' import { openNativeProjectDirectoryPickers } from '@/lib/native-directory-picker' +import { runtimeProjectUiId } from '@/lib/runtime-project' import type { DeviceInfo, RuntimeProjectWork } from '@/types/api' import { DeviceFolderPicker } from './DeviceFolderPicker' @@ -15,6 +23,7 @@ interface LocalProjectEditDialogProps { onGetDeviceHomeDirectory: (deviceId: string) => Promise onListDeviceDirectories: (deviceId: string, path: string) => Promise onCreateDeviceDirectory: (deviceId: string, path: string) => Promise + projectSpaceApis?: ProjectSpaceBindingApi[] onClose: () => void onSave: (data: { deviceId: string @@ -41,6 +50,7 @@ export function LocalProjectEditDialog({ onGetDeviceHomeDirectory, onListDeviceDirectories, onCreateDeviceDirectory, + projectSpaceApis, onClose, onSave, onDelete, @@ -54,6 +64,7 @@ export function LocalProjectEditDialog({ onGetDeviceHomeDirectory={onGetDeviceHomeDirectory} onListDeviceDirectories={onListDeviceDirectories} onCreateDeviceDirectory={onCreateDeviceDirectory} + projectSpaceApis={projectSpaceApis} onClose={onClose} onSave={onSave} onDelete={onDelete} @@ -67,6 +78,7 @@ function LocalProjectEditDialogContent({ onGetDeviceHomeDirectory, onListDeviceDirectories, onCreateDeviceDirectory, + projectSpaceApis = [], onClose, onSave, onDelete, @@ -87,13 +99,45 @@ function LocalProjectEditDialogContent({ const [submitting, setSubmitting] = useState(false) const [showFolderPicker, setShowFolderPicker] = useState(false) const [error, setError] = useState(null) + const [projectSpaceOptions, setProjectSpaceOptions] = useState([]) + const [autoJoinProjectSpaceKey, setAutoJoinProjectSpaceKey] = useState(null) + const [initialAutoJoinProjectSpaceKey, setInitialAutoJoinProjectSpaceKey] = useState< + string | null + >(null) + const [projectSpacesLoading, setProjectSpacesLoading] = useState(projectSpaceApis.length > 0) const deviceId = projectWork.project.stateDeviceId?.trim() || projectWork.deviceWorkspaces[0]?.deviceId.trim() || '' + const localProjectId = runtimeProjectUiId(projectWork.project) useEscapeKey(onClose, !submitting) + useEffect(() => { + if (projectSpaceApis.length === 0) return + let active = true + void loadProjectSpaceBindingOptions(projectSpaceApis, localProjectId, deviceId || undefined) + .then(options => { + if (!active) return + const selectedKey = options.find(option => option.binding?.is_default)?.key ?? null + const selectedProject = options.find(option => option.key === selectedKey)?.project ?? null + cacheAutoJoinProjectSpace(localProjectId, deviceId || undefined, selectedProject) + setProjectSpaceOptions(options) + setAutoJoinProjectSpaceKey(selectedKey) + setInitialAutoJoinProjectSpaceKey(selectedKey) + }) + .catch(loadError => { + if (!active) return + setError(loadError instanceof Error ? loadError.message : String(loadError)) + }) + .finally(() => { + if (active) setProjectSpacesLoading(false) + }) + return () => { + active = false + } + }, [deviceId, localProjectId, projectSpaceApis]) + const addFolders = async () => { if (!shouldUseNativeProjectDirectoryPicker()) { setShowFolderPicker(true) @@ -119,6 +163,14 @@ function LocalProjectEditDialogContent({ name: trimmedName, roots, }) + if (autoJoinProjectSpaceKey !== initialAutoJoinProjectSpaceKey) { + await saveAutoJoinProjectSpace( + projectSpaceOptions, + autoJoinProjectSpaceKey, + localProjectId, + deviceId || undefined + ) + } onClose() } catch (saveError) { setError(saveError instanceof Error ? saveError.message : String(saveError)) @@ -237,6 +289,43 @@ function LocalProjectEditDialogContent({ )}
+ {projectSpaceApis.length > 0 && ( + <> +

+ {t('workbench.project_auto_join_space', '自动加入项目空间')} +

+

+ {t( + 'workbench.project_auto_join_space_description', + '在这个项目里开启的新对话会默认加入所选项目空间,并在发送前明确显示。' + )} +

+ + + )} + {error &&

{error}

}
+
+ ) + })} + {localProjects.some( + localProject => + !localBindings.some(binding => binding.local_project_id === localProject.id) + ) ? ( +
+ + +
+ ) : null} + + +
diff --git a/wework/src/features/todo/CloudTodoWorkspace.tsx b/wework/src/features/todo/CloudTodoWorkspace.tsx index fe75f1836a..e7223e97bc 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.tsx @@ -1909,6 +1909,7 @@ export function CloudTodoWorkspace({ aitableApi={aitableApi} dwsApi={services.dwsApi} project={selectedProject} + localProjects={localProjects} boardCardDisplay={boardCardDisplay} onProjectUpdated={updated => setProjects(current => diff --git a/wework/src/features/todo/TodoBindingPicker.test.tsx b/wework/src/features/todo/TodoBindingPicker.test.tsx index 41aff38dcb..ecf1cbb1e1 100644 --- a/wework/src/features/todo/TodoBindingPicker.test.tsx +++ b/wework/src/features/todo/TodoBindingPicker.test.tsx @@ -5,13 +5,17 @@ import type { WorkbenchServices } from '@/features/workbench/workbenchServices' import { TodoBindingPicker } from './TodoBindingPicker' const project = { - id: 1, + id: '1', public_id: 'project-1', project_key: 'WEG', name: 'Wegent', description: '', + project_store: 'backend' as const, + task_provider: 'local', + provider_config: {}, created_by_user_id: 1, status: 'active', + tags: [], version: 1, created_at: '2026-07-22T00:00:00Z', updated_at: '2026-07-22T00:00:00Z', @@ -19,8 +23,9 @@ const project = { const item = { id: 'WEG-1', - cloud_project_id: 1, + cloud_project_id: '1', sequence_number: 1, + parent_id: null, created_by_user_id: 1, assignee_user_id: null, title: 'Cloud TODO', @@ -28,6 +33,7 @@ const item = { status: 'inbox' as const, priority: 'none' as const, due_at: null, + tags: [], sort_order: 0, current_delivery_id: null, version: 1, @@ -54,7 +60,7 @@ describe('TodoBindingPicker', () => { const onBound = vi.fn() render( { const onBound = vi.fn() render( { const onBound = vi.fn() const view = render( { view.rerender( { const onBound = vi.fn() render( { ) expect(onBound).toHaveBeenCalledWith(project, null) }) + + it('lists local and cloud project spaces and binds through the selected local API', async () => { + const cloudApi = api() + const localProject = { + ...project, + id: 'local-project', + public_id: 'local-project-public', + project_key: 'LOCAL', + name: 'Local Project Space', + project_store: 'local' as const, + } + const localItem = { + ...item, + id: 'LOCAL-1', + cloud_project_id: localProject.id, + title: 'Local TODO', + } + const localApi = { + ...api(), + listCloudProjects: vi.fn(async () => ({ items: [localProject] })), + listLoopItems: vi.fn(async () => ({ items: [localItem] })), + } + const onBound = vi.fn() + + render( + + ) + + const select = await screen.findByTestId('todo-binding-project') + expect(select).toHaveTextContent('Local Project Space') + expect(select).toHaveTextContent('Wegent') + await userEvent.selectOptions(select, 'local:local-project') + await userEvent.click(screen.getByTestId('todo-binding-project-only')) + + expect(localApi.bindProjectTask).toHaveBeenCalledWith( + localProject.id, + { deviceId: 'local-device', taskId: 'task-1' }, + undefined + ) + expect(cloudApi.bindProjectTask).not.toHaveBeenCalled() + expect(onBound).toHaveBeenCalledWith(localProject, null) + }) + + it('keeps local project spaces visible when loading their tasks fails', async () => { + const localProject = { + ...project, + id: 'local-project', + public_id: 'local-project-public', + project_key: 'LOCAL', + name: 'Local Project Space', + project_store: 'local' as const, + } + const localApi = { + ...api(), + listCloudProjects: vi.fn(async () => ({ items: [localProject] })), + listLoopItems: vi.fn(async () => { + throw new Error('Local task list unavailable') + }), + } + + render( + + ) + + expect(await screen.findByTestId('todo-binding-project')).toHaveTextContent( + 'Local Project Space' + ) + }) }) diff --git a/wework/src/features/todo/TodoBindingPicker.tsx b/wework/src/features/todo/TodoBindingPicker.tsx index 9934a83784..2462a67fc0 100644 --- a/wework/src/features/todo/TodoBindingPicker.tsx +++ b/wework/src/features/todo/TodoBindingPicker.tsx @@ -6,8 +6,15 @@ import type { RuntimeTaskAddress } from '@/types/api' type DeliveryApi = NonNullable -interface TodoBindingPickerProps { +interface ProjectSpaceOption { + key: string + project: CloudProject + items: CloudLoopItem[] api: DeliveryApi +} + +interface TodoBindingPickerProps { + apis: DeliveryApi[] runtimeTask?: RuntimeTaskAddress runtimeTaskTitle?: string | null currentProject: CloudProject | null @@ -17,7 +24,7 @@ interface TodoBindingPickerProps { } export function TodoBindingPicker({ - api, + apis, runtimeTask, runtimeTaskTitle, currentProject, @@ -25,57 +32,89 @@ export function TodoBindingPicker({ onClose, onBound, }: TodoBindingPickerProps) { - const [projects, setProjects] = useState([]) - const [items, setItems] = useState([]) + const [projectOptions, setProjectOptions] = useState([]) const [query, setQuery] = useState('') const [creating, setCreating] = useState(false) - const [projectId, setProjectId] = useState(null) + const [projectKey, setProjectKey] = useState(null) const [title, setTitle] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState(null) useEffect(() => { let active = true - void api - .listCloudProjects() - .then(async response => { - const groups = await Promise.all( - response.items.map(project => api.listLoopItems(project.id).then(result => result.items)) + void Promise.allSettled( + apis.map(async api => { + const response = await api.listCloudProjects() + return Promise.all( + response.items.map(async project => { + const items = await api + .listLoopItems(project.id) + .then(result => result.items) + .catch(() => []) + return { + key: `${project.project_store}:${project.id}`, + project, + api, + items, + } + }) ) - if (!active) return - setProjects(response.items) - setProjectId(currentProject?.id ?? response.items[0]?.id ?? null) - setItems(groups.flat()) - }) - .catch(cause => { - if (active) setError(cause instanceof Error ? cause.message : '加载任务失败') }) + ).then(results => { + if (!active) return + const options = results.flatMap(result => (result.status === 'fulfilled' ? result.value : [])) + const uniqueOptions = options.filter( + (option, index) => options.findIndex(candidate => candidate.key === option.key) === index + ) + const currentProjectKey = currentProject + ? `${currentProject.project_store}:${currentProject.id}` + : null + setProjectOptions(uniqueOptions) + setProjectKey( + uniqueOptions.some(option => option.key === currentProjectKey) + ? currentProjectKey + : (uniqueOptions[0]?.key ?? null) + ) + if (uniqueOptions.length === 0 && results.some(result => result.status === 'rejected')) { + const rejection = results.find(result => result.status === 'rejected') + setError( + rejection?.status === 'rejected' && rejection.reason instanceof Error + ? rejection.reason.message + : '加载任务失败' + ) + } + }) return () => { active = false } - }, [api, currentProject?.id]) + }, [apis, currentProject]) + const selectedOption = + projectOptions.find(option => option.key === projectKey) ?? projectOptions[0] ?? null const visibleItems = useMemo(() => { const normalized = query.trim().toLowerCase() - return items.filter( + return (selectedOption?.items ?? []).filter( item => - item.cloud_project_id === projectId && - (!normalized || - `${item.id} ${item.title} ${item.description}`.toLowerCase().includes(normalized)) + !normalized || + `${item.id} ${item.title} ${item.description}`.toLowerCase().includes(normalized) ) - }, [items, projectId, query]) + }, [query, selectedOption]) - const selectedProject = projects.find(project => project.id === projectId) ?? null + const selectedProject = selectedOption?.project ?? null async function bindProject() { - if (!selectedProject || saving) return + if (!selectedOption || saving) return setSaving(true) setError(null) try { if (runtimeTask) { - await api.bindProjectTask(selectedProject.id, runtimeTask, runtimeTaskTitle) + await selectedOption.api.bindProjectTask( + selectedOption.project.id, + runtimeTask, + runtimeTaskTitle + ) } - onBound(selectedProject, null) + onBound(selectedOption.project, null) } catch (cause) { setError(cause instanceof Error ? cause.message : '关联云项目失败') } finally { @@ -84,16 +123,16 @@ export function TodoBindingPicker({ } async function bind(item: CloudLoopItem) { - if (saving || currentItem?.id === item.id) return + if (!selectedOption || saving || currentItem?.id === item.id) return setSaving(true) setError(null) try { if (runtimeTask) { await (runtimeTaskTitle - ? api.bindTask(item.id, runtimeTask, runtimeTaskTitle) - : api.bindTask(item.id, runtimeTask)) + ? selectedOption.api.bindTask(item.id, runtimeTask, runtimeTaskTitle) + : selectedOption.api.bindTask(item.id, runtimeTask)) } - onBound(projects.find(project => project.id === item.cloud_project_id) ?? null, item) + onBound(selectedOption.project, item) } catch (cause) { setError(cause instanceof Error ? cause.message : '关联任务失败') } finally { @@ -110,7 +149,13 @@ export function TodoBindingPicker({ setSaving(true) setError(null) try { - await api.unbindCloudContext(runtimeTask) + const currentProjectKey = currentProject + ? `${currentProject.project_store}:${currentProject.id}` + : null + const currentOption = + projectOptions.find(option => option.key === currentProjectKey) ?? selectedOption + if (!currentOption) throw new Error('关联的项目空间不可用') + await currentOption.api.unbindCloudContext(runtimeTask) onBound(null, null) } catch (cause) { setError(cause instanceof Error ? cause.message : '解除关联失败') @@ -120,17 +165,20 @@ export function TodoBindingPicker({ } async function createAndBind() { - if (!projectId || !title.trim() || saving) return + if (!selectedOption || !title.trim() || saving) return setSaving(true) setError(null) try { - const item = await api.createLoopItem(projectId, { title: title.trim(), status: 'inbox' }) + const item = await selectedOption.api.createLoopItem(selectedOption.project.id, { + title: title.trim(), + status: 'inbox', + }) if (runtimeTask) { await (runtimeTaskTitle - ? api.bindTask(item.id, runtimeTask, runtimeTaskTitle) - : api.bindTask(item.id, runtimeTask)) + ? selectedOption.api.bindTask(item.id, runtimeTask, runtimeTaskTitle) + : selectedOption.api.bindTask(item.id, runtimeTask)) } - onBound(projects.find(project => project.id === item.cloud_project_id) ?? null, item) + onBound(selectedOption.project, item) } catch (cause) { setError(cause instanceof Error ? cause.message : '创建并关联任务失败') } finally { @@ -157,13 +205,13 @@ export function TodoBindingPicker({
@@ -182,13 +230,13 @@ export function TodoBindingPicker({ @@ -256,7 +304,7 @@ export function TodoBindingPicker({ -
- ) - })} - {localProjects.some( - localProject => - !localBindings.some(binding => binding.local_project_id === localProject.id) - ) ? ( -
- - -
- ) : null} -
-
-
diff --git a/wework/src/features/todo/CloudTodoWorkspace.tsx b/wework/src/features/todo/CloudTodoWorkspace.tsx index 7c85803060..3575f4ba55 100644 --- a/wework/src/features/todo/CloudTodoWorkspace.tsx +++ b/wework/src/features/todo/CloudTodoWorkspace.tsx @@ -1940,7 +1940,6 @@ export function CloudTodoWorkspace({ aitableApi={aitableApi} dwsApi={services.dwsApi} project={selectedProject} - localProjects={localProjects} boardCardDisplay={boardCardDisplay} onProjectUpdated={updated => setProjects(current => diff --git a/wework/src/features/todo/projectSpaceLocalBindings.test.ts b/wework/src/features/todo/projectSpaceLocalBindings.test.ts deleted file mode 100644 index b516fa72d3..0000000000 --- a/wework/src/features/todo/projectSpaceLocalBindings.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, test, vi } from 'vitest' -import type { CloudProject, CloudProjectLocalBinding } from '@/api/deliveries' -import { - findProjectSpaceContextForTask, - loadProjectSpaceBindingOptions, - readCachedAutoJoinProjectSpace, - saveAutoJoinProjectSpace, - type ProjectSpaceBindingApi, -} from './projectSpaceLocalBindings' - -function project(id: string, name: string, projectStore: 'local' | 'backend'): CloudProject { - return { - id, - public_id: `public-${id}`, - project_key: id.toUpperCase(), - name, - description: '', - project_store: projectStore, - task_provider: projectStore === 'local' ? 'local' : 'native', - provider_config: {}, - created_by_user_id: 1, - status: 'active', - tags: [], - version: 1, - created_at: '2026-08-04T00:00:00Z', - updated_at: '2026-08-04T00:00:00Z', - } -} - -function binding( - id: string, - projectId: string, - localProjectId: number, - isDefault: boolean -): CloudProjectLocalBinding { - return { - id, - cloud_project_id: projectId, - local_project_id: localProjectId, - device_id: 'device-1', - is_default: isDefault, - created_at: '2026-08-04T00:00:00Z', - updated_at: '2026-08-04T00:00:00Z', - } -} - -function api( - cloudProject: CloudProject, - localBindings: CloudProjectLocalBinding[] -): ProjectSpaceBindingApi { - return { - listCloudProjects: vi.fn().mockResolvedValue({ items: [cloudProject] }), - listLocalBindings: vi.fn().mockResolvedValue(localBindings), - updateLocalBinding: vi.fn().mockImplementation(async (_projectId, bindingId, data) => ({ - ...localBindings.find(candidate => candidate.id === bindingId)!, - ...data, - })), - addLocalBinding: vi.fn(), - } as unknown as ProjectSpaceBindingApi -} - -describe('projectSpaceLocalBindings', () => { - test('moves automatic joining between local and cloud project-space stores', async () => { - window.localStorage.clear() - const localProjectId = 7 - const firstProject = project('space-local', 'Local board', 'local') - const secondProject = project('space-cloud', 'Cloud board', 'backend') - const firstBinding = binding('binding-local', firstProject.id, localProjectId, true) - const secondBinding = binding('binding-cloud', secondProject.id, localProjectId, false) - const localApi = api(firstProject, [firstBinding]) - const cloudApi = api(secondProject, [secondBinding]) - - const options = await loadProjectSpaceBindingOptions( - [localApi, cloudApi], - localProjectId, - 'device-1' - ) - await saveAutoJoinProjectSpace(options, 'backend:space-cloud', localProjectId, 'device-1') - - expect(cloudApi.updateLocalBinding).toHaveBeenCalledWith(secondProject.id, secondBinding.id, { - is_default: true, - }) - expect(localApi.updateLocalBinding).toHaveBeenCalledWith(firstProject.id, firstBinding.id, { - is_default: false, - }) - expect(readCachedAutoJoinProjectSpace(localProjectId, 'device-1')).toEqual(secondProject) - }) - - test('finds a local project-space context when the cloud store has no binding', async () => { - const context = { - id: 'context-1', - device_id: 'device-1', - task_id: 'task-1', - cloud_project_id: 'space-local', - loop_item_id: 'todo-1', - project: project('space-local', 'Local board', 'local'), - loop_item: null, - } - const cloudApi = { - findCloudContextForTask: vi.fn().mockRejectedValue(new Error('Not found')), - } as unknown as ProjectSpaceBindingApi - const localApi = { - findCloudContextForTask: vi.fn().mockResolvedValue(context), - } as unknown as ProjectSpaceBindingApi - - await expect( - findProjectSpaceContextForTask([cloudApi, localApi], { - deviceId: 'device-1', - taskId: 'task-1', - }) - ).resolves.toEqual(context) - }) -}) diff --git a/wework/src/features/todo/projectSpaceLocalBindings.ts b/wework/src/features/todo/projectSpaceLocalBindings.ts deleted file mode 100644 index 799ef085e0..0000000000 --- a/wework/src/features/todo/projectSpaceLocalBindings.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { - PROJECT_SPACE_LOCAL_BINDINGS_CHANGED_EVENT, - type CloudProject, - type CloudProjectLocalBinding, -} from '@/api/deliveries' -import type { WorkbenchServices } from '@/features/workbench/workbenchServices' -import type { RuntimeTaskAddress } from '@/types/api' - -export type ProjectSpaceBindingApi = NonNullable - -export interface ProjectSpaceBindingOption { - key: string - project: CloudProject - api: ProjectSpaceBindingApi - binding: CloudProjectLocalBinding | null -} - -const AUTO_JOIN_PROJECT_SPACE_STORAGE_PREFIX = 'wework.project-space-auto-join.v1' - -function autoJoinStorageKey(localProjectId: number, deviceId?: string | null): string { - return `${AUTO_JOIN_PROJECT_SPACE_STORAGE_PREFIX}:${deviceId || 'all'}:${localProjectId}` -} - -export function readCachedAutoJoinProjectSpace( - localProjectId: number, - deviceId?: string | null -): CloudProject | null { - try { - const stored = window.localStorage.getItem(autoJoinStorageKey(localProjectId, deviceId)) - return stored ? (JSON.parse(stored) as CloudProject) : null - } catch { - return null - } -} - -export function cacheAutoJoinProjectSpace( - localProjectId: number, - deviceId: string | null | undefined, - project: CloudProject | null -): void { - try { - const key = autoJoinStorageKey(localProjectId, deviceId) - if (project) { - window.localStorage.setItem(key, JSON.stringify(project)) - } else { - window.localStorage.removeItem(key) - } - } catch { - // Persistence is an optimization; the server binding remains authoritative. - } -} - -export function projectSpaceBindingApis( - services: WorkbenchServices | null | undefined -): ProjectSpaceBindingApi[] { - if (!services) return [] - const candidates = [ - services.projectSpaceApis?.local, - services.projectSpaceApis?.cloud, - services.deliveryApi, - ] - return candidates.filter( - (api, index): api is ProjectSpaceBindingApi => Boolean(api) && candidates.indexOf(api) === index - ) -} - -export function findProjectSpaceContextForTask( - apis: ProjectSpaceBindingApi[], - task: RuntimeTaskAddress -): ReturnType { - return Promise.any(apis.map(api => api.findCloudContextForTask(task))) -} - -function matchingBinding( - bindings: CloudProjectLocalBinding[], - localProjectId: number, - deviceId?: string -): CloudProjectLocalBinding | null { - return ( - bindings.find( - binding => - binding.local_project_id === localProjectId && binding.device_id === (deviceId ?? null) - ) ?? - bindings.find( - binding => binding.local_project_id === localProjectId && binding.device_id === null - ) ?? - null - ) -} - -function optionKey(project: CloudProject): string { - return `${project.project_store}:${project.id}` -} - -export async function loadProjectSpaceBindingOptions( - apis: ProjectSpaceBindingApi[], - localProjectId: number, - deviceId?: string -): Promise { - const results = await Promise.allSettled( - apis.map(async api => { - const projects = await api.listCloudProjects() - return Promise.all( - projects.items.map(async project => ({ - key: optionKey(project), - project, - api, - binding: matchingBinding( - await api.listLocalBindings(project.id), - localProjectId, - deviceId - ), - })) - ) - }) - ) - const candidates = results.flatMap(result => (result.status === 'fulfilled' ? result.value : [])) - const options = new Map() - for (const candidate of candidates) { - const existing = options.get(candidate.key) - if (!existing || (!existing.binding?.is_default && candidate.binding?.is_default)) { - options.set(candidate.key, candidate) - } - } - return Array.from(options.values()).sort((left, right) => - left.project.name.localeCompare(right.project.name) - ) -} - -export async function saveAutoJoinProjectSpace( - options: ProjectSpaceBindingOption[], - selectedKey: string | null, - localProjectId: number, - deviceId?: string -): Promise { - const selected = selectedKey ? options.find(option => option.key === selectedKey) : null - if (selectedKey && !selected) throw new Error('Selected project space is unavailable') - - if (selected) { - if (selected.binding) { - if (!selected.binding.is_default) { - await selected.api.updateLocalBinding(selected.project.id, selected.binding.id, { - is_default: true, - }) - } - } else { - await selected.api.addLocalBinding(selected.project.id, { - local_project_id: localProjectId, - device_id: deviceId, - is_default: true, - }) - } - } - - await Promise.all( - options - .filter(option => option.key !== selectedKey && option.binding?.is_default) - .map(option => - option.api.updateLocalBinding(option.project.id, option.binding!.id, { - is_default: false, - }) - ) - ) - cacheAutoJoinProjectSpace(localProjectId, deviceId, selected?.project ?? null) - window.dispatchEvent(new Event(PROJECT_SPACE_LOCAL_BINDINGS_CHANGED_EVENT)) -} diff --git a/wework/src/features/todo/projectSpaceSelection.test.ts b/wework/src/features/todo/projectSpaceSelection.test.ts new file mode 100644 index 0000000000..b8e9d99d33 --- /dev/null +++ b/wework/src/features/todo/projectSpaceSelection.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test, vi } from 'vitest' +import type { CloudProject } from '@/api/deliveries' +import { + findProjectSpaceContextForTask, + loadProjectSpaceOptions, + projectSpaceRef, + type ProjectSpaceApi, +} from './projectSpaceSelection' + +function project(id: string, name: string, projectStore: 'local' | 'backend'): CloudProject { + return { + id, + public_id: `public-${id}`, + project_key: id.toUpperCase(), + name, + description: '', + project_store: projectStore, + task_provider: projectStore === 'local' ? 'local' : 'native', + provider_config: {}, + created_by_user_id: 1, + status: 'active', + tags: [], + version: 1, + created_at: '2026-08-04T00:00:00Z', + updated_at: '2026-08-04T00:00:00Z', + } +} + +describe('projectSpaceSelection', () => { + test('lists local and cloud project spaces without querying per-project bindings', async () => { + const localProject = project('space-local', 'Local board', 'local') + const cloudProject = project('space-cloud', 'Cloud board', 'backend') + const localApi = { + listCloudProjects: vi.fn().mockResolvedValue({ items: [localProject] }), + } as unknown as ProjectSpaceApi + const cloudApi = { + listCloudProjects: vi.fn().mockResolvedValue({ items: [cloudProject] }), + } as unknown as ProjectSpaceApi + + await expect(loadProjectSpaceOptions([localApi, cloudApi])).resolves.toEqual([ + { + key: 'backend:space-cloud', + project: cloudProject, + api: cloudApi, + }, + { + key: 'local:space-local', + project: localProject, + api: localApi, + }, + ]) + expect(projectSpaceRef(cloudProject)).toEqual({ + projectStore: 'backend', + projectId: 'space-cloud', + }) + }) + + test('finds a local project-space context when the cloud store has no context', async () => { + const context = { + id: 'context-1', + device_id: 'device-1', + task_id: 'task-1', + cloud_project_id: 'space-local', + loop_item_id: 'todo-1', + project: project('space-local', 'Local board', 'local'), + loop_item: null, + } + const cloudApi = { + findCloudContextForTask: vi.fn().mockRejectedValue(new Error('Not found')), + } as unknown as ProjectSpaceApi + const localApi = { + findCloudContextForTask: vi.fn().mockResolvedValue(context), + } as unknown as ProjectSpaceApi + + await expect( + findProjectSpaceContextForTask([cloudApi, localApi], { + deviceId: 'device-1', + taskId: 'task-1', + }) + ).resolves.toEqual(context) + }) +}) diff --git a/wework/src/features/todo/projectSpaceSelection.ts b/wework/src/features/todo/projectSpaceSelection.ts new file mode 100644 index 0000000000..6273ebe1eb --- /dev/null +++ b/wework/src/features/todo/projectSpaceSelection.ts @@ -0,0 +1,68 @@ +import type { CloudProject } from '@/api/deliveries' +import type { WorkbenchServices } from '@/features/workbench/workbenchServices' +import type { RuntimeProjectSpaceRef, RuntimeTaskAddress } from '@/types/api' + +export type ProjectSpaceApi = NonNullable + +export interface ProjectSpaceOption { + key: string + project: CloudProject + api: ProjectSpaceApi +} + +export function projectSpaceRef(project: CloudProject): RuntimeProjectSpaceRef { + return { + projectStore: project.project_store, + projectId: project.id, + } +} + +export function projectSpaceKey(ref: RuntimeProjectSpaceRef): string { + return `${ref.projectStore}:${ref.projectId}` +} + +export function projectSpaceApis( + services: WorkbenchServices | null | undefined +): ProjectSpaceApi[] { + if (!services) return [] + const candidates = [ + services.projectSpaceApis?.local, + services.projectSpaceApis?.cloud, + services.deliveryApi, + ] + return candidates.filter( + (api, index): api is ProjectSpaceApi => Boolean(api) && candidates.indexOf(api) === index + ) +} + +export function findProjectSpaceContextForTask( + apis: ProjectSpaceApi[], + task: RuntimeTaskAddress +): ReturnType { + return Promise.any(apis.map(api => api.findCloudContextForTask(task))) +} + +export async function loadProjectSpaceOptions( + apis: ProjectSpaceApi[] +): Promise { + const results = await Promise.allSettled( + apis.map(async api => { + const projects = await api.listCloudProjects() + return projects.items.map(project => ({ + key: projectSpaceKey(projectSpaceRef(project)), + project, + api, + })) + }) + ) + const options = new Map() + for (const result of results) { + if (result.status !== 'fulfilled') continue + for (const option of result.value) { + if (!options.has(option.key)) options.set(option.key, option) + } + } + return Array.from(options.values()).sort((left, right) => + left.project.name.localeCompare(right.project.name) + ) +} diff --git a/wework/src/features/workbench/remoteRuntimeWorkCache.test.ts b/wework/src/features/workbench/remoteRuntimeWorkCache.test.ts index d47d300454..de6058a378 100644 --- a/wework/src/features/workbench/remoteRuntimeWorkCache.test.ts +++ b/wework/src/features/workbench/remoteRuntimeWorkCache.test.ts @@ -101,6 +101,10 @@ describe('remote runtime work cache', () => { ], }) ) + input.projects[0].project.defaultProjectSpace = { + projectStore: 'backend', + projectId: 'space-1', + } writeCachedRemoteRuntimeWork(7, input, [ device('remote-a', 'online', { client_ip: '10.201.3.200' }), @@ -118,6 +122,10 @@ describe('remote runtime work cache', () => { remoteHostId: 'remote-a', repoUrl: 'git@example.com:team/repo.git', }) + expect(restored.projects[0].project.defaultProjectSpace).toEqual({ + projectStore: 'backend', + projectId: 'space-1', + }) expect(restoredTask).toMatchObject({ taskId: 'task-a', threadId: 'thread-a', diff --git a/wework/src/features/workbench/remoteRuntimeWorkCache.ts b/wework/src/features/workbench/remoteRuntimeWorkCache.ts index aa04f411bb..8674e4c34a 100644 --- a/wework/src/features/workbench/remoteRuntimeWorkCache.ts +++ b/wework/src/features/workbench/remoteRuntimeWorkCache.ts @@ -4,6 +4,7 @@ import type { RuntimeDeviceWorkspace, RuntimeProjectRef, RuntimeProjectRoot, + RuntimeProjectSpaceRef, RuntimeProjectWork, RuntimeTaskSummary, RuntimeWorkListResponse, @@ -74,6 +75,13 @@ function sanitizeProjectRef(value: unknown): RuntimeProjectRef | null { .map(sanitizeProjectRoot) .filter((root): root is RuntimeProjectRoot => root !== null) : undefined + const rawDefaultProjectSpace = recordValue(project.defaultProjectSpace) + const defaultProjectStore = stringValue(rawDefaultProjectSpace.projectStore) + const defaultProjectId = stringValue(rawDefaultProjectSpace.projectId) + const defaultProjectSpace: RuntimeProjectSpaceRef | undefined = + (defaultProjectStore === 'local' || defaultProjectStore === 'backend') && defaultProjectId + ? { projectStore: defaultProjectStore, projectId: defaultProjectId } + : undefined return { key, @@ -99,6 +107,7 @@ function sanitizeProjectRef(value: unknown): RuntimeProjectRef | null { ? { pinnedOrder: nullableNumberValue(project.pinnedOrder) } : {}), ...(booleanValue(project.active) !== undefined ? { active: booleanValue(project.active) } : {}), + ...(defaultProjectSpace ? { defaultProjectSpace } : {}), } } diff --git a/wework/src/features/workbench/useWorkbenchProjectActions.ts b/wework/src/features/workbench/useWorkbenchProjectActions.ts index 3fe96616c7..cc1330f770 100644 --- a/wework/src/features/workbench/useWorkbenchProjectActions.ts +++ b/wework/src/features/workbench/useWorkbenchProjectActions.ts @@ -21,6 +21,7 @@ import type { GitRepoInfo, ProjectWithTasks, RuntimeProjectAppearanceRequest, + RuntimeProjectSpaceRef, RuntimeProjectPinRequest, RuntimeProjectReorderRequest, RuntimeProjectTaskReorderRequest, @@ -181,7 +182,13 @@ export function useWorkbenchProjectActions({ ) const updateLocalRuntimeProject = useCallback( - async (data: { deviceId: string; projectKey: string; name: string; roots: string[] }) => { + async (data: { + deviceId: string + projectKey: string + name: string + roots: string[] + defaultProjectSpace: RuntimeProjectSpaceRef | null + }) => { const response = await executorClient.runtime.upsertLocalRuntimeProject({ ...data, runtime: 'codex', diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts index 09ffea27c8..4c49aedbb5 100644 --- a/wework/src/features/workbench/workbenchContextTypes.ts +++ b/wework/src/features/workbench/workbenchContextTypes.ts @@ -32,6 +32,7 @@ import type { RuntimeTaskAddress, RuntimeTaskForkTarget, RuntimeProjectAppearanceRequest, + RuntimeProjectSpaceRef, RuntimeProjectPinRequest, RuntimeProjectReorderRequest, RuntimeProjectTaskReorderRequest, @@ -251,6 +252,7 @@ export interface WorkbenchContextValue { projectKey: string name: string roots: string[] + defaultProjectSpace: RuntimeProjectSpaceRef | null }) => Promise removeProject: (projectId: number) => Promise reorderRuntimeProjects: (data: RuntimeProjectReorderRequest) => Promise diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index ca932689be..1b1b487111 100644 --- a/wework/src/i18n/locales/en/common.json +++ b/wework/src/i18n/locales/en/common.json @@ -1970,16 +1970,6 @@ "home_manage_search": "Search project spaces", "home_manage_empty": "No matching project spaces", "home_open": "Open", - "local_binding_title": "Link local projects", - "local_binding_description": "Linked project spaces appear in the task composer's “+” menu. Configure automatic joining in the local project settings.", - "local_binding_project_fallback": "Local project #{{id}}", - "local_binding_device": "Device {{device}}", - "local_binding_all_devices": "All devices", - "local_binding_default": "Automatic", - "local_binding_link": "Link", - "local_binding_link_failed": "Failed to link the local project", - "local_binding_unlink": "Unlink", - "local_binding_unlink_failed": "Failed to unlink the local project", "my_work_calendar_note": "The calendar only shows tasks with a due date.", "my_work_col_due": "Due", "my_work_col_project": "Project", diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json index 76be3f428e..8dddd9a329 100644 --- a/wework/src/i18n/locales/zh-CN/common.json +++ b/wework/src/i18n/locales/zh-CN/common.json @@ -1967,16 +1967,6 @@ "home_manage_search": "搜索项目空间", "home_manage_empty": "没有匹配的项目空间", "home_open": "打开", - "local_binding_title": "关联本地项目", - "local_binding_description": "关联后可从任务输入框的“+”菜单选择当前项目空间;自动加入请在本地项目设置中配置。", - "local_binding_project_fallback": "本地项目 #{{id}}", - "local_binding_device": "设备 {{device}}", - "local_binding_all_devices": "所有设备", - "local_binding_default": "自动加入", - "local_binding_link": "关联", - "local_binding_link_failed": "关联本地项目失败", - "local_binding_unlink": "解除关联", - "local_binding_unlink_failed": "解除本地项目关联失败", "my_work_calendar_note": "日历仅展示设置了截止日期的任务。", "my_work_col_due": "截止日期", "my_work_col_project": "项目", diff --git a/wework/src/types/api.ts b/wework/src/types/api.ts index 5557763c08..a9149f9fed 100644 --- a/wework/src/types/api.ts +++ b/wework/src/types/api.ts @@ -470,6 +470,12 @@ export interface RuntimeProjectRef { pinnedOrder?: number | null active?: boolean appearance?: RuntimeProjectAppearance | null + defaultProjectSpace?: RuntimeProjectSpaceRef | null +} + +export interface RuntimeProjectSpaceRef { + projectStore: 'local' | 'backend' + projectId: string } export interface RuntimeProjectRoot { @@ -813,6 +819,7 @@ export interface RuntimeLocalProjectUpsertRequest { projectKey: string name: string roots: string[] + defaultProjectSpace?: RuntimeProjectSpaceRef | null runtime: 'codex' } @@ -822,6 +829,7 @@ export interface RuntimeLocalProjectUpsertResponse { projectKey: string name: string roots: string[] + defaultProjectSpace?: RuntimeProjectSpaceRef | null runtime: 'codex' error?: string | null }