diff --git a/backend/app/api/endpoints/cloud_projects.py b/backend/app/api/endpoints/cloud_projects.py index 3d71c8d1b3..7cdd6968ff 100644 --- a/backend/app/api/endpoints/cloud_projects.py +++ b/backend/app/api/endpoints/cloud_projects.py @@ -27,8 +27,6 @@ CloudProjectMemberUpdate, CloudProjectResponse, CloudProjectUpdate, - LocalBindingCreate, - LocalBindingResponse, ) from app.services.cloud_files import cloud_file_service from app.services.cloud_projects import cloud_project_service @@ -104,35 +102,6 @@ def archive_cloud_project( cloud_project_service.archive(db, project_id, current_user.id, version) -@router.post( - "/{project_id}/local-bindings", - response_model=LocalBindingResponse, - status_code=status.HTTP_201_CREATED, -) -def add_local_binding( - project_id: int, - values: LocalBindingCreate, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> LocalBindingResponse: - binding = cloud_project_service.add_local_binding( - db, project_id, current_user.id, values - ) - return LocalBindingResponse.model_validate(binding) - - -@router.get("/{project_id}/local-bindings", response_model=list[LocalBindingResponse]) -def list_local_bindings( - project_id: int, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -) -> list[LocalBindingResponse]: - bindings = cloud_project_service.list_local_bindings( - db, project_id, current_user.id - ) - return [LocalBindingResponse.model_validate(binding) for binding in bindings] - - @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/models/__init__.py b/backend/app/models/__init__.py index 32297bf72e..38a973754b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,7 +15,6 @@ from app.models.cloud_project import ( CloudProject, CloudProjectFile, - CloudProjectLocalBinding, LoopItemTaskBinding, ) from app.models.delivery import ( @@ -67,7 +66,6 @@ "DingtalkSyncedNode", "CloudProject", "CloudProjectFile", - "CloudProjectLocalBinding", "LoopItemTaskBinding", "LoopItem", "LoopItemAttachment", diff --git a/backend/app/models/cloud_project.py b/backend/app/models/cloud_project.py index a679a28f57..e2c0f1e871 100644 --- a/backend/app/models/cloud_project.py +++ b/backend/app/models/cloud_project.py @@ -6,13 +6,11 @@ from app.models.delivery import ( CloudProject, CloudProjectFile, - CloudProjectLocalBinding, LoopItemTaskBinding, ) __all__ = [ "CloudProject", "CloudProjectFile", - "CloudProjectLocalBinding", "LoopItemTaskBinding", ] diff --git a/backend/app/models/delivery.py b/backend/app/models/delivery.py index 0ef29e00a7..98e8d1d6eb 100644 --- a/backend/app/models/delivery.py +++ b/backend/app/models/delivery.py @@ -198,14 +198,6 @@ def tags(self) -> list[str]: return [str(tag) for tag in tags] -class CloudProjectLocalBinding(LoopNode): - __mapper_args__ = {"polymorphic_identity": "local_binding"} - - def __init__(self, **kwargs: object) -> None: - kwargs.setdefault("is_default", False) - super().__init__(**kwargs) - - class LoopItemTaskBinding(LoopNode): __mapper_args__ = {"polymorphic_identity": "execution"} diff --git a/backend/app/schemas/cloud_project.py b/backend/app/schemas/cloud_project.py index 56711fbb7d..d10d41fec7 100644 --- a/backend/app/schemas/cloud_project.py +++ b/backend/app/schemas/cloud_project.py @@ -222,30 +222,6 @@ class CloudProjectListResponse(BaseModel): items: list[CloudProjectResponse] -class LocalBindingCreate(BaseModel): - local_project_id: int - device_id: str | None = Field(default=None, max_length=100) - is_default: bool = False - - -class LocalBindingResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: SnowflakeId - cloud_project_id: SnowflakeId - local_project_id: int - user_id: int - device_id: str | None - is_default: bool - created_at: datetime - updated_at: datetime - - @field_validator("device_id", mode="before") - @classmethod - def normalize_empty_device_id(cls, value: object) -> object: - return None if value == "" else value - - class CloudProjectMemberCreate(BaseModel): user_id: int = Field(ge=1) role: BaseRole = BaseRole.Developer 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..a4cf9e4abb 100644 --- a/backend/app/services/cloud_projects/service.py +++ b/backend/app/services/cloud_projects/service.py @@ -14,9 +14,8 @@ from sqlalchemy.orm import Session from app.core.provider_credentials import store_provider_config -from app.models.cloud_project import CloudProject, CloudProjectLocalBinding +from app.models.cloud_project import CloudProject from app.models.delivery import LoopItem, loop_datetime_is_unset -from app.models.project import Project from app.models.resource_member import MemberStatus, ResourceMember from app.models.share_link import ResourceType from app.models.user import User @@ -26,7 +25,6 @@ CloudProjectMemberCreate, CloudProjectMemberUpdate, CloudProjectUpdate, - LocalBindingCreate, default_board_statuses, normalize_provider_config, ) @@ -270,64 +268,6 @@ def archive(self, db: Session, project_id: int, user_id: int, version: int) -> N raise HTTPException(status.HTTP_409_CONFLICT, "Cloud project changed") db.commit() - def add_local_binding( - self, - db: Session, - cloud_project_id: int, - user_id: int, - values: LocalBindingCreate, - ) -> CloudProjectLocalBinding: - require_cloud_project_role(db, cloud_project_id, user_id, BaseRole.Developer) - local_project = ( - db.query(Project) - .filter( - Project.id == values.local_project_id, - Project.user_id == user_id, - Project.is_active.is_(True), - ) - .first() - ) - if local_project is None: - 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.device_id == values.device_id, - ).update({"is_default": False}) - binding = CloudProjectLocalBinding( - cloud_project_id=cloud_project_id, - user_id=user_id, - **values.model_dump(), - ) - db.add(binding) - try: - db.commit() - except IntegrityError as exc: - db.rollback() - raise HTTPException( - status.HTTP_409_CONFLICT, "Local project is already linked" - ) from exc - db.refresh(binding) - return binding - - def list_local_bindings( - self, db: Session, cloud_project_id: int, user_id: int - ) -> list[CloudProjectLocalBinding]: - require_cloud_project_role(db, cloud_project_id, user_id) - return ( - db.query(CloudProjectLocalBinding) - .filter( - CloudProjectLocalBinding.cloud_project_id == cloud_project_id, - CloudProjectLocalBinding.user_id == user_id, - ) - .order_by( - CloudProjectLocalBinding.is_default.desc(), - CloudProjectLocalBinding.updated_at.desc(), - ) - .all() - ) - def list_members( self, db: Session, cloud_project_id: int, user_id: int ) -> list[dict[str, object]]: diff --git a/backend/app/services/loop_items/service.py b/backend/app/services/loop_items/service.py index 8f4ec1cf48..d70a41d380 100644 --- a/backend/app/services/loop_items/service.py +++ b/backend/app/services/loop_items/service.py @@ -788,6 +788,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..05eec7f0be 100644 --- a/backend/tests/api/test_cloud_projects_api.py +++ b/backend/tests/api/test_cloud_projects_api.py @@ -15,7 +15,6 @@ from app.core.security import create_access_token from app.models.delivery import CloudProject, Delivery, DeliveryAsset, LoopItem -from app.models.project import Project from app.models.user import User from app.services.cloud_files import cloud_file_service from app.services.delivery import delivery_service @@ -786,52 +785,51 @@ def provider_request( assert all("server-only-secret" not in str(payload) for _, _, payload in requests) -def test_cloud_project_can_link_local_workspace( +def test_explicit_project_selection_tracks_runtime_task_idempotently( test_client: TestClient, test_db: Session, test_user: User, test_token: str, ) -> None: - local_project = Project( - user_id=test_user.id, - name="Local checkout", - client_origin="wework", - ) - test_db.add(local_project) - test_db.commit() - test_db.refresh(local_project) - - created = test_client.post( + project = test_client.post( "/api/v1/cloud-projects", headers=_auth(test_token), - json={ - "project_key": "collab", - "name": "Shared collaboration", - "description": "A cloud-first project", - }, + 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 created.status_code == 201 - cloud_project = created.json() - assert cloud_project["project_key"] == "COLLAB" + assert tracked.status_code == 201 + assert tracked.json()["item"]["status"] == "in_progress" + item_id = tracked.json()["item"]["id"] - linked = test_client.post( - f"/api/v1/cloud-projects/{cloud_project['id']}/local-bindings", + retried = test_client.post( + f"/api/v1/cloud-projects/{project['id']}/tasks/track", headers=_auth(test_token), - json={ - "local_project_id": local_project.id, - "device_id": "desktop-1", - "is_default": True, - }, + json=payload, ) - assert linked.status_code == 201 - assert linked.json()["local_project_id"] == local_project.id + assert retried.status_code == 201 + assert retried.json()["item"]["id"] == item_id - bindings = test_client.get( - f"/api/v1/cloud-projects/{cloud_project['id']}/local-bindings", + 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 bindings.status_code == 200 - assert bindings.json()[0]["device_id"] == "desktop-1" + assert reviewed.status_code == 200 + assert reviewed.json()["status"] == "in_review" def test_todo_lifecycle_and_multiple_local_tasks( diff --git a/docs/en/wegent/developer-guide/cloud-project-collaboration.md b/docs/en/wegent/developer-guide/cloud-project-collaboration.md index 35c22ba79c..cc208dcaef 100644 --- a/docs/en/wegent/developer-guide/cloud-project-collaboration.md +++ b/docs/en/wegent/developer-guide/cloud-project-collaboration.md @@ -8,13 +8,13 @@ sidebar_position: 32 ## Goal -A cloud project is the shared collaboration and storage boundary for a team. Members may link the same cloud project to different local projects, execute work in Wework, and submit selected conversations, files, and Markdown as immutable delivery snapshots. +A cloud project is the shared collaboration and storage boundary for a team. Members may select the same cloud project as the default destination of their own local projects, execute work in Wework, and submit selected conversations, files, and Markdown as immutable delivery snapshots. A cloud project is not the existing `Project` model: - `Project` is a user-owned local execution workspace containing device, path, Git, and runtime configuration. - `CloudProject` is a shared aggregate containing membership, TODOs, shared files, and a MinIO namespace. -- One cloud project may link to many local projects owned by different members. +- Local projects owned by different members may independently select the same cloud project; the cloud project stores no reverse link. - One TODO may link to many Wework Tasks, while one Task may process at most one active TODO at a time. ## Domain relationships @@ -23,8 +23,6 @@ A cloud project is not the existing `Project` model: CloudProject ├── ResourceMember(resource_type=CloudProject) ├── ShareLink(resource_type=CloudProject) -├── CloudProjectLocalBinding -│ └── Project (local execution workspace) └── LoopItem ├── LoopItemTaskBinding │ └── TaskResource @@ -38,7 +36,7 @@ CloudProject | Data | Source of truth | | --- | --- | | Cloud projects, members, TODOs, task links, delivery metadata | Backend MySQL | -| Local paths, devices, Git, and execution configuration | Existing `projects` and `tasks` | +| Local paths, devices, Git, execution configuration, and default project-space reference | Device-local Codex project state | | Shared files, Markdown, conversations, and delivery snapshots | MinIO/S3 | | AI access to cloud data | MCP authorized by the Backend | @@ -69,9 +67,9 @@ created_by_user_id, storage_prefix, next_item_number status, version, created_at, updated_at ``` -### CloudProjectLocalBinding +### Local-project default space -`cloud_project_local_bindings` records which local project a member uses on a device. Absolute paths remain in local project configuration and must not be returned to other cloud project members. +A local Codex project may store one `{ projectStore, projectId }` default project-space reference. The reference belongs to device-local project state, never enters the Backend, and creates no reverse index on the project space. A new conversation may override or clear the default before its first message is sent. ### LoopItem @@ -109,7 +107,7 @@ Every TODO, delivery, file, and MCP request resolves the caller's cloud-project ## Service boundaries ```text -cloud_projects/ projects, members, and local bindings +cloud_projects/ projects and members loop_items/ TODOs, state transitions, and Task bindings delivery/ immutable delivery snapshots cloud_files/ mutable shared files @@ -133,7 +131,6 @@ Delivery services do not own TODO CRUD. LoopItem services do not access MinIO di /v1/cloud-projects /v1/cloud-projects/{id}/members /v1/cloud-projects/{id}/members/{user_id} -/v1/cloud-projects/{id}/local-bindings /v1/cloud-projects/{id}/files /v1/cloud-projects/{id}/folders /v1/cloud-projects/files/{file_id} diff --git a/docs/en/wework/projects.md b/docs/en/wework/projects.md index 6557c40b7f..61dca5dcc2 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. + +The default project space belongs to the local project's settings and is stored with that project's device-local state; the project space does not keep a reverse link. 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/wegent/developer-guide/cloud-project-collaboration.md b/docs/zh/wegent/developer-guide/cloud-project-collaboration.md index 8cbf3b2a74..ed7676406f 100644 --- a/docs/zh/wegent/developer-guide/cloud-project-collaboration.md +++ b/docs/zh/wegent/developer-guide/cloud-project-collaboration.md @@ -8,13 +8,13 @@ sidebar_position: 32 ## 目标 -云项目是多人共享的协作与存储边界。成员可以把同一个云项目关联到各自不同的本地项目,在 Wework 中执行任务,并把选定的聊天记录、文件和 Markdown 说明作为不可变交付快照提交到云端。 +云项目是多人共享的协作与存储边界。成员可以在自己的本地项目中选择默认云项目,在 Wework 中执行任务,并把选定的聊天记录、文件和 Markdown 说明作为不可变交付快照提交到云端。 云项目不等同于现有 `Project`: - `Project` 是单个用户拥有的本地执行工作区,保存设备、路径、Git 和执行配置。 - `CloudProject` 是多人共享的协作聚合根,拥有成员权限、TODO、共享文件和 MinIO 空间。 -- 一个云项目可以被多个成员关联到多个本地项目。 +- 多个成员的本地项目可以分别把同一个云项目保存为默认目标;云项目不保存反向关联。 - 一个 TODO 可以关联多个 Wework Task,但一个 Task 同时最多处理一个活跃 TODO。 ## 领域关系 @@ -23,8 +23,6 @@ sidebar_position: 32 CloudProject ├── ResourceMember(resource_type=CloudProject) ├── ShareLink(resource_type=CloudProject) -├── CloudProjectLocalBinding -│ └── Project (local execution workspace) └── LoopItem ├── LoopItemTaskBinding │ └── TaskResource @@ -38,7 +36,7 @@ CloudProject | 数据 | 事实来源 | | --- | --- | | 云项目、成员、TODO、任务关联、交付元数据 | Backend MySQL | -| 本地路径、设备、Git 和执行配置 | 现有 `projects` 与 `tasks` | +| 本地路径、设备、Git、执行配置和默认项目空间引用 | 本地 Codex 项目状态 | | 共享文件、Markdown、聊天记录、交付快照 | MinIO/S3 | | AI 对云空间的访问 | Backend 鉴权后的 MCP | @@ -69,9 +67,9 @@ created_by_user_id, storage_prefix, next_item_number status, version, created_at, updated_at ``` -### CloudProjectLocalBinding +### 本地项目默认空间 -`cloud_project_local_bindings` 保存某个成员在某台设备上使用的本地项目。绝对路径仍保存在本地项目配置中,并且不能向其他云项目成员返回。 +本地 Codex 项目可以保存一个 `{ projectStore, projectId }` 默认项目空间引用。该引用属于设备上的本地项目状态,不进入 Backend,也不向项目空间建立反向索引。新对话发送前可以覆盖或清除这个默认值。 ### LoopItem @@ -109,7 +107,7 @@ inbox → pending → in_progress → in_review → completed ## 服务边界 ```text -cloud_projects/ 项目、成员和本地关联 +cloud_projects/ 项目和成员 loop_items/ TODO、状态机和 Task 关联 delivery/ 不可变交付快照 cloud_files/ 可变共享文件 @@ -133,7 +131,6 @@ Delivery 服务不负责 TODO CRUD;LoopItem 服务不直接访问 MinIO;MCP /v1/cloud-projects /v1/cloud-projects/{id}/members /v1/cloud-projects/{id}/members/{user_id} -/v1/cloud-projects/{id}/local-bindings /v1/cloud-projects/{id}/files /v1/cloud-projects/{id}/folders /v1/cloud-projects/files/{file_id} diff --git a/docs/zh/wework/projects.md b/docs/zh/wework/projects.md index 5d475f1c29..e37a463cb7 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/executor/src/runtime_work/codex_global_state.rs b/executor/src/runtime_work/codex_global_state.rs index 115fc6de15..97da1241de 100644 --- a/executor/src/runtime_work/codex_global_state.rs +++ b/executor/src/runtime_work/codex_global_state.rs @@ -60,6 +60,10 @@ const OPLOG_FLUSH_POLL_INTERVAL: Duration = Duration::from_secs(3); static CODEX_GLOBAL_STATE_OPLOG_FLUSH_WATCHER_RUNNING: AtomicBool = AtomicBool::new(false); +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CodexGlobalProject { pub key: String, @@ -73,6 +77,7 @@ pub(crate) struct CodexGlobalProject { pub pinned_order: Option, pub active: bool, pub appearance: Option, + pub default_project_space: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -132,6 +137,13 @@ struct CodexGlobalStateOplogRecord { pinned: Option, #[serde(skip_serializing_if = "Option::is_none")] appearance: Option, + #[serde( + rename = "defaultProjectSpace", + skip_serializing_if = "Option::is_none" + )] + default_project_space: Option, + #[serde(rename = "clearDefaultProjectSpace", skip_serializing_if = "is_false")] + clear_default_project_space: bool, #[serde(skip_serializing_if = "Option::is_none")] label: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -153,6 +165,8 @@ impl Default for CodexGlobalStateOplogRecord { insert_at_end: None, pinned: None, appearance: None, + default_project_space: None, + clear_default_project_space: false, label: None, roots: Vec::new(), updated_at: 0, @@ -410,6 +424,7 @@ pub(crate) fn upsert_codex_global_local_project( project_key: &str, name: &str, roots: &[String], + default_project_space: Option, ) -> Result { let project_key = clean_text(project_key).ok_or_else(|| "projectKey is required".to_owned())?; let name = clean_text(name).ok_or_else(|| "name is required".to_owned())?; @@ -423,30 +438,24 @@ pub(crate) fn upsert_codex_global_local_project( if roots.is_empty() { return Err("roots must contain at least one workspace path".to_owned()); } + let clear_default_project_space = default_project_space.as_ref().is_some_and(Value::is_null); append_codex_global_state_op_record(&CodexGlobalStateOplogRecord { kind: OPLOG_KIND_UPSERT_LOCAL_PROJECT.to_owned(), workspace_path: roots[0].clone(), project_key: Some(project_key.clone()), label: Some(name.clone()), roots: roots.clone(), + default_project_space: default_project_space.filter(|value| !value.is_null()), + clear_default_project_space, updated_at: now_ms(), ..Default::default() })?; flush_or_watch_codex_global_state_oplog(); refresh_codex_global_project_cache(); - Ok(CodexGlobalProject { - key: project_key, - workspace_path: roots[0].clone(), - roots, - name, - source: "local_project".to_owned(), - kind: "local".to_owned(), - remote_host_id: None, - pinned: false, - pinned_order: None, - active: false, - appearance: None, - }) + CodexGlobalProjectIndex::load() + .project_for_key(&project_key) + .cloned() + .ok_or_else(|| "local project was not persisted".to_owned()) } pub(crate) fn register_codex_global_thread_workspace_root( @@ -773,6 +782,15 @@ fn apply_codex_global_state_ops( (op.project_key.as_deref(), op.label.as_deref()) { upsert_local_project_payload(payload, project_key, name, &op.roots); + if op.clear_default_project_space { + set_local_project_default_space_payload(payload, project_key, Value::Null); + } else if let Some(default_project_space) = op.default_project_space.clone() { + set_local_project_default_space_payload( + payload, + project_key, + default_project_space, + ); + } } } OPLOG_KIND_UPSERT_REMOTE_PROJECT => { @@ -1156,6 +1174,7 @@ fn local_projects_from_payload(payload: &Map) -> Vec) -> Vec) -> Vec) -> CodexG pinned_order: None, active: false, appearance: None, + default_project_space: None, } } @@ -1371,13 +1393,18 @@ fn upsert_local_project_payload( if !projects.is_object() { *projects = Value::Object(Map::new()); } - projects + let projects = projects .as_object_mut() - .expect("local projects is an object") - .insert( - project_key.to_owned(), - json!({"id": project_key, "name": name}), - ); + .expect("local projects is an object"); + let project = projects + .entry(project_key.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !project.is_object() { + *project = Value::Object(Map::new()); + } + let project = project.as_object_mut().expect("local project is an object"); + project.insert("id".to_owned(), Value::String(project_key.to_owned())); + project.insert("name".to_owned(), Value::String(name.to_owned())); let writable_roots = payload .entry(PROJECT_WRITABLE_ROOTS_KEY.to_owned()) @@ -1400,6 +1427,26 @@ fn upsert_local_project_payload( upsert_project_order(payload, project_key); } +fn set_local_project_default_space_payload( + payload: &mut Map, + project_key: &str, + default_project_space: Value, +) { + let Some(project) = payload + .get_mut(LOCAL_PROJECTS_KEY) + .and_then(Value::as_object_mut) + .and_then(|projects| projects.get_mut(project_key)) + .and_then(Value::as_object_mut) + else { + return; + }; + if default_project_space.is_null() { + project.remove("defaultProjectSpace"); + } else { + project.insert("defaultProjectSpace".to_owned(), default_project_space); + } +} + fn rename_codex_global_project_payload( payload: &mut Map, project_ref: &str, diff --git a/executor/src/runtime_work/handler.rs b/executor/src/runtime_work/handler.rs index 0a53a5a18d..d412b62942 100644 --- a/executor/src/runtime_work/handler.rs +++ b/executor/src/runtime_work/handler.rs @@ -288,10 +288,10 @@ pub struct RuntimeWorkRpcHandler { codex_app_server: CodexAppServerClient, codex_runtime_proxy_config: Arc>, event_tx: Option>, - active_local_tasks: Arc>>, + next_execution_id: Arc, active_turn_cancellations: Arc>>, active_codex_turns: Arc>>, - active_request_user_inputs: Arc>>>, + active_request_user_inputs: Arc>>, supervisor_evaluating: Arc>>, thread_event_routes: Arc>>, notification_router: Arc>>>, @@ -312,12 +312,20 @@ struct CodexRuntimeProxyConfig { } struct ActiveTurnCancellation { + execution_id: u64, cancel: oneshot::Sender<()>, stopped: oneshot::Receiver<()>, } +#[derive(Clone)] +struct ActiveRequestUserInput { + execution_id: u64, + sender: mpsc::Sender, +} + #[derive(Clone)] struct ActiveCodexTurn { + execution_id: u64, thread_id: String, turn_id: String, } @@ -358,7 +366,7 @@ impl RuntimeWorkRpcHandler { CodexRuntimeProxyConfig::default(), )), event_tx: None, - active_local_tasks: Arc::new(Mutex::new(HashSet::new())), + next_execution_id: Arc::new(AtomicU64::new(1)), active_turn_cancellations: Arc::new(Mutex::new(HashMap::new())), active_codex_turns: Arc::new(Mutex::new(HashMap::new())), active_request_user_inputs: Arc::new(Mutex::new(HashMap::new())), diff --git a/executor/src/runtime_work/handler/collection.rs b/executor/src/runtime_work/handler/collection.rs index a732b6ef7b..b1ca748628 100644 --- a/executor/src/runtime_work/handler/collection.rs +++ b/executor/src/runtime_work/handler/collection.rs @@ -101,9 +101,7 @@ impl RuntimeWorkRpcHandler { continue; } let running = self.is_active_local_task(&link.local_task_id); - if apply_local_execution_state(&mut link, running) { - self.store.update_task_execution_state(&link); - } + apply_local_execution_state(&mut link, running); link.list_order = Some(links.len()); links.push(link); } @@ -543,15 +541,10 @@ impl RuntimeWorkRpcHandler { pub(super) fn link_from_thread(&self, thread: &Value) -> Option { let thread_id = string_field(thread, "id")?; - let mut local_link = self.local_task_by_thread_id(&thread_id); + let local_link = self.local_task_by_thread_id(&thread_id); let local_active = local_link .as_ref() .is_some_and(|link| self.is_active_local_task(&link.local_task_id)); - if let Some(link) = &mut local_link { - if !local_active && normalize_inactive_running_codex_task(link) { - self.store.update_task_execution_state(link); - } - } let workspace_path = string_field(thread, "cwd") .or_else(|| local_link.as_ref().map(|link| link.workspace_path.clone())) .unwrap_or_else(|| "~/.codex".to_owned()); @@ -628,25 +621,18 @@ impl RuntimeWorkRpcHandler { self.store.upsert_task(link); } - pub(super) fn mark_active_local_task(&self, local_task_id: &str) { - self.active_local_tasks - .lock() - .expect("active local task set lock should not be poisoned") - .insert(local_task_id.to_owned()); - } - - pub(super) fn unmark_active_local_task(&self, local_task_id: &str) { - self.active_local_tasks - .lock() - .expect("active local task set lock should not be poisoned") - .remove(local_task_id); - } - - pub(super) fn set_active_turn_cancellation( + pub(super) fn start_local_task_execution( &self, local_task_id: String, - control: ActiveTurnCancellation, - ) { + cancel: oneshot::Sender<()>, + stopped: oneshot::Receiver<()>, + ) -> u64 { + let execution_id = self.next_execution_id.fetch_add(1, Ordering::Relaxed); + let control = ActiveTurnCancellation { + execution_id, + cancel, + stopped, + }; if let Some(previous) = self .active_turn_cancellations .lock() @@ -655,27 +641,49 @@ impl RuntimeWorkRpcHandler { { let _ = previous.cancel.send(()); } + execution_id } - pub(super) fn clear_active_turn_cancellation(&self, local_task_id: &str) { - self.active_turn_cancellations + pub(super) fn finish_local_task_execution( + &self, + local_task_id: &str, + execution_id: u64, + ) -> bool { + let mut active = self + .active_turn_cancellations .lock() - .expect("active turn cancellation map lock should not be poisoned") - .remove(local_task_id); + .expect("active turn cancellation map lock should not be poisoned"); + if active + .get(local_task_id) + .is_some_and(|control| control.execution_id == execution_id) + { + active.remove(local_task_id); + true + } else { + false + } } pub(super) fn record_active_codex_turn( &self, local_task_id: &str, + execution_id: u64, thread_id: String, turn_id: String, ) { + if !self.is_local_task_execution_active(local_task_id, execution_id) { + return; + } self.active_codex_turns .lock() .expect("active codex turn map lock should not be poisoned") .insert( local_task_id.to_owned(), - ActiveCodexTurn { thread_id, turn_id }, + ActiveCodexTurn { + execution_id, + thread_id, + turn_id, + }, ); } @@ -705,11 +713,17 @@ impl RuntimeWorkRpcHandler { None } - pub(super) fn clear_active_codex_turn(&self, local_task_id: &str) { - self.active_codex_turns + pub(super) fn clear_active_codex_turn(&self, local_task_id: &str, execution_id: u64) { + let mut active = self + .active_codex_turns .lock() - .expect("active codex turn map lock should not be poisoned") - .remove(local_task_id); + .expect("active codex turn map lock should not be poisoned"); + if active + .get(local_task_id) + .is_some_and(|turn| turn.execution_id == execution_id) + { + active.remove(local_task_id); + } } pub(super) async fn abort_active_turn(&self, local_task_id: &str) -> bool { @@ -727,39 +741,55 @@ impl RuntimeWorkRpcHandler { return false; } } - self.clear_active_codex_turn(local_task_id); - self.unmark_active_local_task(local_task_id); + self.active_codex_turns + .lock() + .expect("active codex turn map lock should not be poisoned") + .remove(local_task_id); true } pub(super) fn is_active_local_task(&self, local_task_id: &str) -> bool { - self.active_local_tasks + self.active_turn_cancellations .lock() - .expect("active local task set lock should not be poisoned") - .contains(local_task_id) + .expect("active turn cancellation map lock should not be poisoned") + .contains_key(local_task_id) + } + + fn is_local_task_execution_active(&self, local_task_id: &str, execution_id: u64) -> bool { + self.active_turn_cancellations + .lock() + .expect("active turn cancellation map lock should not be poisoned") + .get(local_task_id) + .is_some_and(|control| control.execution_id == execution_id) + } + + pub(super) fn clear_active_request_user_input(&self, local_task_id: &str, execution_id: u64) { + let mut active = self + .active_request_user_inputs + .lock() + .expect("request user input map lock should not be poisoned"); + if active + .get(local_task_id) + .is_some_and(|request| request.execution_id == execution_id) + { + active.remove(local_task_id); + } } pub(super) fn finish_local_task( &self, local_task_id: &str, + execution_id: u64, thread_id: Option, status: &str, ) { - self.clear_active_turn_cancellation(local_task_id); + if !self.finish_local_task_execution(local_task_id, execution_id) { + return; + } self.store.update_task(local_task_id, |link| { if thread_id.is_some() { link.thread_id = thread_id; } - link.status = status.to_owned(); - link.running = status == "running"; - link.thread_status = if link.running { "active" } else { "idle" }.to_owned(); - link.turn_status = match status { - "running" => Some("inProgress".to_owned()), - "done" => Some("completed".to_owned()), - "cancelled" => Some("interrupted".to_owned()), - "failed" => Some("failed".to_owned()), - _ => link.turn_status.clone(), - }; link.updated_at = now_ms(); if status != "running" { link.completed_at = Some(link.updated_at); @@ -769,7 +799,6 @@ impl RuntimeWorkRpcHandler { } }); if status != "running" { - self.unmark_active_local_task(local_task_id); self.schedule_worktree_prune(); } } diff --git a/executor/src/runtime_work/handler/helpers/routing.rs b/executor/src/runtime_work/handler/helpers/routing.rs index 23b6efb51e..68d0a0e2ef 100644 --- a/executor/src/runtime_work/handler/helpers/routing.rs +++ b/executor/src/runtime_work/handler/helpers/routing.rs @@ -1,6 +1,6 @@ -fn normalize_inactive_running_codex_task(link: &mut RuntimeTaskLink) -> bool { +fn normalize_inactive_running_codex_task(link: &mut RuntimeTaskLink) { if !is_inactive_running_codex_task(link) { - return false; + return; } link.status = "active".to_owned(); link.running = false; @@ -13,23 +13,18 @@ fn normalize_inactive_running_codex_task(link: &mut RuntimeTaskLink) -> bool { link.turn_status = Some("completed".to_owned()); } link.updated_at = now_ms(); - true } -fn apply_local_execution_state(link: &mut RuntimeTaskLink, running: bool) -> bool { +fn apply_local_execution_state(link: &mut RuntimeTaskLink, running: bool) { if !running { - return normalize_inactive_running_codex_task(link); + normalize_inactive_running_codex_task(link); + return; } - let changed = !link.running - || link.status != "running" - || link.thread_status != "active" - || link.turn_status.as_deref() != Some("inProgress"); link.running = true; link.status = "running".to_owned(); link.thread_status = "active".to_owned(); link.turn_status = Some("inProgress".to_owned()); - changed } fn is_inactive_running_codex_task(link: &RuntimeTaskLink) -> bool { @@ -198,6 +193,7 @@ fn codex_project_workspaces(project_index: &CodexGlobalProjectIndex) -> Vec { self.record_active_codex_turn( &local_task_id, + active_turn.execution_id, active_turn.thread_id.clone(), turn_id.clone(), ); @@ -674,7 +673,8 @@ impl RuntimeWorkRpcHandler { .active_request_user_inputs .lock() .ok() - .and_then(|requests| requests.get(local_task_id).cloned()); + .and_then(|requests| requests.get(local_task_id).cloned()) + .map(|request| request.sender); let Some(sender) = sender else { return Ok(json!({ "success": false, @@ -708,10 +708,6 @@ impl RuntimeWorkRpcHandler { let link = self .store .update_task(&local_task_id, |link| { - link.status = "cancelled".to_owned(); - link.running = false; - link.thread_status = "idle".to_owned(); - link.turn_status = Some("interrupted".to_owned()); link.updated_at = now_ms(); link.completed_at = Some(link.updated_at); }) @@ -744,7 +740,8 @@ impl RuntimeWorkRpcHandler { .active_request_user_inputs .lock() .ok() - .and_then(|requests| requests.get(local_task_id).cloned()); + .and_then(|requests| requests.get(local_task_id).cloned()) + .map(|request| request.sender); if let Some(sender) = sender { let _ = sender.try_send(empty_request_user_input_response()); } @@ -769,10 +766,6 @@ impl RuntimeWorkRpcHandler { ); } link.workspace_path = workspace_path.to_owned(); - link.status = "running".to_owned(); - link.running = true; - link.thread_status = "active".to_owned(); - link.turn_status = Some("inProgress".to_owned()); link.ephemeral = link.ephemeral || request.ephemeral; if request.runtime_project_key.is_some() { link.runtime_project_key = request.runtime_project_key.clone(); diff --git a/executor/src/runtime_work/handler/tests.rs b/executor/src/runtime_work/handler/tests.rs index 1529052d89..d3c82c53fe 100644 --- a/executor/src/runtime_work/handler/tests.rs +++ b/executor/src/runtime_work/handler/tests.rs @@ -2,9 +2,14 @@ // // SPDX-License-Identifier: Apache-2.0 -use super::queries::reconcile_persisted_codex_turn_status; use super::*; +fn start_test_execution(handler: &RuntimeWorkRpcHandler, local_task_id: &str) -> u64 { + let (cancel, _cancelled) = oneshot::channel(); + let (_stopped, stopped) = oneshot::channel(); + handler.start_local_task_execution(local_task_id.to_owned(), cancel, stopped) +} + #[tokio::test] async fn fork_resolves_the_requested_turn_even_when_the_source_is_running() { for (case, persisted_running, active_in_memory) in @@ -22,7 +27,7 @@ async fn fork_resolves_the_requested_turn_even_when_the_source_is_running() { link.running = persisted_running; handler.upsert_local_task(link); if active_in_memory { - handler.mark_active_local_task("task-1"); + start_test_execution(&handler, "task-1"); } let response = handler @@ -42,7 +47,7 @@ async fn fork_resolves_the_requested_turn_even_when_the_source_is_running() { } #[test] -fn finishing_an_active_goal_keeps_the_task_idle() { +fn finishing_an_active_goal_updates_metadata_without_persisting_execution_state() { let index_path = temp_runtime_work_index_path("finish-active-goal"); let mut handler = RuntimeWorkRpcHandler::new("device-1", "/bin/false"); handler.store = RuntimeWorkStore::new(index_path.clone()); @@ -54,14 +59,17 @@ fn finishing_an_active_goal_keeps_the_task_idle() { link.goal_status = Some("active".to_owned()); handler.upsert_local_task(link); - handler.finish_local_task("task-1", Some("thread-1".to_owned()), "done"); + let execution_id = start_test_execution(&handler, "task-1"); + handler.finish_local_task("task-1", execution_id, Some("thread-1".to_owned()), "done"); let task = handler .local_task_link("task-1") .expect("task should remain stored"); - assert_eq!(task.status, "done"); + assert_eq!(task.status, "active"); assert!(!task.running); assert_eq!(task.goal_status.as_deref(), Some("active")); + assert_eq!(task.thread_id.as_deref(), Some("thread-1")); + assert!(task.completed_at.is_some()); let _ = fs::remove_file(index_path); } @@ -78,12 +86,14 @@ fn turn_result_persists_observed_goal_status_before_settling_task() { ); link.goal_status = Some("active".to_owned()); handler.upsert_local_task(link); - handler.mark_active_local_task("task-1"); + let execution_id = start_test_execution(&handler, "task-1"); handler.handle_turn_result( "task-1", + execution_id, &ExecutionRequest::default(), Some(&ActiveCodexTurn { + execution_id, thread_id: "thread-1".to_owned(), turn_id: "turn-1".to_owned(), }), @@ -100,7 +110,7 @@ fn turn_result_persists_observed_goal_status_before_settling_task() { let task = handler .local_task_link("task-1") .expect("task should remain stored"); - assert_eq!(task.status, "done"); + assert_eq!(task.status, "active"); assert!(!task.running); assert_eq!(task.goal_status.as_deref(), Some("complete")); assert!(!handler.is_active_local_task("task-1")); @@ -108,6 +118,51 @@ fn turn_result_persists_observed_goal_status_before_settling_task() { let _ = fs::remove_file(index_path); } +#[test] +fn stale_execution_cannot_finish_its_replacement() { + let index_path = temp_runtime_work_index_path("stale-execution-finish"); + let mut handler = RuntimeWorkRpcHandler::new("device-1", "/bin/false"); + handler.store = RuntimeWorkStore::new(index_path.clone()); + handler.upsert_local_task(RuntimeTaskLink::new_pending( + "task-1".to_owned(), + "/tmp/project".to_owned(), + "Task".to_owned(), + )); + + let stale_execution_id = start_test_execution(&handler, "task-1"); + let current_execution_id = start_test_execution(&handler, "task-1"); + + handler.finish_local_task( + "task-1", + stale_execution_id, + Some("stale-thread".to_owned()), + "done", + ); + + let task = handler + .local_task_link("task-1") + .expect("task should remain stored"); + assert!(handler.is_active_local_task("task-1")); + assert_eq!(task.thread_id, None); + assert_eq!(task.completed_at, None); + + handler.finish_local_task( + "task-1", + current_execution_id, + Some("current-thread".to_owned()), + "done", + ); + + let task = handler + .local_task_link("task-1") + .expect("task should remain stored"); + assert!(!handler.is_active_local_task("task-1")); + assert_eq!(task.thread_id.as_deref(), Some("current-thread")); + assert!(task.completed_at.is_some()); + + let _ = fs::remove_file(index_path); +} + #[test] fn unlinked_failed_codex_turn_persists_assistant_error_in_runtime_handle() { let index_path = temp_runtime_work_index_path("persist-failed-assistant"); @@ -459,10 +514,13 @@ fn completed_responses_use_the_active_codex_turn_id() { "/tmp/project".to_owned(), "Task".to_owned(), )); + let execution_id = start_test_execution(&handler, &local_task_id); handler.handle_turn_result( &local_task_id, + execution_id, &request, Some(&ActiveCodexTurn { + execution_id, thread_id: format!("thread-{case}"), turn_id: "turn-1".to_owned(), }), @@ -989,6 +1047,7 @@ async fn codex_app_server_restart_requires_confirmation_for_active_turns() { .insert( "thread-1".to_owned(), ActiveCodexTurn { + execution_id: 1, thread_id: "thread-1".to_owned(), turn_id: "turn-1".to_owned(), }, @@ -1263,7 +1322,7 @@ fn task_list_running_state_comes_from_executor_memory() { assert_eq!(idle_link.thread_status, "idle"); assert_eq!(idle_link.turn_status.as_deref(), Some("completed")); - handler.mark_active_local_task("task-1"); + start_test_execution(&handler, "task-1"); let running_link = handler .link_from_thread(&thread) @@ -1297,10 +1356,11 @@ fn active_local_task_routes_only_notifications_from_other_turns_globally() { link.thread_id = Some("thread-1".to_owned()); link.updated_at = 1_780_000_000_000; handler.upsert_local_task(link); - handler.mark_active_local_task(local_task_id); + let execution_id = start_test_execution(&handler, local_task_id); handler.register_thread_event_route("thread-1", local_task_id.to_owned(), request, true); handler.record_active_codex_turn( local_task_id, + execution_id, "thread-1".to_owned(), "turn-current".to_owned(), ); @@ -1475,33 +1535,6 @@ fn codex_guidance_turn_mismatch_exposes_the_actual_turn_id() { ); } -#[test] -fn persisted_terminal_status_reconciles_only_the_exact_last_turn_id() { - let mut link = RuntimeTaskLink::new_pending( - "task-1".to_owned(), - "/tmp/project".to_owned(), - "Task".to_owned(), - ); - link.thread_id = Some("thread-1".to_owned()); - link.status = "done".to_owned(); - link.turn_status = Some("completed".to_owned()); - link.completed_at = Some(123); - link.runtime_handle["lastTurnId"] = json!("turn-2"); - let mut thread = json!({ - "id": "thread-1", - "turns": [ - {"id": "turn-1", "status": "interrupted", "items": []}, - {"id": "turn-2", "status": "interrupted", "items": []} - ] - }); - - reconcile_persisted_codex_turn_status(&mut thread, &link); - - assert_eq!(thread["turns"][0]["status"], "interrupted"); - assert_eq!(thread["turns"][1]["status"], "completed"); - assert_eq!(thread["turns"][1]["completedAt"], 123); -} - #[test] fn archived_cleanup_targets_do_not_delete_regular_project_root() { let link = RuntimeTaskLink::new_pending( diff --git a/executor/src/runtime_work/handler/turns.rs b/executor/src/runtime_work/handler/turns.rs index 02530c9f3e..706bce5d5a 100644 --- a/executor/src/runtime_work/handler/turns.rs +++ b/executor/src/runtime_work/handler/turns.rs @@ -77,23 +77,23 @@ impl RuntimeWorkRpcHandler { } log_executor_event("runtime work turn spawning", &fields); - self.mark_active_local_task(&local_task_id); let (request_user_input_tx, request_user_input_rx): ( mpsc::Sender, CodexRequestUserInputReceiver, ) = mpsc::channel(1); - if let Ok(mut requests) = self.active_request_user_inputs.lock() { - requests.insert(local_task_id.clone(), request_user_input_tx); - } let (cancel_tx, cancel_rx) = oneshot::channel(); let (stopped_tx, stopped_rx) = oneshot::channel(); - self.set_active_turn_cancellation( - local_task_id.clone(), - ActiveTurnCancellation { - cancel: cancel_tx, - stopped: stopped_rx, - }, - ); + let execution_id = + self.start_local_task_execution(local_task_id.clone(), cancel_tx, stopped_rx); + if let Ok(mut requests) = self.active_request_user_inputs.lock() { + requests.insert( + local_task_id.clone(), + ActiveRequestUserInput { + execution_id, + sender: request_user_input_tx, + }, + ); + } let handler = self.clone(); let turn_local_task_id = local_task_id.clone(); let turn_handle = tokio::spawn(async move { @@ -169,6 +169,7 @@ impl RuntimeWorkRpcHandler { }); let active_turn_handler = handler.clone(); let active_turn_local_task_id = turn_local_task_id.clone(); + let active_turn_execution_id = execution_id; let active_turn_subtask_id = request.subtask_id.to_string(); let active_turn_request = request.clone(); let callback_hook_turn = Arc::clone(&hook_turn); @@ -178,11 +179,13 @@ impl RuntimeWorkRpcHandler { .lock() .expect("hook turn context lock should not be poisoned") = Some(ActiveCodexTurn { + execution_id: active_turn_execution_id, thread_id: thread_id.clone(), turn_id: turn_id.clone(), }); active_turn_handler.record_active_codex_turn( &active_turn_local_task_id, + active_turn_execution_id, thread_id, turn_id.clone(), ); @@ -210,7 +213,8 @@ impl RuntimeWorkRpcHandler { let finished_turn_handler = handler.clone(); let finished_turn_local_task_id = turn_local_task_id.clone(); let active_turn_finished: CodexActiveTurnFinishedCallback = Box::new(move || { - finished_turn_handler.clear_active_codex_turn(&finished_turn_local_task_id); + finished_turn_handler + .clear_active_codex_turn(&finished_turn_local_task_id, execution_id); }); let result = handler .codex_app_server @@ -254,13 +258,10 @@ impl RuntimeWorkRpcHandler { }), ); let _ = mapper_handle.await; - handler.clear_active_turn_cancellation(&turn_local_task_id); - handler.clear_active_codex_turn(&turn_local_task_id); - handler.unmark_active_local_task(&turn_local_task_id); + handler.finish_local_task(&turn_local_task_id, execution_id, None, "cancelled"); + handler.clear_active_codex_turn(&turn_local_task_id, execution_id); handler.mark_thread_event_routes_idle_for_local_task(&turn_local_task_id); - if let Ok(mut requests) = handler.active_request_user_inputs.lock() { - requests.remove(&turn_local_task_id); - } + handler.clear_active_request_user_input(&turn_local_task_id, execution_id); let _ = stopped_tx.send(()); return; } @@ -270,11 +271,15 @@ impl RuntimeWorkRpcHandler { .lock() .expect("hook turn context lock should not be poisoned") .clone(); - handler.handle_turn_result(&turn_local_task_id, &request, active_turn.as_ref(), result); - handler.clear_active_codex_turn(&turn_local_task_id); - if let Ok(mut requests) = handler.active_request_user_inputs.lock() { - requests.remove(&turn_local_task_id); - } + handler.handle_turn_result( + &turn_local_task_id, + execution_id, + &request, + active_turn.as_ref(), + result, + ); + handler.clear_active_codex_turn(&turn_local_task_id, execution_id); + handler.clear_active_request_user_input(&turn_local_task_id, execution_id); let _ = stopped_tx.send(()); }); drop(turn_handle); @@ -283,6 +288,7 @@ impl RuntimeWorkRpcHandler { pub(super) fn handle_turn_result( &self, local_task_id: &str, + execution_id: u64, request: &ExecutionRequest, active_turn: Option<&ActiveCodexTurn>, result: Result, @@ -327,7 +333,12 @@ impl RuntimeWorkRpcHandler { event_request.clone(), false, ); - self.finish_local_task(local_task_id, Some(thread_id.clone()), status); + self.finish_local_task( + local_task_id, + execution_id, + Some(thread_id.clone()), + status, + ); self.mark_thread_event_route_idle(&thread_id); self.register_codex_thread_workspace_root(&thread_id, &event_request); match turn.outcome { @@ -390,7 +401,7 @@ impl RuntimeWorkRpcHandler { } Err(error) => { self.mark_thread_event_routes_idle_for_local_task(local_task_id); - self.finish_local_task(local_task_id, None, "failed"); + self.finish_local_task(local_task_id, execution_id, None, "failed"); self.persist_failed_assistant_message(local_task_id, &event_request, &error); let mut fields = task_fields(&event_request.task_id, &event_request.subtask_id); fields.push(("local_task_id", local_task_id.to_owned())); diff --git a/executor/src/runtime_work/handler/workspaces.rs b/executor/src/runtime_work/handler/workspaces.rs index 5b5727de5f..a83964dc93 100644 --- a/executor/src/runtime_work/handler/workspaces.rs +++ b/executor/src/runtime_work/handler/workspaces.rs @@ -52,8 +52,13 @@ impl RuntimeWorkRpcHandler { opened_roots.insert(canonical); } } - let project = upsert_codex_global_local_project(&project_key, &name, &roots) - .map_err(|error| AppIpcError::new("codex_global_state_error", error))?; + let default_project_space = payload + .get("defaultProjectSpace") + .or_else(|| payload.get("default_project_space")) + .cloned(); + let project = + upsert_codex_global_local_project(&project_key, &name, &roots, default_project_space) + .map_err(|error| AppIpcError::new("codex_global_state_error", error))?; Ok(json!({ "success": true, "accepted": true, @@ -61,6 +66,7 @@ impl RuntimeWorkRpcHandler { "projectKey": project.key, "name": project.name, "roots": project.roots, + "defaultProjectSpace": project.default_project_space, "runtime": "codex", })) } diff --git a/executor/src/runtime_work/response.rs b/executor/src/runtime_work/response.rs index 89d9e6632f..177c5a2d48 100644 --- a/executor/src/runtime_work/response.rs +++ b/executor/src/runtime_work/response.rs @@ -88,28 +88,6 @@ pub(crate) struct RuntimeTaskLink { } impl RuntimeTaskLink { - pub(crate) fn copy_execution_state_from(&mut self, current: &Self) { - self.status = current.status.clone(); - self.running = current.running; - self.thread_status = current.thread_status.clone(); - self.turn_status = current.turn_status.clone(); - self.updated_at = current.updated_at; - } - - pub(crate) fn preserve_runtime_state_from(&mut self, current: &Self) { - self.git_info = current.git_info.clone(); - self.list_order = current.list_order; - self.group_workspace_path = current.group_workspace_path.clone(); - self.group_project_key = current.group_project_key.clone(); - self.pinned = current.pinned; - self.pinned_order = current.pinned_order; - let archived = self.status == "archived"; - let current_archived = current.status == "archived"; - if archived == current_archived { - self.copy_execution_state_from(current); - } - } - pub fn new_pending(local_task_id: String, workspace_path: String, title: String) -> Self { Self { local_task_id, @@ -117,11 +95,11 @@ impl RuntimeTaskLink { workspace_path, title, runtime: "codex".to_owned(), - status: "running".to_owned(), - running: true, + status: "active".to_owned(), + running: false, continuable: true, - thread_status: "active".to_owned(), - turn_status: Some("inProgress".to_owned()), + thread_status: "notLoaded".to_owned(), + turn_status: None, goal_status: None, supervisor: None, git_info: None, @@ -206,10 +184,10 @@ impl RuntimeTaskLink { git_info.insert("currentBranch".to_owned(), Value::String(current_branch)); } let running = !local_archived && execution_running; - let mut status = merged_task_status(thread, local_link.as_ref(), running, local_archived); + let mut status = merged_task_status(thread, running, local_archived); let mut thread_status = codex_thread_status_type(thread).unwrap_or_else(|| "notLoaded".to_owned()); - let mut turn_status = task_turn_status(thread, local_link.as_ref(), running); + let mut turn_status = task_turn_status(thread, running); if !running { if runtime_status_is_running(&status) { status = "active".to_owned(); @@ -379,6 +357,7 @@ pub(crate) struct RuntimeWorkspaceLink { pub project_pinned_order: Option, pub project_active: bool, pub project_appearance: Option, + pub default_project_space: Option, } impl Default for RuntimeWorkspaceLink { @@ -399,6 +378,7 @@ impl Default for RuntimeWorkspaceLink { project_pinned_order: None, project_active: false, project_appearance: None, + default_project_space: None, } } } @@ -503,6 +483,9 @@ pub(crate) fn workspace_response( if let Some(appearance) = workspace.project_appearance.clone() { workspace_json["projectAppearance"] = appearance; } + if let Some(default_project_space) = workspace.default_project_space.clone() { + workspace_json["defaultProjectSpace"] = default_project_space; + } } if let Some(remote_host_id) = remote_host_id { workspace_json["remoteHostId"] = Value::String(remote_host_id); @@ -833,32 +816,17 @@ fn thread_status(thread: &Value) -> String { .to_owned() } -fn merged_task_status( - thread: &Value, - local_link: Option<&RuntimeTaskLink>, - running: bool, - archived: bool, -) -> String { +fn merged_task_status(thread: &Value, running: bool, archived: bool) -> String { if archived { return "archived".to_owned(); } if running { return "running".to_owned(); } - if let Some(status) = local_link - .map(|link| link.status.trim().to_ascii_lowercase()) - .filter(|status| matches!(status.as_str(), "done" | "cancelled" | "failed")) - { - return status; - } thread_status(thread) } -fn task_turn_status( - thread: &Value, - local_link: Option<&RuntimeTaskLink>, - running: bool, -) -> Option { +fn task_turn_status(thread: &Value, running: bool) -> Option { if running { return Some("inProgress".to_owned()); } @@ -868,17 +836,6 @@ fn task_turn_status( .and_then(|turns| turns.last()) .and_then(|turn| string_field(turn, "status")) .map(normalize_codex_turn_status) - .or_else(|| local_link.and_then(|link| link.turn_status.clone())) - .or_else(|| { - local_link.and_then( - |link| match link.status.trim().to_ascii_lowercase().as_str() { - "done" => Some("completed".to_owned()), - "cancelled" | "canceled" => Some("interrupted".to_owned()), - "failed" => Some("failed".to_owned()), - _ => None, - }, - ) - }) } fn normalize_codex_turn_status(status: String) -> String { @@ -1113,30 +1070,25 @@ mod tests { } #[test] - fn idle_thread_preserves_local_terminal_task_statuses() { - for (task_status, turn_status) in [ - ("done", "completed"), - ("cancelled", "interrupted"), + fn idle_thread_uses_provider_turn_status() { + for (provider_status, turn_status) in [ + ("completed", "completed"), + ("interrupted", "interrupted"), ("failed", "failed"), ] { - let local_link = RuntimeTaskLink { - status: task_status.to_owned(), - running: false, - turn_status: Some(turn_status.to_owned()), - ..RuntimeTaskLink::default() - }; let link = RuntimeTaskLink::from_thread_metadata( &json!({ "id": "thread-1", "status": "idle", "cwd": "/workspace/project", + "turns": [{"status": provider_status}], }), - Some(local_link), + None, "/workspace/project".to_owned(), false, ); - assert_eq!(link.status, task_status); + assert_eq!(link.status, "active"); assert!(!link.running); assert!(link.continuable); assert_eq!(link.thread_status, "idle"); diff --git a/executor/src/runtime_work/store.rs b/executor/src/runtime_work/store.rs index 2da2d8fc94..8f869ae695 100644 --- a/executor/src/runtime_work/store.rs +++ b/executor/src/runtime_work/store.rs @@ -135,14 +135,6 @@ impl RuntimeWorkStore { self.update_task_with_persistence(local_task_id, updater, true) } - pub fn update_task_execution_state(&self, runtime_state: &RuntimeTaskLink) { - self.update_task_with_persistence( - &runtime_state.local_task_id, - |task| task.copy_execution_state_from(runtime_state), - false, - ); - } - fn update_task_with_persistence( &self, local_task_id: &str, @@ -204,13 +196,8 @@ impl RuntimeWorkStore { return; } - let mut disk_index = read_index_from_path(&self.index_path); + let disk_index = read_index_from_path(&self.index_path); if let Ok(mut index) = self.index.lock() { - for (task_id, disk_task) in &mut disk_index.tasks { - if let Some(current_task) = index.tasks.get(task_id) { - disk_task.preserve_runtime_state_from(current_task); - } - } *index = disk_index; } if let Ok(mut signature) = self.index_signature.lock() { @@ -266,13 +253,7 @@ fn strip_transient_task_state(index: &mut Value) { task.remove("status"); task.remove("running"); task.remove("thread_status"); - if !task - .get("turn_status") - .and_then(Value::as_str) - .is_some_and(is_terminal_turn_status) - { - task.remove("turn_status"); - } + task.remove("turn_status"); task.insert("archived".to_owned(), Value::Bool(archived)); } } @@ -298,41 +279,20 @@ fn restore_persisted_task_metadata(index: &mut Value) { .as_deref() .is_some_and(|status| status.eq_ignore_ascii_case("archived")) }); - let restored_status = legacy_status - .filter(|status| { - matches!( - status.trim().to_ascii_lowercase().as_str(), - "done" | "cancelled" | "canceled" | "failed" - ) - }) - .unwrap_or_else(|| "active".to_owned()); task.insert( "status".to_owned(), Value::String(if archived { "archived".to_owned() } else { - restored_status + "active".to_owned() }), ); task.remove("running"); task.remove("thread_status"); - if !task - .get("turn_status") - .and_then(Value::as_str) - .is_some_and(is_terminal_turn_status) - { - task.remove("turn_status"); - } + task.remove("turn_status"); } } -fn is_terminal_turn_status(status: &str) -> bool { - matches!( - status.replace(['_', '-'], "").to_ascii_lowercase().as_str(), - "completed" | "done" | "failed" | "error" | "interrupted" | "cancelled" | "canceled" - ) -} - fn index_file_signature(index_path: &Path) -> Option { let metadata = fs::metadata(index_path).ok()?; let modified_ms = metadata @@ -453,7 +413,7 @@ mod tests { use super::*; #[test] - fn shared_index_reload_preserves_process_local_running_state() { + fn shared_index_never_persists_execution_state() { let directory = tempfile::tempdir().expect("temporary directory should be created"); let index_path = directory.path().join("index.json"); let owner_store = RuntimeWorkStore::new(index_path.clone()); @@ -476,10 +436,10 @@ mod tests { let owner_task = owner_store .get_task("owner-task") .expect("owner task should survive peer writes"); - assert!(owner_task.running); - assert_eq!(owner_task.status, "running"); - assert_eq!(owner_task.thread_status, "active"); - assert_eq!(owner_task.turn_status.as_deref(), Some("inProgress")); + assert!(!owner_task.running); + assert_eq!(owner_task.status, "active"); + assert_eq!(owner_task.thread_status, "notLoaded"); + assert_eq!(owner_task.turn_status, None); let persisted: Value = serde_json::from_slice( &fs::read(&index_path).expect("shared index should be readable"), @@ -495,42 +455,6 @@ mod tests { assert_eq!(persisted_owner.get("archived"), Some(&Value::Bool(false))); } - #[test] - fn shared_index_reload_preserves_process_local_inactive_execution_state() { - let directory = tempfile::tempdir().expect("temporary directory should be created"); - let index_path = directory.path().join("index.json"); - let owner_store = RuntimeWorkStore::new(index_path.clone()); - let peer_store = RuntimeWorkStore::new(index_path); - let mut owner_task = RuntimeTaskLink::new_pending( - "owner-task".to_owned(), - "/tmp/owner".to_owned(), - "Owner task".to_owned(), - ); - - owner_store.upsert_task(owner_task.clone()); - owner_task.status = "done".to_owned(); - owner_task.running = false; - owner_task.thread_status = "idle".to_owned(); - owner_task.turn_status = Some("completed".to_owned()); - owner_store.update_task_execution_state(&owner_task); - peer_store.upsert_task(RuntimeTaskLink::new_imported( - "peer-task".to_owned(), - "/tmp/peer".to_owned(), - "Peer task".to_owned(), - "codex".to_owned(), - serde_json::json!({}), - serde_json::json!({}), - )); - - let reloaded = owner_store - .get_task("owner-task") - .expect("owner task should survive peer writes"); - assert_eq!(reloaded.status, "done"); - assert!(!reloaded.running); - assert_eq!(reloaded.thread_status, "idle"); - assert_eq!(reloaded.turn_status.as_deref(), Some("completed")); - } - #[test] fn persisted_archive_metadata_restores_without_task_status_fields() { let directory = tempfile::tempdir().expect("temporary directory should be created"); @@ -558,7 +482,7 @@ mod tests { } #[test] - fn terminal_turn_status_persists_without_live_execution_state() { + fn terminal_turn_status_is_not_persisted() { let directory = tempfile::tempdir().expect("temporary directory should be created"); let index_path = directory.path().join("index.json"); let store = RuntimeWorkStore::new(index_path.clone()); @@ -584,10 +508,7 @@ mod tests { assert!(!task.contains_key("status")); assert!(!task.contains_key("running")); assert!(!task.contains_key("thread_status")); - assert_eq!( - task.get("turn_status"), - Some(&Value::String("completed".to_owned())) - ); + assert!(!task.contains_key("turn_status")); let restored = RuntimeWorkStore::new(index_path) .get_task("completed-task") @@ -595,7 +516,7 @@ mod tests { assert!(!restored.running); assert_eq!(restored.status, "active"); assert_eq!(restored.thread_status, "notLoaded"); - assert_eq!(restored.turn_status.as_deref(), Some("completed")); + assert_eq!(restored.turn_status, None); } #[test] diff --git a/executor/tests/app_runtime_work_native_update_contract.rs b/executor/tests/app_runtime_work_native_update_contract.rs index 4e924b0474..c13102f6dc 100644 --- a/executor/tests/app_runtime_work_native_update_contract.rs +++ b/executor/tests/app_runtime_work_native_update_contract.rs @@ -268,7 +268,7 @@ async fn runtime_task_list_ignores_persisted_running_state_when_thread_is_missin } #[tokio::test] -async fn runtime_task_list_preserves_local_failed_state_when_codex_thread_is_idle() { +async fn runtime_task_list_ignores_persisted_failed_state_when_codex_thread_is_idle() { let _lock = env_lock().await; let executor_home = temp_path("runtime-local-failed-home", "dir"); let _home = EnvGuard::set("WEGENT_EXECUTOR_HOME", &executor_home.display().to_string()); @@ -321,11 +321,11 @@ async fn runtime_task_list_preserves_local_failed_state_when_codex_thread_is_idl .find(|task| task["taskId"] == "local-failed-running-rollout") .unwrap(); - assert_eq!(locally_failed["status"], "failed"); + assert_eq!(locally_failed["status"], "active"); assert_eq!(locally_failed["running"], false); assert_eq!(locally_failed["continuable"], true); assert_eq!(locally_failed["threadStatus"], "idle"); - assert_eq!(locally_failed["turnStatus"], "failed"); + assert!(locally_failed["turnStatus"].is_null()); } fn write_fake_codex(log_path: &Path) -> PathBuf { diff --git a/executor/tests/app_runtime_work_workspace_contract.rs b/executor/tests/app_runtime_work_workspace_contract.rs index 3b5af5c52e..4010bb0402 100644 --- a/executor/tests/app_runtime_work_workspace_contract.rs +++ b/executor/tests/app_runtime_work_workspace_contract.rs @@ -290,7 +290,11 @@ async fn runtime_local_project_rpc_persists_multiple_roots() { "runtime": "codex", "projectKey": "product", "name": "Product", - "roots": [first_root, second_root] + "roots": [first_root, second_root], + "defaultProjectSpace": { + "projectStore": "backend", + "projectId": "space-1" + } } })) .await @@ -299,8 +303,16 @@ async fn runtime_local_project_rpc_persists_multiple_roots() { assert_eq!(response["accepted"], true); assert_eq!(response["deviceId"], "device-1"); assert_eq!(response["projectKey"], "product"); + assert_eq!( + response["defaultProjectSpace"], + json!({"projectStore": "backend", "projectId": "space-1"}) + ); let state = read_json_file(&codex_home.join(".codex-global-state.json")); assert_eq!(state["local-projects"]["product"]["name"], "Product"); + assert_eq!( + state["local-projects"]["product"]["defaultProjectSpace"], + json!({"projectStore": "backend", "projectId": "space-1"}) + ); assert_eq!( state["project-writable-roots"]["product"] .as_array() @@ -309,6 +321,24 @@ async fn runtime_local_project_rpc_persists_multiple_roots() { 2 ); assert_eq!(state["unknown"], true); + + handler + .handle_runtime_rpc(json!({ + "method": "runtime.projects.upsert_local", + "payload": { + "runtime": "codex", + "projectKey": "product", + "name": "Product", + "roots": [first_root, second_root], + "defaultProjectSpace": null + } + })) + .await + .expect("clearing the default project space should succeed"); + let state = read_json_file(&codex_home.join(".codex-global-state.json")); + assert!(state["local-projects"]["product"] + .get("defaultProjectSpace") + .is_none()); } #[tokio::test] diff --git a/wework/e2e/desktop/task-flow.e2e.mjs b/wework/e2e/desktop/task-flow.e2e.mjs index 79f929c444..ba9d48844c 100644 --- a/wework/e2e/desktop/task-flow.e2e.mjs +++ b/wework/e2e/desktop/task-flow.e2e.mjs @@ -2774,14 +2774,14 @@ async function verifyRunningFollowUpFork({ text: RUNNING_FORK_COMPLETION_TEXT, timeoutMs: DEFAULT_STEP_TIMEOUT_MS, }) - const completedRuntimeIndex = JSON.parse( + const settledRuntimeIndex = JSON.parse( await readFile(join(executorHome, 'runtime-work', 'index.json'), 'utf8') ) const sourceTaskId = sourceTaskRowTestId.replace('runtime-local-task-row-', '') assert.equal( - completedRuntimeIndex.tasks[sourceTaskId]?.turn_status, - 'completed', - 'The source follow-up was interrupted instead of completing after the fork' + Object.hasOwn(settledRuntimeIndex.tasks[sourceTaskId] ?? {}, 'turn_status'), + false, + 'The completed source follow-up leaked process-local turn status into the runtime index' ) } @@ -3472,6 +3472,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}-`)) } @@ -9457,15 +9536,15 @@ class DesktopE2EServer { Connection: 'keep-alive', 'Content-Type': 'text/event-stream; charset=utf-8', }) - response.write( + response.write(createSse([responseCreated(responseId)])) + this.resolveSupervisorCorrectionStarted() + await this.supervisorCorrectionRelease + response.end( createSse([ - responseCreated(responseId), assistantMessage(SUPERVISOR_CORRECTION_COMPLETION_TEXT), + responseCompleted(responseId), ]) ) - this.resolveSupervisorCorrectionStarted() - await this.supervisorCorrectionRelease - response.end(createSse([responseCompleted(responseId)])) return } assert.ok(requestText.includes(SUPERVISOR_PROMPT), 'The supervisor task prompt was lost') @@ -12649,6 +12728,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 }) @@ -12854,6 +12939,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..28ca4c1f85 100644 --- a/wework/src/api/deliveries.ts +++ b/wework/src/api/deliveries.ts @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api/core' import type { HttpClient } from './http' import type { RuntimeTaskAddress } from '@/types/api' + import { openLocalFile } from '@/lib/local-terminal' import { isTauriRuntime } from '@/lib/runtime-environment' @@ -174,17 +175,6 @@ export interface ProjectDeliveryFile { delivered_at: string } -export interface CloudProjectLocalBinding { - id: string - cloud_project_id: CloudProjectId - local_project_id: number - user_id: number - device_id: string | null - is_default: boolean - created_at: string - updated_at: string -} - export interface CloudProjectMember { id: number user_id: number @@ -216,7 +206,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,15 +433,35 @@ 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) }, unbindTask(itemId: string, task: RuntimeTaskAddress): Promise { return client.delete(`/v1/loop-items/${encodeURIComponent(itemId)}/tasks`, task) }, - listLocalBindings(projectId: CloudProjectIdInput): Promise { - return client.get(`/v1/cloud-projects/${projectId}/local-bindings`) - }, listCloudProjectMembers(projectId: CloudProjectIdInput): Promise { return client.get(`/v1/cloud-projects/${projectId}/members`) }, @@ -456,12 +490,6 @@ export function createDeliveryApi(client: HttpClient) { ): Promise<{ users: CloudUserSearchItem[]; total: number }> { return client.get(`/users/search?q=${encodeURIComponent(query)}&limit=20`) }, - addLocalBinding( - projectId: CloudProjectIdInput, - data: { local_project_id: number; device_id?: string; is_default?: boolean } - ): Promise { - return client.post(`/v1/cloud-projects/${projectId}/local-bindings`, data) - }, 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..bba7ac60aa 100644 --- a/wework/src/api/local/localDelivery.test.ts +++ b/wework/src/api/local/localDelivery.test.ts @@ -324,6 +324,136 @@ describe('local delivery API', () => { }) }) + 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..7d1d1cd2df 100644 --- a/wework/src/api/local/localDelivery.ts +++ b/wework/src/api/local/localDelivery.ts @@ -1,16 +1,17 @@ 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 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,7 +305,7 @@ export function createLocalDeliveryApi( request: LocalRequest ): NonNullable { const taskProjects = new Map() - + const trackProjectTaskOnce = createProjectTaskTrackingSingleFlight() function rememberTasks(projectId: CloudProjectId, records: LocalLoopItemRecord[]) { for (const record of records) taskProjects.set(record.id, projectId) } @@ -525,6 +526,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, @@ -562,13 +616,11 @@ export function createLocalDeliveryApi( loop_item: loopItem, } }, - listLocalBindings: async () => [], 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 listCloudFiles(projectId: CloudProjectId) { const records = await request('files.list', { project_id: projectId, diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index 8b451ac003..03cd08cc75 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -25,6 +25,7 @@ import type { RuntimeModelPrepareRequest, RuntimeLocalProjectUpsertRequest, RuntimeLocalProjectUpsertResponse, + RuntimeProjectSpaceRef, RuntimeGoalClearRequest, RuntimeGoalClearResponse, RuntimeGoalGetRequest, @@ -1841,6 +1842,15 @@ function adaptRuntimeWorkListResponse( const projectSource = stringValue(workspace.projectSource) ?? stringValue(workspace.project_source) ?? 'legacy_root' const projectPinnedOrder = workspace.projectPinnedOrder ?? workspace.project_pinned_order + const rawDefaultProjectSpace = recordValue( + workspace.defaultProjectSpace ?? workspace.default_project_space + ) + const defaultProjectStore = stringValue(rawDefaultProjectSpace.projectStore) + const defaultProjectId = stringValue(rawDefaultProjectSpace.projectId) + const defaultProjectSpace: RuntimeProjectSpaceRef | null = + (defaultProjectStore === 'local' || defaultProjectStore === 'backend') && defaultProjectId + ? { projectStore: defaultProjectStore, projectId: defaultProjectId } + : null const projectWork: RuntimeWorkListResponse['projects'][number] = { project: { key: projectKey, @@ -1863,6 +1873,7 @@ function adaptRuntimeWorkListResponse( appearance: (workspace.projectAppearance ?? workspace.project_appearance ?? null) as | RuntimeWorkListResponse['projects'][number]['project']['appearance'] | null, + ...(defaultProjectSpace ? { defaultProjectSpace } : {}), }, deviceWorkspaces: [deviceWorkspace], totalTasks: tasks.length, 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 6986ed2015..bfe276e7ab 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 { ProjectSpaceApi } from '@/features/todo/projectSpaceSelection' const experimentalFeatures = vi.hoisted(() => ({ enabled: true })) @@ -3345,6 +3346,56 @@ 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: [] }), + } as unknown as ProjectSpaceApi + + 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, + }, + projectSpaceApis: [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 71c6487ef8..420c756ea9 100644 --- a/wework/src/components/layout/DesktopSidebar.tsx +++ b/wework/src/components/layout/DesktopSidebar.tsx @@ -100,6 +100,7 @@ import type { RuntimeIMNotificationSettingsResponse, RuntimeProjectWork, RuntimeProjectAppearanceRequest, + RuntimeProjectSpaceRef, RuntimeProjectPinRequest, RuntimeProjectReorderRequest, RuntimeProjectTaskReorderRequest, @@ -158,6 +159,7 @@ import { } from '@/features/workbench/runtimeSidebarDiagnostics' import { formatRelativeSidebarTime, useSidebarRelativeTimeRefresh } from './runtimeSidebarTime' import { useResizableSidebar } from './useResizableSidebar' +import type { ProjectSpaceApi } from '@/features/todo/projectSpaceSelection' interface DesktopSidebarProps { user: UserProfile | null @@ -240,6 +242,7 @@ interface DesktopSidebarProps { projectKey: string name: string roots: string[] + defaultProjectSpace: RuntimeProjectSpaceRef | null }) => Promise onRemoveProject: (projectId: number) => Promise onReorderRuntimeProjects?: (data: RuntimeProjectReorderRequest) => Promise @@ -250,6 +253,7 @@ interface DesktopSidebarProps { onGetDeviceHomeDirectory: (deviceId: string) => Promise onListDeviceDirectories: (deviceId: string, path: string) => Promise onCreateDeviceDirectory: (deviceId: string, path: string) => Promise + projectSpaceApis?: ProjectSpaceApi[] onOpenSettings: (options?: OpenSettingsOptions) => void onLogout: () => void } @@ -2583,6 +2587,7 @@ export function DesktopSidebar({ onGetDeviceHomeDirectory, onListDeviceDirectories, onCreateDeviceDirectory, + projectSpaceApis, onOpenSettings, onLogout, collapsed = false, @@ -4132,6 +4137,7 @@ export function DesktopSidebar({ onGetDeviceHomeDirectory={onGetDeviceHomeDirectory} onListDeviceDirectories={onListDeviceDirectories} onCreateDeviceDirectory={onCreateDeviceDirectory} + projectSpaceApis={experimentalFeaturesEnabled ? projectSpaceApis : undefined} onClose={() => setEditingLocalProject(null)} onSave={data => onUpdateLocalRuntimeProject diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index fa27a4f692..aa66ec1f9b 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 { projectSpaceApis } from '@/features/todo/projectSpaceSelection' import { WorkbenchBackground } from '@/features/appearance' import { isTauriRuntime } from '@/lib/runtime-environment' import { useResizableSidebar } from './useResizableSidebar' @@ -118,6 +119,7 @@ export function DesktopWorkbenchLayout({ routeActive = true }: DesktopWorkbenchL () => resolveLocalTodoProjects(state.projects, state.runtimeWork), [state.projects, state.runtimeWork] ) + const availableProjectSpaceApis = useMemo(() => projectSpaceApis(services), [services]) const workspaceTabs = useOptionalWorkspaceTabs() const initialPath = stripAppBasePath(window.location.pathname) const [currentPath, setCurrentPath] = useState(initialPath) @@ -622,6 +624,7 @@ export function DesktopWorkbenchLayout({ routeActive = true }: DesktopWorkbenchL onGetDeviceHomeDirectory={onGetDeviceHomeDirectory} onListDeviceDirectories={onListDeviceDirectories} onCreateDeviceDirectory={onCreateDeviceDirectory} + projectSpaceApis={availableProjectSpaceApis} 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 ddf101f6b0..8671391d12 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' @@ -12,13 +19,19 @@ import { getPopoutComposerPlaceholder } from '@/features/workbench/popoutWorkspa import { DeliveryDialog } from '@/features/delivery/DeliveryDialog' import type { CloudLoopItem, CloudProject } from '@/api/deliveries' import { TodoBindingPicker } from '@/features/todo/TodoBindingPicker' +import { + findProjectSpaceContextForTask, + projectSpaceApis, + projectSpaceKey, + projectSpaceRef, +} from '@/features/todo/projectSpaceSelection' 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, @@ -46,6 +59,7 @@ import type { WorkspaceTarget, } from '@/types/workspace-files' import { cn } from '@/lib/utils' +import { runtimeProjectUiId } from '@/lib/runtime-project' import { defaultAppearance, getWorkbenchBackground, @@ -203,10 +217,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 @@ -534,6 +585,10 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const { t: tChat } = useTranslation('chat') const currentRuntimeTask = pane.currentRuntimeTask const currentProject = pane.currentProject + const currentRuntimeProject = state.runtimeWork?.projects.find( + projectWork => currentProject && runtimeProjectUiId(projectWork.project) === currentProject.id + )?.project + const defaultProjectSpace = currentRuntimeProject?.defaultProjectSpace ?? null const paneKey = getWorkbenchPaneKey(pane) const [turnNavigationPortalTarget, setTurnNavigationPortalTarget] = useState(null) @@ -547,6 +602,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ }, []) const paneSession = useWorkbenchPaneSession({ currentRuntimeTask }) const sendPaneInput = paneSession.send + const todoBindingApis = useMemo(() => projectSpaceApis(services), [services]) + const pendingAutoJoinResolutionRef = useRef(null) const [deliveryItem, setDeliveryItem] = useState | null>(null) const [boundCloudProject, setBoundCloudProject] = useState(null) const [boundCloudItem, setBoundCloudItem] = useState(null) @@ -569,6 +626,10 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ ) const [todoBindingError, setTodoBindingError] = useState(null) const [cloudProjects, setCloudProjects] = useState([]) + const [defaultProjectOptionKey, setDefaultProjectOptionKey] = useState(null) + const [dismissedDefaultCloudProjectKey, setDismissedDefaultCloudProjectKey] = useState< + string | null + >(null) const [cloudActionNotice, setCloudActionNotice] = useState(null) const [cloudMentionState, setCloudMentionState] = useState<{ todoId: string @@ -590,38 +651,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) }, @@ -650,14 +687,40 @@ 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 submissionProject = currentRuntimeTask ? null : pendingCloudProject + const submissionItem = + submissionProject?.id === pendingCloudProject?.id ? pendingTodoItem : null + if (!currentRuntimeTask) { + setPendingCloudContext(submissionProject, submissionItem) + pendingAutoJoinResolutionRef.current = + !submissionProject && + Boolean(defaultProjectSpace) && + dismissedDefaultCloudProjectKey !== defaultCloudProjectSelectionKey && + projectSpaceApis(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) { @@ -666,7 +729,20 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ }, }) }, - [cloudAdditionalContext, currentRuntimeTask, pendingSupervisorConfig, sendPaneInput] + [ + cloudAdditionalContext, + currentRuntimeTask, + defaultProjectSpace, + defaultCloudProjectSelectionKey, + dismissedDefaultCloudProjectKey, + paneSession.input, + pendingCloudProject, + pendingTodoItem, + pendingSupervisorConfig, + sendPaneInput, + services, + setPendingCloudContext, + ] ) const setTaskSupervisor = useCallback( @@ -723,6 +799,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) { @@ -736,9 +820,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ active = false } } - if (services?.deliveryApi) { - void services.deliveryApi - .findCloudContextForTask(currentRuntimeTask) + const contextApis = projectSpaceApis(services) + if (contextApis.length > 0) { + void findProjectSpaceContextForTask(contextApis, currentRuntimeTask) .then(context => { if (!active) return setBoundCloudProject(context.project) @@ -776,26 +860,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) }) @@ -814,8 +914,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ currentRuntimeTask, pendingCloudProject, pendingTodoItem, - runtimeTaskTitle, - services?.deliveryApi, + projectSpaceApiFor, setPendingCloudContext, t, ]) @@ -909,31 +1008,90 @@ 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(() => { let active = true - const api = services?.deliveryApi - if (!api) { + const apis = projectSpaceApis(services) + if (!apis.length) { queueMicrotask(() => { - if (active) setCloudProjects([]) + if (active) { + setCloudProjects([]) + setDefaultProjectOptionKey(null) + } }) return () => { active = false } } - void api - .listCloudProjects() - .then(result => { - if (active) setCloudProjects(result.items) + void Promise.allSettled( + apis.map(async api => { + const result = await api.listCloudProjects() + return result.items + }) + ) + .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.id === candidate.id && other.project_store === candidate.project_store + ) === index + ) + const defaultProject = defaultProjectSpace + ? uniqueProjects.find( + project => + projectSpaceKey(projectSpaceRef(project)) === projectSpaceKey(defaultProjectSpace) + ) + : null + setCloudProjects(uniqueProjects) + setDefaultProjectOptionKey( + defaultProject ? projectSpaceKey(projectSpaceRef(defaultProject)) : null + ) + 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, + item: null, + target: pendingAutoJoin?.target ?? null, + description: pendingAutoJoin?.description ?? '', + } + pendingAutoJoinResolutionRef.current = null + setPendingCloudProject(defaultProject) + setPendingTodoItemState(null) + } else if (!defaultProject && pendingAutoJoin) { + pendingAutoJoinResolutionRef.current = null + } }) .catch(() => { - if (active) setCloudProjects([]) + if (active) { + setCloudProjects([]) + setDefaultProjectOptionKey(null) + } }) return () => { active = false } - }, [services?.deliveryApi]) + }, [ + currentRuntimeTask, + defaultProjectSpace, + defaultCloudProjectSelectionKey, + dismissedDefaultCloudProjectKey, + pendingCloudProject, + services, + setPendingCloudContext, + ]) const cloudProjectMentionCandidates = useMemo( () => cloudProjects.map(project => { @@ -943,6 +1101,10 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ key: `cloud-project-space:${project.id}`, title: project.name, description: project.description || project.project_key || undefined, + statusLabel: + projectSpaceKey(projectSpaceRef(project)) === defaultProjectOptionKey + ? 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, @@ -959,7 +1121,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ project, } }), - [cloudProjects, t] + [cloudProjects, defaultProjectOptionKey, t] ) const bindComposerCloudProject = useCallback( (project: CloudProject, notice: string) => { @@ -968,7 +1130,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) @@ -985,10 +1147,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 }) @@ -996,6 +1159,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 && @@ -2617,6 +2800,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ experimentalFeaturesEnabled && Boolean(services?.deliveryApi) } onSelectCloudProject={handleSelectCloudProject} + selectedCloudProjectId={composerCloudProject?.id} + toolbarLeadingContext={pendingProjectSpaceContext} isStreaming={paneIsBusy} onPause={pauseCurrentResponse} onCompactContext={compactCurrentContext} @@ -2778,6 +2963,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ experimentalFeaturesEnabled && Boolean(services?.deliveryApi) } onSelectCloudProject={handleSelectCloudProject} + selectedCloudProjectId={composerCloudProject?.id} + toolbarLeadingContext={pendingProjectSpaceContext} isStreaming={paneIsBusy} onPause={pauseCurrentResponse} onCompactContext={compactCurrentContext} @@ -3037,9 +3224,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ onDelivered={() => void finishLocalDelivery()} /> )} - {todoBindingPickerOpen && services?.deliveryApi && ( + {todoBindingPickerOpen && todoBindingApis.length > 0 && ( ({ @@ -65,6 +66,7 @@ describe('LocalProjectEditDialog', () => { projectKey: 'multi-root', name: 'Platform', roots: ['/repo/api', '/repo/docs'], + defaultProjectSpace: null, }) }) @@ -88,4 +90,61 @@ 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 onSave = vi.fn().mockResolvedValue(undefined) + 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', + }, + ], + }), + } as unknown as ProjectSpaceApi + + 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(onSave).toHaveBeenCalledWith({ + deviceId: 'local-device', + projectKey: 'multi-root', + name: 'Product', + roots: ['/repo/web', '/repo/api'], + defaultProjectSpace: null, + }) + }) }) diff --git a/wework/src/components/projects/LocalProjectEditDialog.tsx b/wework/src/components/projects/LocalProjectEditDialog.tsx index 7d49cb397f..60611a072e 100644 --- a/wework/src/components/projects/LocalProjectEditDialog.tsx +++ b/wework/src/components/projects/LocalProjectEditDialog.tsx @@ -1,11 +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 { + loadProjectSpaceOptions, + projectSpaceKey, + projectSpaceRef, + type ProjectSpaceApi, + type ProjectSpaceOption, +} from '@/features/todo/projectSpaceSelection' import { useEscapeKey } from '@/hooks/useEscapeKey' import { useTranslation } from '@/hooks/useTranslation' import { openNativeProjectDirectoryPickers } from '@/lib/native-directory-picker' -import type { DeviceInfo, RuntimeProjectWork } from '@/types/api' +import type { DeviceInfo, RuntimeProjectSpaceRef, RuntimeProjectWork } from '@/types/api' import { DeviceFolderPicker } from './DeviceFolderPicker' interface LocalProjectEditDialogProps { @@ -15,12 +22,14 @@ interface LocalProjectEditDialogProps { onGetDeviceHomeDirectory: (deviceId: string) => Promise onListDeviceDirectories: (deviceId: string, path: string) => Promise onCreateDeviceDirectory: (deviceId: string, path: string) => Promise + projectSpaceApis?: ProjectSpaceApi[] onClose: () => void onSave: (data: { deviceId: string projectKey: string name: string roots: string[] + defaultProjectSpace: RuntimeProjectSpaceRef | null }) => Promise onDelete: () => void } @@ -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,38 @@ 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(() => { + const ref = projectWork.project.defaultProjectSpace + return ref ? projectSpaceKey(ref) : null + }) + const [projectSpacesLoading, setProjectSpacesLoading] = useState(projectSpaceApis.length > 0) const deviceId = projectWork.project.stateDeviceId?.trim() || projectWork.deviceWorkspaces[0]?.deviceId.trim() || '' - useEscapeKey(onClose, !submitting) + useEffect(() => { + if (projectSpaceApis.length === 0) return + let active = true + void loadProjectSpaceOptions(projectSpaceApis) + .then(options => { + if (!active) return + setProjectSpaceOptions(options) + }) + .catch(loadError => { + if (!active) return + setError(loadError instanceof Error ? loadError.message : String(loadError)) + }) + .finally(() => { + if (active) setProjectSpacesLoading(false) + }) + return () => { + active = false + } + }, [projectSpaceApis]) + const addFolders = async () => { if (!shouldUseNativeProjectDirectoryPicker()) { setShowFolderPicker(true) @@ -113,11 +150,23 @@ function LocalProjectEditDialogContent({ setSubmitting(true) setError(null) try { + const selectedProjectSpace = projectSpaceOptions.find( + option => option.key === autoJoinProjectSpaceKey + ) + const existingDefaultProjectSpace = projectWork.project.defaultProjectSpace ?? null + const existingDefaultKey = existingDefaultProjectSpace + ? projectSpaceKey(existingDefaultProjectSpace) + : null await onSave({ deviceId, projectKey: projectWork.project.key, name: trimmedName, roots, + defaultProjectSpace: selectedProjectSpace + ? projectSpaceRef(selectedProjectSpace.project) + : autoJoinProjectSpaceKey === existingDefaultKey + ? existingDefaultProjectSpace + : null, }) onClose() } catch (saveError) { @@ -237,6 +286,43 @@ function LocalProjectEditDialogContent({ )}
+ {projectSpaceApis.length > 0 && ( + <> +

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

+

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

+ + + )} + {error &&

{error}

}