From b4b3466098364cadc1b3aa45a0807142bd0508df Mon Sep 17 00:00:00 2001 From: sifang Date: Thu, 25 Jun 2026 12:33:19 +0800 Subject: [PATCH 1/2] feat(wework): add runtime project archive workflow --- backend/app/api/endpoints/runtime_work.py | 196 ++++ backend/app/schemas/runtime_work.py | 142 ++- backend/app/services/runtime_work_service.py | 921 +++++++++++++++++- .../api/endpoints/test_runtime_work_api.py | 203 +++- .../services/test_runtime_work_service.py | 570 ++++++++++- executor/agents/codex/config_builder.py | 3 + executor/runtime_work/codex_discovery.py | 498 +++++++++- executor/runtime_work/codex_global_state.py | 421 ++++++++ executor/runtime_work/local_task_store.py | 23 + executor/runtime_work/rpc_handler.py | 600 +++++++++++- .../tests/agents/test_codex_config_builder.py | 3 + .../runtime_work/test_local_task_store.py | 784 ++++++++++++++- wework/src/api/runtimeWork.test.ts | 68 +- wework/src/api/runtimeWork.ts | 48 + wework/src/components/chat/ChatInput.test.tsx | 2 +- .../chat/composer/ProjectWorkBar.test.tsx | 10 +- wework/src/components/common/ActionMenu.tsx | 16 +- .../src/components/common/TextInputDialog.tsx | 34 +- .../components/layout/DesktopSidebar.test.tsx | 548 ++++++++--- .../src/components/layout/DesktopSidebar.tsx | 824 ++++++++++------ .../layout/DesktopWorkbenchLayout.test.tsx | 55 +- .../layout/DesktopWorkbenchLayout.tsx | 29 +- wework/src/components/layout/MobileDrawer.tsx | 15 +- .../layout/MobileWorkbenchLayout.test.tsx | 10 +- .../layout/MobileWorkbenchLayout.tsx | 6 +- .../components/layout/TaskForkDialog.test.tsx | 12 +- .../projects/DeviceFolderPicker.tsx | 25 +- .../projects/ProjectFolderIcon.test.tsx | 12 + .../components/projects/ProjectFolderIcon.tsx | 39 +- .../projects/StandaloneProjectDialogs.tsx | 18 +- .../ArchivedConversationsSettingsPage.tsx | 432 ++++++++ .../settings/ConnectionsSettingsPage.tsx | 17 +- .../settings/MobileSettingsPage.test.tsx | 3 + .../settings/MobileSettingsPage.tsx | 50 +- .../workbench/WorkbenchProvider.test.tsx | 124 ++- .../features/workbench/WorkbenchProvider.tsx | 263 ++++- .../workbench/workbenchReducer.test.ts | 6 +- .../features/workbench/workbenchReducer.ts | 4 +- wework/src/i18n/locales/en/common.json | 43 + wework/src/i18n/locales/zh-CN/common.json | 45 +- .../lib/project-workspace-selection.test.ts | 4 +- wework/src/lib/workspace-target.test.ts | 2 +- wework/src/lib/workspace-target.ts | 2 +- wework/src/pages/PluginManagementPage.tsx | 9 +- wework/src/pages/PluginsPage.tsx | 9 +- wework/src/pages/WorkbenchPage.tsx | 8 + wework/src/types/api.ts | 82 +- 47 files changed, 6540 insertions(+), 698 deletions(-) create mode 100644 executor/runtime_work/codex_global_state.py create mode 100644 wework/src/components/settings/ArchivedConversationsSettingsPage.tsx diff --git a/backend/app/api/endpoints/runtime_work.py b/backend/app/api/endpoints/runtime_work.py index 237d94883c..5f9b86a963 100644 --- a/backend/app/api/endpoints/runtime_work.py +++ b/backend/app/api/endpoints/runtime_work.py @@ -11,6 +11,8 @@ from app.core.security import get_current_user from app.models.user import User from app.schemas.runtime_work import ( + ArchivedConversationsListRequest, + ArchivedConversationsListResponse, BindRuntimeTaskIMSessionsRequest, BindRuntimeTaskIMSessionsResponse, DeviceWorkspacePrepareRequest, @@ -18,6 +20,9 @@ DeviceWorkspaceResponse, DeviceWorkspaceUpsert, RuntimeGlobalIMNotificationUpdateRequest, + RuntimeArchiveProjectConversationsRequest, + RuntimeArchivedConversationBulkRequest, + RuntimeArchivedConversationBulkResponse, RuntimeIMNotificationSettingsResponse, RuntimeSendRequest, RuntimeSendResponse, @@ -29,11 +34,14 @@ RuntimeTaskForkResponse, RuntimeTaskIMNotificationSubscriptionRequest, RuntimeTaskIMNotificationSubscriptionResponse, + RuntimeTaskRenameRequest, RuntimeTranscriptRequest, RuntimeTranscriptResponse, RuntimeWorkListResponse, RuntimeWorkspaceOpenRequest, RuntimeWorkspaceOpenResponse, + RuntimeWorkspaceRemoveRequest, + RuntimeWorkspaceRenameRequest, ) from app.services import runtime_work_service from shared.telemetry.decorators import ( @@ -327,6 +335,156 @@ async def archive_runtime_task_endpoint( ) +@router.post( + "/rename", + response_model=RuntimeTaskArchiveResponse, + response_model_by_alias=True, +) +async def rename_runtime_task_endpoint( + request: RuntimeTaskRenameRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Rename one device-local runtime conversation.""" + + return await runtime_work_service.rename_runtime_task( + db=db, + user_id=current_user.id, + request=request, + ) + + +@router.post( + "/archived-conversations/list", + response_model=ArchivedConversationsListResponse, + response_model_by_alias=True, +) +async def list_archived_conversations_endpoint( + request: ArchivedConversationsListRequest | None = Body(default=None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List archived device-local conversations.""" + + return await runtime_work_service.list_archived_conversations( + db=db, + user_id=current_user.id, + request=request or ArchivedConversationsListRequest(), + ) + + +@router.post( + "/archived-conversations/archive", + response_model=RuntimeTaskArchiveResponse, + response_model_by_alias=True, +) +async def archive_conversation_endpoint( + address: RuntimeTaskAddress, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Archive one device-local conversation.""" + + return await runtime_work_service.archive_runtime_task( + db=db, + user_id=current_user.id, + address=address, + ) + + +@router.post( + "/archived-conversations/archive-project", + response_model=RuntimeArchivedConversationBulkResponse, + response_model_by_alias=True, +) +async def archive_project_conversations_endpoint( + request: RuntimeArchiveProjectConversationsRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Archive active conversations under one runtime project.""" + + return await runtime_work_service.archive_project_conversations( + db=db, + user_id=current_user.id, + request=request, + ) + + +@router.post( + "/archived-conversations/archive-all", + response_model=RuntimeArchivedConversationBulkResponse, + response_model_by_alias=True, +) +async def archive_all_conversations_endpoint( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Archive all active device-local conversations.""" + + return await runtime_work_service.archive_all_conversations( + db=db, + user_id=current_user.id, + ) + + +@router.post( + "/archived-conversations/unarchive", + response_model=RuntimeTaskArchiveResponse, + response_model_by_alias=True, +) +async def unarchive_conversation_endpoint( + address: RuntimeTaskAddress, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Unarchive one device-local conversation.""" + + return await runtime_work_service.unarchive_conversation( + db=db, + user_id=current_user.id, + address=address, + ) + + +@router.post( + "/archived-conversations/delete", + response_model=RuntimeTaskArchiveResponse, + response_model_by_alias=True, +) +async def delete_archived_conversation_endpoint( + address: RuntimeTaskAddress, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete one archived device-local conversation.""" + + return await runtime_work_service.delete_archived_conversation( + db=db, + user_id=current_user.id, + address=address, + ) + + +@router.post( + "/archived-conversations/delete-bulk", + response_model=RuntimeArchivedConversationBulkResponse, + response_model_by_alias=True, +) +async def delete_archived_conversations_bulk_endpoint( + request: RuntimeArchivedConversationBulkRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete multiple archived device-local conversations.""" + + return await runtime_work_service.delete_archived_conversations_bulk( + db=db, + user_id=current_user.id, + request=request, + ) + + @router.post( "/create", response_model=RuntimeTaskCreateResponse, @@ -365,6 +523,44 @@ async def open_runtime_workspace_endpoint( ) +@router.post( + "/workspaces/rename", + response_model=RuntimeWorkspaceOpenResponse, + response_model_by_alias=True, +) +async def rename_runtime_workspace_endpoint( + request: RuntimeWorkspaceRenameRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Rename a native runtime workspace project without touching conversations.""" + + return await runtime_work_service.rename_runtime_workspace( + db=db, + user_id=current_user.id, + request=request, + ) + + +@router.post( + "/workspaces/remove", + response_model=RuntimeWorkspaceOpenResponse, + response_model_by_alias=True, +) +async def remove_runtime_workspace_endpoint( + request: RuntimeWorkspaceRemoveRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove a native runtime workspace project without deleting conversations.""" + + return await runtime_work_service.remove_runtime_workspace( + db=db, + user_id=current_user.id, + request=request, + ) + + @router.post( "/fork", response_model=RuntimeTaskForkResponse, diff --git a/backend/app/schemas/runtime_work.py b/backend/app/schemas/runtime_work.py index afecf232d3..f67d9189e5 100644 --- a/backend/app/schemas/runtime_work.py +++ b/backend/app/schemas/runtime_work.py @@ -12,6 +12,7 @@ RuntimeName = Literal["codex", "claude_code"] LocalTaskStatus = Literal["active", "archived"] RuntimeWorkspaceKind = Literal["workspace", "worktree", "chat"] +RuntimeWorkspaceSource = Literal["local", "remote"] class RuntimeTaskAddress(BaseModel): @@ -155,6 +156,7 @@ class DeviceWorkspacePrepareResponse(BaseModel): class RuntimeProjectRef(BaseModel): """Runtime workspace identity used by runtime work lists.""" + id: Optional[int] = None key: str name: str description: str = "" @@ -183,6 +185,11 @@ class RuntimeDeviceWorkspace(BaseModel): alias="repoRootFingerprint", ) label: Optional[str] = None + workspace_source: Optional[RuntimeWorkspaceSource] = Field( + default=None, + alias="workspaceSource", + ) + remote_host_id: Optional[str] = Field(default=None, alias="remoteHostId") mapped: bool available: bool error: Optional[str] = None @@ -210,13 +217,112 @@ class RuntimeWorkListResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) projects: list[RuntimeProjectWork] = Field(default_factory=list) - unmapped_device_workspaces: list[RuntimeDeviceWorkspace] = Field( + chats: list[RuntimeDeviceWorkspace] = Field( default_factory=list, - alias="unmappedDeviceWorkspaces", + alias="chats", ) total_local_tasks: int = Field(..., alias="totalLocalTasks") +class ArchivedConversationsListRequest(BaseModel): + """Filter archived conversations stored on the user's devices.""" + + model_config = ConfigDict(populate_by_name=True) + + device_id: Optional[str] = Field(default=None, alias="deviceId") + workspace_path: Optional[str] = Field(default=None, alias="workspacePath") + project_id: Optional[int] = Field(default=None, alias="projectId", ge=1) + runtime_project_key: Optional[str] = Field( + default=None, + alias="runtimeProjectKey", + ) + search: Optional[str] = None + source: Literal["all", "local", "cloud"] = "all" + sort: Literal["updated", "created", "alphabetical"] = "updated" + + +class ArchivedConversationItem(BaseModel): + """Archived conversation metadata normalized by Backend.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + local_task_id: str = Field(..., alias="localTaskId") + title: str + project_id: Optional[int] = Field(default=None, alias="projectId") + project_key: Optional[str] = Field(default=None, alias="projectKey") + project_name: Optional[str] = Field(default=None, alias="projectName") + workspace_path: str = Field(..., alias="workspacePath") + workspace_kind: RuntimeWorkspaceKind = Field( + default="workspace", + alias="workspaceKind", + ) + device_id: str = Field(..., alias="deviceId") + device_name: Optional[str] = Field(default=None, alias="deviceName") + device_address: Optional[str] = Field(default=None, alias="deviceAddress") + source: Literal["local", "cloud"] = "local" + runtime: Optional[RuntimeName] = None + created_at: Optional[str] = Field(default=None, alias="createdAt") + updated_at: Optional[str] = Field(default=None, alias="updatedAt") + + +class ArchivedConversationProjectGroup(BaseModel): + """Archived conversation counts grouped by project/workspace.""" + + model_config = ConfigDict(populate_by_name=True) + + project_id: Optional[int] = Field(default=None, alias="projectId") + project_key: Optional[str] = Field(default=None, alias="projectKey") + project_name: str = Field(..., alias="projectName") + count: int + + +class ArchivedConversationsListResponse(BaseModel): + """Archived conversations plus project-group counts.""" + + model_config = ConfigDict(populate_by_name=True) + + items: list[ArchivedConversationItem] = Field(default_factory=list) + project_groups: list[ArchivedConversationProjectGroup] = Field( + default_factory=list, + alias="projectGroups", + ) + total: int + + +class RuntimeArchiveProjectConversationsRequest(BaseModel): + """Archive all active conversations under one runtime project.""" + + model_config = ConfigDict(populate_by_name=True) + + project_id: Optional[int] = Field(default=None, alias="projectId", ge=1) + runtime_project_key: Optional[str] = Field( + default=None, + alias="runtimeProjectKey", + ) + + +class RuntimeArchivedConversationBulkRequest(BaseModel): + """Bulk operation over device-local runtime task addresses.""" + + model_config = ConfigDict(populate_by_name=True) + + items: list[RuntimeTaskAddress] = Field(default_factory=list) + + +class RuntimeArchivedConversationBulkResponse(BaseModel): + """Bulk archive/delete acknowledgement.""" + + model_config = ConfigDict(populate_by_name=True) + + accepted: bool + requested_count: int = Field(..., alias="requestedCount") + accepted_count: int = Field(..., alias="acceptedCount") + deleted_count: Optional[int] = Field(default=None, alias="deletedCount") + results: list[dict[str, Any]] = Field(default_factory=list) + error: Optional[str] = None + + class RuntimeTranscriptResponse(BaseModel): """Transcript returned by the owning local executor.""" @@ -258,6 +364,29 @@ class RuntimeWorkspaceOpenRequest(BaseModel): device_id: str = Field(..., alias="deviceId", min_length=1) workspace_path: str = Field(..., alias="workspacePath", min_length=1) runtime: RuntimeName = "codex" + label: Optional[str] = None + action: Optional[Literal["create", "select"]] = None + + +class RuntimeWorkspaceRenameRequest(BaseModel): + """Request to rename a device-local runtime workspace project.""" + + model_config = ConfigDict(populate_by_name=True) + + device_id: str = Field(..., alias="deviceId", min_length=1) + workspace_path: str = Field(..., alias="workspacePath", min_length=1) + runtime: RuntimeName = "codex" + name: str = Field(..., min_length=1) + + +class RuntimeWorkspaceRemoveRequest(BaseModel): + """Request to remove a device-local runtime workspace project.""" + + model_config = ConfigDict(populate_by_name=True) + + device_id: str = Field(..., alias="deviceId", min_length=1) + workspace_path: str = Field(..., alias="workspacePath", min_length=1) + runtime: RuntimeName = "codex" class RuntimeWorkspaceOpenResponse(BaseModel): @@ -377,6 +506,15 @@ class RuntimeTaskArchiveResponse(BaseModel): error: Optional[str] = None +class RuntimeTaskRenameRequest(BaseModel): + """Request to rename one device-local runtime task.""" + + model_config = ConfigDict(populate_by_name=True) + + address: RuntimeTaskAddress + title: str = Field(..., min_length=1, max_length=120) + + class RuntimeTaskCreateRequest(BaseModel): """Request to create a device-local runtime task without DB Task rows.""" diff --git a/backend/app/services/runtime_work_service.py b/backend/app/services/runtime_work_service.py index 8808620b67..7e86de1fd0 100644 --- a/backend/app/services/runtime_work_service.py +++ b/backend/app/services/runtime_work_service.py @@ -4,6 +4,7 @@ """Service for Project -> Device Workspace -> LocalTask runtime work trees.""" +import asyncio import json import logging import posixpath @@ -29,6 +30,10 @@ from app.models.user import User from app.schemas.project import ProjectConfig from app.schemas.runtime_work import ( + ArchivedConversationItem, + ArchivedConversationProjectGroup, + ArchivedConversationsListRequest, + ArchivedConversationsListResponse, BindRuntimeTaskIMSessionsRequest, BindRuntimeTaskIMSessionsResponse, DeviceWorkspacePrepareRequest, @@ -42,6 +47,9 @@ RuntimeIMNotificationSettingsResponse, RuntimeProjectRef, RuntimeProjectWork, + RuntimeArchiveProjectConversationsRequest, + RuntimeArchivedConversationBulkRequest, + RuntimeArchivedConversationBulkResponse, RuntimeSendRequest, RuntimeSendResponse, RuntimeTaskAddress, @@ -53,11 +61,14 @@ RuntimeTaskIMNotificationSubscription, RuntimeTaskIMNotificationSubscriptionRequest, RuntimeTaskIMNotificationSubscriptionResponse, + RuntimeTaskRenameRequest, RuntimeTranscriptRequest, RuntimeTranscriptResponse, RuntimeWorkListResponse, RuntimeWorkspaceOpenRequest, RuntimeWorkspaceOpenResponse, + RuntimeWorkspaceRemoveRequest, + RuntimeWorkspaceRenameRequest, ) from app.services.device.command_service import execute_configured_device_command from app.services.device.runtime_rpc_service import RuntimeRpcError, runtime_rpc_service @@ -117,6 +128,17 @@ class RuntimeForkWorkspaceTransfer: source_commit: str +@dataclass(frozen=True) +class RuntimeWorkspaceListing: + """Executor workspace listing plus local task summaries.""" + + local_tasks: list[LocalTaskSummary] + order_index: int = 0 + label: Optional[str] = None + workspace_source: Optional[str] = None + remote_host_id: Optional[str] = None + + def normalize_workspace_path(path: str) -> str: """Normalize device paths for stable central mapping keys.""" @@ -186,6 +208,12 @@ async def prepare_device_workspace( action=payload.action, ) ) + await _register_prepared_runtime_workspace( + user_id=user_id, + device_id=payload.device_id, + workspace_path=workspace_path, + project_name=project.name, + ) mapping = upsert_device_workspace( db=db, user_id=user_id, @@ -253,15 +281,17 @@ async def list_runtime_work( user_id=user_id, devices=devices, ) + device_order = _runtime_device_order(devices) projects: list[RuntimeProjectWork] = [] conversations: list[RuntimeDeviceWorkspace] = [] total_local_tasks = 0 - for (device_id, workspace_path), local_tasks in sorted( + for (device_id, workspace_path), workspace_listing in sorted( runtime_workspaces.items(), - key=lambda item: _runtime_workspace_sort_key(item[1]), + key=lambda item: _runtime_workspace_order_key(item, device_order), ): + local_tasks = workspace_listing.local_tasks total_local_tasks += len(local_tasks) device = devices_by_id.get(device_id) workspace_kind_fields = _runtime_workspace_kind_fields_from_tasks( @@ -276,6 +306,9 @@ async def list_runtime_work( deviceStatus=_device_status(device), workspacePath=workspace_path, **workspace_kind_fields, + label=workspace_listing.label, + workspaceSource=workspace_listing.workspace_source, + remoteHostId=workspace_listing.remote_host_id, mapped=True, available=True, localTasks=local_tasks, @@ -283,7 +316,11 @@ async def list_runtime_work( if workspace.workspace_kind == "chat": conversations.append(workspace) continue - project_ref = _runtime_project_ref_from_workspace(device_id, workspace_path) + project_ref = _runtime_project_ref_from_workspace( + device_id, + workspace_path, + label=workspace_listing.label, + ) projects.append( RuntimeProjectWork( project=project_ref, @@ -293,7 +330,7 @@ async def list_runtime_work( return RuntimeWorkListResponse( projects=projects, - unmappedDeviceWorkspaces=conversations, + chats=conversations, totalLocalTasks=total_local_tasks, ) @@ -563,6 +600,230 @@ async def archive_runtime_task( return _runtime_archive_response(result, normalized_address) +async def rename_runtime_task( + *, + db: Session, + user_id: int, + request: RuntimeTaskRenameRequest, +) -> RuntimeTaskArchiveResponse: + """Rename a LocalTask through the owning local executor.""" + + normalized_address = _normalized_address(request.address) + title = request.title.strip() + if not title: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="title is required", + ) + _ensure_owned_device(db, user_id, normalized_address.device_id) + _touch_workspace_mapping(db, user_id, normalized_address) + try: + result = await runtime_rpc_service.call( + user_id=user_id, + device_id=normalized_address.device_id, + method="runtime.tasks.rename", + payload={ + **_runtime_task_address_payload(normalized_address), + "title": title, + }, + timeout_seconds=RUNTIME_TRANSCRIPT_TIMEOUT_SECONDS, + ) + except RuntimeRpcError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + return _runtime_archive_response(result, normalized_address) + + +async def list_archived_conversations( + *, + db: Session, + user_id: int, + request: ArchivedConversationsListRequest, +) -> ArchivedConversationsListResponse: + """List archived conversations from online device-local runtime state.""" + + devices = await device_service.get_all_devices(db, user_id) + if request.device_id: + _ensure_owned_device(db, user_id, request.device_id) + devices = [ + device + for device in devices + if str(device.get("device_id") or "") == request.device_id + ] + + project_lookup = _archived_project_lookup(db, user_id) + items: list[ArchivedConversationItem] = [] + for device in devices: + device_id = str(device.get("device_id") or "") + if not device_id or _device_status(device) not in {"online", "busy"}: + continue + device_source = _archived_device_source(device) + if request.source != "all" and request.source != device_source: + continue + try: + result = await runtime_rpc_service.call( + user_id=user_id, + device_id=device_id, + method="runtime.archived_conversations.list", + payload=_archived_list_payload(request), + timeout_seconds=RUNTIME_LIST_TIMEOUT_SECONDS, + ) + except RuntimeRpcError: + continue + for raw_item in result.get("items", []): + item = _archived_conversation_item( + raw_item, + device_id=device_id, + device_name=_device_name(device, device_id), + device_address=_device_address(device, device_id), + source=device_source, + project_lookup=project_lookup, + ) + if item is not None and _include_archived_item(item, request): + items.append(item) + + items = _sort_archived_items(items, request.sort) + return ArchivedConversationsListResponse( + items=items, + projectGroups=_archived_project_groups(items), + total=len(items), + ) + + +async def archive_project_conversations( + *, + db: Session, + user_id: int, + request: RuntimeArchiveProjectConversationsRequest, +) -> RuntimeArchivedConversationBulkResponse: + """Archive active conversations under one runtime project.""" + + if request.project_id is None and not request.runtime_project_key: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="projectId or runtimeProjectKey is required", + ) + if request.project_id is not None: + _get_active_project(db, user_id, request.project_id, None) + + runtime_work = await list_runtime_work(db=db, user_id=user_id) + addresses = _active_runtime_addresses( + runtime_work, + project_id=request.project_id, + runtime_project_key=request.runtime_project_key, + ) + return await _archive_runtime_addresses( + db=db, + user_id=user_id, + addresses=addresses, + ) + + +async def archive_all_conversations( + *, + db: Session, + user_id: int, +) -> RuntimeArchivedConversationBulkResponse: + """Archive all active runtime conversations visible on online devices.""" + + runtime_work = await list_runtime_work(db=db, user_id=user_id) + addresses = _active_runtime_addresses(runtime_work) + return await _archive_runtime_addresses( + db=db, + user_id=user_id, + addresses=addresses, + ) + + +async def unarchive_conversation( + *, + db: Session, + user_id: int, + address: RuntimeTaskAddress, +) -> RuntimeTaskArchiveResponse: + """Unarchive one device-local conversation through the owning executor.""" + + normalized_address = _normalized_address(address) + _ensure_owned_device(db, user_id, normalized_address.device_id) + result = await _call_archived_conversation_rpc( + user_id=user_id, + address=normalized_address, + method="runtime.archived_conversations.unarchive", + ) + if result.get("success") and normalized_address.workspace_path: + _touch_workspace_mapping(db, user_id, normalized_address) + return _runtime_archive_response(result, normalized_address) + + +async def delete_archived_conversation( + *, + db: Session, + user_id: int, + address: RuntimeTaskAddress, +) -> RuntimeTaskArchiveResponse: + """Delete one archived device-local conversation through the executor.""" + + normalized_address = _normalized_address(address) + _ensure_owned_device(db, user_id, normalized_address.device_id) + result = await _call_archived_conversation_rpc( + user_id=user_id, + address=normalized_address, + method="runtime.archived_conversations.delete", + ) + return _runtime_archive_response(result, normalized_address) + + +async def delete_archived_conversations_bulk( + *, + db: Session, + user_id: int, + request: RuntimeArchivedConversationBulkRequest, +) -> RuntimeArchivedConversationBulkResponse: + """Delete archived conversations grouped by owning device RPC.""" + + addresses = [_normalized_address(address) for address in request.items] + for address in addresses: + _ensure_owned_device(db, user_id, address.device_id) + + grouped: dict[str, list[RuntimeTaskAddress]] = {} + for address in addresses: + grouped.setdefault(address.device_id, []).append(address) + + results: list[dict[str, Any]] = [] + deleted_count = 0 + for device_id, device_addresses in grouped.items(): + try: + result = await runtime_rpc_service.call( + user_id=user_id, + device_id=device_id, + method="runtime.archived_conversations.delete_bulk", + payload={ + "items": [ + _runtime_task_address_payload(address) + for address in device_addresses + ] + }, + timeout_seconds=RUNTIME_TRANSCRIPT_TIMEOUT_SECONDS, + ) + except RuntimeRpcError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + results.append(result) + deleted_count += int(result.get("deletedCount") or 0) + + return RuntimeArchivedConversationBulkResponse( + accepted=True, + requestedCount=len(addresses), + acceptedCount=len(addresses), + deletedCount=deleted_count, + results=results, + ) + + async def create_runtime_task( *, db: Session, @@ -607,6 +868,41 @@ async def create_runtime_task( ) +async def _register_prepared_runtime_workspace( + *, + user_id: int, + device_id: str, + workspace_path: str, + project_name: str, +) -> None: + payload = { + "runtime": "codex", + "workspacePath": workspace_path, + } + normalized_name = project_name.strip() if isinstance(project_name, str) else "" + if normalized_name: + payload["label"] = normalized_name + try: + result = await runtime_rpc_service.call( + user_id=user_id, + device_id=device_id, + method="runtime.workspaces.open", + payload=payload, + timeout_seconds=RUNTIME_WORKSPACE_OPEN_TIMEOUT_SECONDS, + ) + except RuntimeRpcError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + if result.get("success") is False or result.get("accepted") is False: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(result.get("error") or "Runtime workspace registration failed"), + ) + + async def open_runtime_workspace( *, db: Session, @@ -622,6 +918,8 @@ async def open_runtime_workspace( "runtime": request.runtime, "workspacePath": workspace_path, } + if request.label: + payload["label"] = request.label.strip() try: result = await runtime_rpc_service.call( user_id=user_id, @@ -643,6 +941,83 @@ async def open_runtime_workspace( ) +async def rename_runtime_workspace( + *, + db: Session, + user_id: int, + request: RuntimeWorkspaceRenameRequest, +) -> RuntimeWorkspaceOpenResponse: + """Rename a runtime workspace project without touching conversations.""" + + device_id = request.device_id.strip() + workspace_path = normalize_workspace_path(request.workspace_path) + name = request.name.strip() + if not name: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="name is required", + ) + _ensure_owned_device(db, user_id, device_id) + try: + result = await runtime_rpc_service.call( + user_id=user_id, + device_id=device_id, + method="runtime.workspaces.rename", + payload={ + "runtime": request.runtime, + "workspacePath": workspace_path, + "label": name, + }, + timeout_seconds=RUNTIME_WORKSPACE_OPEN_TIMEOUT_SECONDS, + ) + except RuntimeRpcError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + return _runtime_workspace_open_response( + result=result, + runtime=request.runtime, + device_id=device_id, + workspace_path=workspace_path, + ) + + +async def remove_runtime_workspace( + *, + db: Session, + user_id: int, + request: RuntimeWorkspaceRemoveRequest, +) -> RuntimeWorkspaceOpenResponse: + """Remove a runtime workspace project without deleting conversations.""" + + device_id = request.device_id.strip() + workspace_path = normalize_workspace_path(request.workspace_path) + _ensure_owned_device(db, user_id, device_id) + try: + result = await runtime_rpc_service.call( + user_id=user_id, + device_id=device_id, + method="runtime.workspaces.remove", + payload={ + "runtime": request.runtime, + "workspacePath": workspace_path, + }, + timeout_seconds=RUNTIME_WORKSPACE_OPEN_TIMEOUT_SECONDS, + ) + except RuntimeRpcError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + return _runtime_workspace_open_response( + result=result, + runtime=request.runtime, + device_id=device_id, + workspace_path=workspace_path, + ) + + async def fork_runtime_task( *, db: Session, @@ -1804,63 +2179,395 @@ async def _list_online_runtime_workspaces( *, user_id: int, devices: list[dict[str, Any]], -) -> dict[tuple[str, str], list[LocalTaskSummary]]: - grouped: dict[tuple[str, str], list[LocalTaskSummary]] = {} - for device in devices: - device_id = str(device.get("device_id") or "") - if not device_id or _device_status(device) not in {"online", "busy"}: - continue - try: - result = await runtime_rpc_service.call( - user_id=user_id, - device_id=device_id, - method="runtime.tasks.list", - payload={}, - timeout_seconds=RUNTIME_LIST_TIMEOUT_SECONDS, +) -> dict[tuple[str, str], RuntimeWorkspaceListing]: + started_at = time.perf_counter() + online_devices = [ + device + for device in devices + if str(device.get("device_id") or "") + and _device_status(device) in {"online", "busy"} + ] + results = await asyncio.gather( + *[ + _list_runtime_workspaces_for_device(user_id=user_id, device=device) + for device in online_devices + ], + return_exceptions=True, + ) + + grouped: dict[tuple[str, str], RuntimeWorkspaceListing] = {} + for result in results: + if isinstance(result, Exception): + logger.warning( + "[RuntimeWork] Failed to list runtime workspaces from device: user_id=%s error_type=%s", + user_id, + result.__class__.__name__, ) - except RuntimeRpcError: continue - for workspace in _iter_runtime_workspaces(result): - workspace_path = normalize_workspace_path(workspace["workspacePath"]) - tasks = [ - LocalTaskSummary.model_validate( - { - **task, - **_runtime_task_kind_fields( - task, - normalize_workspace_path( - str(task.get("workspacePath") or workspace_path) - ), - ), - "workspacePath": normalize_workspace_path( + grouped.update(result) + + logger.info( + "[RuntimeWork] Listed runtime workspaces: user_id=%s online_devices=%s workspace_count=%s task_count=%s elapsed_ms=%s", + user_id, + len(online_devices), + len(grouped), + sum(len(listing.local_tasks) for listing in grouped.values()), + int((time.perf_counter() - started_at) * 1000), + ) + return grouped + + +async def _list_runtime_workspaces_for_device( + *, + user_id: int, + device: dict[str, Any], +) -> dict[tuple[str, str], RuntimeWorkspaceListing]: + started_at = time.perf_counter() + device_id = str(device.get("device_id") or "") + if not device_id: + return {} + + try: + result = await runtime_rpc_service.call( + user_id=user_id, + device_id=device_id, + method="runtime.tasks.list", + payload={}, + timeout_seconds=RUNTIME_LIST_TIMEOUT_SECONDS, + ) + except RuntimeRpcError as exc: + logger.warning( + "[RuntimeWork] Runtime workspace list failed: user_id=%s device_id=%s elapsed_ms=%s error=%s", + user_id, + device_id, + int((time.perf_counter() - started_at) * 1000), + str(exc), + ) + return {} + + grouped: dict[tuple[str, str], RuntimeWorkspaceListing] = {} + for order_index, workspace in enumerate(_iter_runtime_workspaces(result)): + workspace_path = normalize_workspace_path(workspace["workspacePath"]) + tasks = [ + LocalTaskSummary.model_validate( + { + **task, + **_runtime_task_kind_fields( + task, + normalize_workspace_path( str(task.get("workspacePath") or workspace_path) ), - } - ) - for task in workspace["localTasks"] - if isinstance(task, dict) - ] - grouped[(device_id, workspace_path)] = tasks + ), + "workspacePath": normalize_workspace_path( + str(task.get("workspacePath") or workspace_path) + ), + } + ) + for task in workspace["localTasks"] + if isinstance(task, dict) + ] + grouped[(device_id, workspace_path)] = RuntimeWorkspaceListing( + local_tasks=tasks, + order_index=order_index, + label=_runtime_workspace_label(workspace), + workspace_source=_runtime_workspace_source(workspace), + remote_host_id=_runtime_workspace_remote_host_id(workspace), + ) + + logger.info( + "[RuntimeWork] Runtime workspace list completed: user_id=%s device_id=%s workspace_count=%s task_count=%s elapsed_ms=%s", + user_id, + device_id, + len(grouped), + sum(len(listing.local_tasks) for listing in grouped.values()), + int((time.perf_counter() - started_at) * 1000), + ) return grouped -def _runtime_workspace_sort_key( - local_tasks: list[LocalTaskSummary], -) -> tuple[float, str]: - latest = max((_task_timestamp(task) for task in local_tasks), default=0.0) - title = local_tasks[0].title if local_tasks else "" - return (-latest, title.lower()) +def _archived_list_payload( + request: ArchivedConversationsListRequest, +) -> dict[str, Any]: + payload: dict[str, Any] = {} + if request.workspace_path: + payload["workspacePath"] = normalize_workspace_path(request.workspace_path) + if request.search: + payload["search"] = request.search.strip() + return payload -def _task_timestamp(task: LocalTaskSummary) -> float: - for value in (task.updated_at, task.created_at): - if not value: - continue - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() - except ValueError: +def _archived_project_lookup( + db: Session, + user_id: int, +) -> dict[tuple[str, str], dict[str, Any]]: + mappings = list_device_workspace_kinds(db=db, user_id=user_id) + project_ids = {mapping.project_id for mapping in mappings} + projects = ( + db.query(Project) + .filter( + Project.user_id == user_id, + Project.id.in_(project_ids), + Project.is_active == True, + ) + .all() + if project_ids + else [] + ) + projects_by_id = {project.id: project for project in projects} + lookup: dict[tuple[str, str], dict[str, Any]] = {} + for mapping in mappings: + workspace_path = normalize_workspace_path(mapping.workspace_path) + project = projects_by_id.get(mapping.project_id) + lookup[(mapping.device_id, workspace_path)] = { + "projectId": mapping.project_id, + "projectKey": f"project:{mapping.project_id}", + "projectName": project.name if project else mapping.label, + } + return lookup + + +def _archived_conversation_item( + raw_item: Any, + *, + device_id: str, + device_name: str, + device_address: str, + source: str, + project_lookup: dict[tuple[str, str], dict[str, Any]], +) -> Optional[ArchivedConversationItem]: + if not isinstance(raw_item, dict): + return None + local_task_id = raw_item.get("localTaskId") or raw_item.get("id") + workspace_path = raw_item.get("workspacePath") + if not isinstance(local_task_id, str) or not local_task_id.strip(): + return None + if not isinstance(workspace_path, str) or not workspace_path.strip(): + return None + + normalized_workspace = normalize_workspace_path(workspace_path) + project = project_lookup.get((device_id, normalized_workspace)) or {} + project_key = str( + project.get("projectKey") + or _runtime_workspace_key(device_id, normalized_workspace) + ) + project_name = str( + project.get("projectName") or _path_basename(normalized_workspace) + ) + runtime = raw_item.get("runtime") + return ArchivedConversationItem( + id=f"{device_id}:{local_task_id.strip()}", + localTaskId=local_task_id.strip(), + title=str(raw_item.get("title") or local_task_id).strip(), + projectId=project.get("projectId"), + projectKey=project_key, + projectName=project_name, + workspacePath=normalized_workspace, + workspaceKind=raw_item.get("workspaceKind") or "workspace", + deviceId=device_id, + deviceName=device_name, + deviceAddress=device_address, + source=source if source == "local" else "cloud", + runtime=runtime if runtime in {"codex", "claude_code"} else None, + createdAt=raw_item.get("createdAt"), + updatedAt=raw_item.get("updatedAt"), + ) + + +def _include_archived_item( + item: ArchivedConversationItem, + request: ArchivedConversationsListRequest, +) -> bool: + if request.source != "all" and item.source != request.source: + return False + if request.project_id is not None and item.project_id != request.project_id: + return False + if request.runtime_project_key and item.project_key != request.runtime_project_key: + return False + if ( + request.search + and request.search.strip().lower() + not in " ".join( + [ + item.title, + item.project_name or "", + item.workspace_path, + item.local_task_id, + ] + ).lower() + ): + return False + return True + + +def _sort_archived_items( + items: list[ArchivedConversationItem], + sort_key: str, +) -> list[ArchivedConversationItem]: + if sort_key == "alphabetical": + return sorted(items, key=lambda item: item.title.lower()) + if sort_key == "created": + return sorted( + items, + key=lambda item: _timestamp_from_iso(item.created_at), + reverse=True, + ) + return sorted( + items, + key=lambda item: _timestamp_from_iso(item.updated_at or item.created_at), + reverse=True, + ) + + +def _archived_project_groups( + items: list[ArchivedConversationItem], +) -> list[ArchivedConversationProjectGroup]: + grouped: dict[tuple[Optional[int], Optional[str], str], int] = {} + for item in items: + key = ( + item.project_id, + item.project_key, + item.project_name or _path_basename(item.workspace_path), + ) + grouped[key] = grouped.get(key, 0) + 1 + return [ + ArchivedConversationProjectGroup( + projectId=project_id, + projectKey=project_key, + projectName=project_name, + count=count, + ) + for (project_id, project_key, project_name), count in sorted( + grouped.items(), + key=lambda item: item[0][2].lower(), + ) + ] + + +async def _archive_runtime_addresses( + *, + db: Session, + user_id: int, + addresses: list[RuntimeTaskAddress], +) -> RuntimeArchivedConversationBulkResponse: + results: list[dict[str, Any]] = [] + accepted_count = 0 + for address in addresses: + response = await archive_runtime_task( + db=db, + user_id=user_id, + address=address, + ) + payload = response.model_dump(by_alias=True, exclude_none=True) + if response.accepted: + accepted_count += 1 + results.append(payload) + return RuntimeArchivedConversationBulkResponse( + accepted=True, + requestedCount=len(addresses), + acceptedCount=accepted_count, + results=results, + ) + + +def _active_runtime_addresses( + runtime_work: RuntimeWorkListResponse, + *, + project_id: Optional[int] = None, + runtime_project_key: Optional[str] = None, +) -> list[RuntimeTaskAddress]: + addresses: list[RuntimeTaskAddress] = [] + include_chats = project_id is None and runtime_project_key is None + for project_work in runtime_work.projects: + if not _runtime_project_matches( + project_work, + project_id=project_id, + runtime_project_key=runtime_project_key, + ): continue - return 0.0 + addresses.extend(_workspace_task_addresses(project_work.device_workspaces)) + if include_chats: + addresses.extend(_workspace_task_addresses(runtime_work.chats)) + return addresses + + +def _runtime_project_matches( + project_work: RuntimeProjectWork, + *, + project_id: Optional[int], + runtime_project_key: Optional[str], +) -> bool: + if project_id is not None and project_work.project.id != project_id: + return False + if runtime_project_key and project_work.project.key != runtime_project_key: + return False + return True + + +def _workspace_task_addresses( + workspaces: list[RuntimeDeviceWorkspace], +) -> list[RuntimeTaskAddress]: + addresses: list[RuntimeTaskAddress] = [] + for workspace in workspaces: + for task in workspace.local_tasks: + if task.status == "archived": + continue + addresses.append( + RuntimeTaskAddress( + deviceId=workspace.device_id, + workspacePath=workspace.workspace_path, + localTaskId=task.local_task_id, + ) + ) + return addresses + + +async def _call_archived_conversation_rpc( + *, + user_id: int, + address: RuntimeTaskAddress, + method: str, +) -> dict[str, Any]: + try: + return await runtime_rpc_service.call( + user_id=user_id, + device_id=address.device_id, + method=method, + payload=_runtime_task_address_payload(address), + timeout_seconds=RUNTIME_TRANSCRIPT_TIMEOUT_SECONDS, + ) + except RuntimeRpcError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + +def _runtime_device_order(devices: list[dict[str, Any]]) -> dict[str, int]: + ordered_devices = sorted( + enumerate(devices), + key=lambda item: (_runtime_device_type_rank(item[1]), item[0]), + ) + return { + str(device.get("device_id") or ""): index + for index, (_original_index, device) in enumerate(ordered_devices) + if str(device.get("device_id") or "") + } + + +def _runtime_device_type_rank(device: dict[str, Any]) -> int: + return 0 if _device_type(device) == "local" else 1 + + +def _runtime_workspace_order_key( + item: tuple[tuple[str, str], RuntimeWorkspaceListing], + device_order: dict[str, int], +) -> tuple[int, int, str]: + (device_id, workspace_path), listing = item + return ( + device_order.get(device_id, len(device_order)), + listing.order_index, + workspace_path.lower(), + ) def _iter_runtime_workspaces(result: dict[str, Any]) -> list[dict[str, Any]]: @@ -1877,10 +2584,36 @@ def _iter_runtime_workspaces(result: dict[str, Any]) -> list[dict[str, Any]]: raw_tasks = item.get("localTasks") or item.get("local_tasks") or [] if not isinstance(raw_tasks, list): raw_tasks = [] - normalized.append({"workspacePath": path, "localTasks": raw_tasks}) + normalized.append( + { + "workspacePath": path, + "localTasks": raw_tasks, + "label": _runtime_workspace_label(item), + "workspaceSource": _runtime_workspace_source(item), + "remoteHostId": _runtime_workspace_remote_host_id(item), + } + ) return normalized +def _runtime_workspace_label(workspace: dict[str, Any]) -> Optional[str]: + label = workspace.get("label") + return label.strip() if isinstance(label, str) and label.strip() else None + + +def _runtime_workspace_source(workspace: dict[str, Any]) -> Optional[str]: + source = workspace.get("workspaceSource") or workspace.get("workspace_source") + if not isinstance(source, str): + return None + normalized = source.strip().lower() + return normalized if normalized in {"local", "remote"} else None + + +def _runtime_workspace_remote_host_id(workspace: dict[str, Any]) -> Optional[str]: + host_id = workspace.get("remoteHostId") or workspace.get("remote_host_id") + return host_id.strip() if isinstance(host_id, str) and host_id.strip() else None + + def _parse_runtime_worktree_path(path: str) -> Optional[RuntimeWorktreePath]: parts = [part for part in normalize_workspace_path(path).split("/") if part] for index, part in enumerate(parts): @@ -1972,16 +2705,27 @@ def _path_basename(path: str) -> str: def _runtime_project_ref_from_workspace( device_id: str, workspace_path: str, + *, + label: Optional[str] = None, ) -> RuntimeProjectRef: normalized_path = normalize_workspace_path(workspace_path) + display_name = ( + label.strip() + if isinstance(label, str) and label.strip() + else _path_basename(normalized_path) or normalized_path + ) return RuntimeProjectRef( - key=f"{device_id}:{normalized_path}", - name=_path_basename(normalized_path) or normalized_path, + key=_runtime_workspace_key(device_id, normalized_path), + name=display_name, description=normalized_path, color=None, ) +def _runtime_workspace_key(device_id: str, workspace_path: str) -> str: + return f"{device_id}:{normalize_workspace_path(workspace_path)}" + + def _device_name(device: Optional[dict[str, Any]], fallback: str) -> str: if not device: return fallback @@ -1989,6 +2733,66 @@ def _device_name(device: Optional[dict[str, Any]], fallback: str) -> str: return str(name) if name else fallback +def _archived_device_source(device: Optional[dict[str, Any]]) -> str: + return "local" if _device_type(device) == "local" else "cloud" + + +def _device_type(device: Optional[dict[str, Any]]) -> str: + if not device: + return "" + raw_type = ( + device.get("device_type") + or device.get("deviceType") + or device.get("type") + or "" + ) + if hasattr(raw_type, "value"): + raw_type = raw_type.value + return str(raw_type).strip().lower() + + +def _device_address(device: Optional[dict[str, Any]], fallback: str) -> str: + if not device: + return fallback + + for key in ( + "runtime_transfer_host", + "runtimeTransferHost", + "client_ip", + "clientIp", + "ip", + "ip_address", + "ipAddress", + "private_ip", + "privateIp", + "public_ip", + "publicIp", + "host", + "hostname", + ): + value = _string_value(device.get(key)) + if value: + return value + + for config_key in ("remote_config", "remoteConfig", "cloud_config", "cloudConfig"): + config = device.get(config_key) + if not isinstance(config, dict): + continue + for key in ( + "ip", + "ipAddress", + "host", + "hostname", + "deviceId", + "deviceName", + ): + value = _string_value(config.get(key)) + if value: + return value + + return _string_value(device.get("device_id")) or fallback + + def _device_status(device: Optional[dict[str, Any]]) -> str: if not device: return "unavailable" @@ -1996,6 +2800,15 @@ def _device_status(device: Optional[dict[str, Any]]) -> str: return str(status_value) if status_value else "offline" +def _timestamp_from_iso(value: Optional[str]) -> float: + if not value: + return 0.0 + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return 0.0 + + def _normalized_address(address: RuntimeTaskAddress) -> RuntimeTaskAddress: workspace_path = ( normalize_workspace_path(address.workspace_path) diff --git a/backend/tests/api/endpoints/test_runtime_work_api.py b/backend/tests/api/endpoints/test_runtime_work_api.py index 4964b5c1d9..0f233d1920 100644 --- a/backend/tests/api/endpoints/test_runtime_work_api.py +++ b/backend/tests/api/endpoints/test_runtime_work_api.py @@ -17,7 +17,7 @@ def test_list_runtime_work_endpoint_uses_current_user( service_mock = AsyncMock( return_value={ "projects": [], - "unmappedDeviceWorkspaces": [], + "chats": [], "totalLocalTasks": 0, } ) @@ -228,6 +228,120 @@ def test_runtime_archive_endpoint_dispatches_address( assert service_mock.await_args.kwargs["address"].local_task_id == "codex-1" +def test_runtime_rename_endpoint_dispatches_request( + test_client, + test_token, + monkeypatch, +): + from app.api.endpoints import runtime_work + + service_mock = AsyncMock( + return_value={ + "accepted": True, + "localTaskId": "codex-1", + "workspacePath": "/repo/Wegent", + "error": None, + } + ) + monkeypatch.setattr( + runtime_work.runtime_work_service, "rename_runtime_task", service_mock + ) + + response = test_client.post( + "/api/runtime-work/rename", + headers=_auth_headers(test_token), + json={ + "address": { + "deviceId": "device-1", + "workspacePath": "/repo/Wegent", + "localTaskId": "codex-1", + }, + "title": "对齐需求核心点", + }, + ) + + assert response.status_code == 200 + assert response.json()["accepted"] is True + request = service_mock.await_args.kwargs["request"] + assert request.address.local_task_id == "codex-1" + assert request.title == "对齐需求核心点" + + +def test_archived_conversations_list_endpoint_dispatches_filters( + test_client, + test_token, + monkeypatch, +): + from app.api.endpoints import runtime_work + + service_mock = AsyncMock( + return_value={ + "items": [], + "projectGroups": [], + "total": 0, + } + ) + monkeypatch.setattr( + runtime_work.runtime_work_service, + "list_archived_conversations", + service_mock, + ) + + response = test_client.post( + "/api/runtime-work/archived-conversations/list", + headers=_auth_headers(test_token), + json={"search": "hello", "source": "local", "sort": "updated"}, + ) + + assert response.status_code == 200 + assert response.json()["total"] == 0 + request = service_mock.await_args.kwargs["request"] + assert request.search == "hello" + assert request.source == "local" + + +def test_archived_conversations_delete_bulk_endpoint_dispatches_items( + test_client, + test_token, + monkeypatch, +): + from app.api.endpoints import runtime_work + + service_mock = AsyncMock( + return_value={ + "accepted": True, + "requestedCount": 1, + "acceptedCount": 1, + "deletedCount": 1, + "results": [], + } + ) + monkeypatch.setattr( + runtime_work.runtime_work_service, + "delete_archived_conversations_bulk", + service_mock, + ) + + response = test_client.post( + "/api/runtime-work/archived-conversations/delete-bulk", + headers=_auth_headers(test_token), + json={ + "items": [ + { + "deviceId": "device-1", + "workspacePath": "/repo/Wegent", + "localTaskId": "codex-1", + } + ] + }, + ) + + assert response.status_code == 200 + assert response.json()["deletedCount"] == 1 + request = service_mock.await_args.kwargs["request"] + assert request.items[0].local_task_id == "codex-1" + + def test_runtime_workspace_open_endpoint_dispatches_request( test_client, test_token, @@ -241,7 +355,6 @@ def test_runtime_workspace_open_endpoint_dispatches_request( "deviceId": "device-1", "workspacePath": "/Users/crystal/Documents/hello-0", "runtime": "codex", - "threadId": "thread-1", "error": None, } ) @@ -258,11 +371,95 @@ def test_runtime_workspace_open_endpoint_dispatches_request( "deviceId": "device-1", "workspacePath": "/Users/crystal/Documents/hello-0", "runtime": "codex", + "label": "Hello project", }, ) assert response.status_code == 200 - assert response.json()["threadId"] == "thread-1" + assert response.json()["threadId"] is None + request = service_mock.await_args.kwargs["request"] + assert request.device_id == "device-1" + assert request.workspace_path == "/Users/crystal/Documents/hello-0" + assert request.label == "Hello project" + + +def test_runtime_workspace_rename_endpoint_dispatches_request( + test_client, + test_token, + monkeypatch, +): + from app.api.endpoints import runtime_work + + service_mock = AsyncMock( + return_value={ + "accepted": True, + "deviceId": "device-1", + "workspacePath": "/Users/crystal/Documents/hello-0", + "runtime": "codex", + "threadId": None, + "error": None, + } + ) + monkeypatch.setattr( + runtime_work.runtime_work_service, + "rename_runtime_workspace", + service_mock, + ) + + response = test_client.post( + "/api/runtime-work/workspaces/rename", + headers=_auth_headers(test_token), + json={ + "deviceId": "device-1", + "workspacePath": "/Users/crystal/Documents/hello-0", + "runtime": "codex", + "name": "Hello project", + }, + ) + + assert response.status_code == 200 + assert response.json()["accepted"] is True + request = service_mock.await_args.kwargs["request"] + assert request.device_id == "device-1" + assert request.workspace_path == "/Users/crystal/Documents/hello-0" + assert request.name == "Hello project" + + +def test_runtime_workspace_remove_endpoint_dispatches_request( + test_client, + test_token, + monkeypatch, +): + from app.api.endpoints import runtime_work + + service_mock = AsyncMock( + return_value={ + "accepted": True, + "deviceId": "device-1", + "workspacePath": "/Users/crystal/Documents/hello-0", + "runtime": "codex", + "threadId": None, + "error": None, + } + ) + monkeypatch.setattr( + runtime_work.runtime_work_service, + "remove_runtime_workspace", + service_mock, + ) + + response = test_client.post( + "/api/runtime-work/workspaces/remove", + headers=_auth_headers(test_token), + json={ + "deviceId": "device-1", + "workspacePath": "/Users/crystal/Documents/hello-0", + "runtime": "codex", + }, + ) + + assert response.status_code == 200 + assert response.json()["accepted"] is True request = service_mock.await_args.kwargs["request"] assert request.device_id == "device-1" assert request.workspace_path == "/Users/crystal/Documents/hello-0" diff --git a/backend/tests/services/test_runtime_work_service.py b/backend/tests/services/test_runtime_work_service.py index b2e7fd7bb2..7bce0c836c 100644 --- a/backend/tests/services/test_runtime_work_service.py +++ b/backend/tests/services/test_runtime_work_service.py @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: Apache-2.0 +import asyncio +import time from types import SimpleNamespace from unittest.mock import AsyncMock @@ -122,6 +124,12 @@ def _expected_runtime_fork_worktree_path(target_path: str, transfer_id: str) -> return f"{parent}/worktrees/{transfer_id}/{project_dir}" +def _mock_runtime_workspace_open(runtime_work_service, monkeypatch) -> AsyncMock: + rpc = AsyncMock(return_value={"success": True, "accepted": True}) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) + return rpc + + @pytest.mark.asyncio async def test_device_workspace_upsert_normalizes_unique_mapping(test_db, test_user): from app.schemas.runtime_work import DeviceWorkspaceUpsert @@ -308,6 +316,7 @@ async def execute(**kwargs): monkeypatch.setattr( runtime_work_service, "execute_configured_device_command", execute ) + rpc = _mock_runtime_workspace_open(runtime_work_service, monkeypatch) response = await runtime_work_service.prepare_device_workspace( db=test_db, @@ -328,6 +337,17 @@ async def execute(**kwargs): ("project_folder_status", ["/repo/Wegent"]), ("mkdir_p", ["/repo/Wegent"]), ] + rpc.assert_awaited_once_with( + user_id=test_user.id, + device_id="device-1", + method="runtime.workspaces.open", + payload={ + "runtime": "codex", + "workspacePath": "/repo/Wegent", + "label": "Wegent", + }, + timeout_seconds=60, + ) @pytest.mark.asyncio @@ -358,6 +378,7 @@ async def execute(**kwargs): monkeypatch.setattr( runtime_work_service, "execute_configured_device_command", execute ) + rpc = _mock_runtime_workspace_open(runtime_work_service, monkeypatch) response = await runtime_work_service.prepare_device_workspace( db=test_db, @@ -375,6 +396,7 @@ async def execute(**kwargs): assert response.mapping.repo_url is None assert response.prepared_action == "created" assert calls == [("project_folder_status", ["/repo/Wegent"])] + rpc.assert_awaited_once() @pytest.mark.asyncio @@ -405,6 +427,7 @@ async def execute(**kwargs): monkeypatch.setattr( runtime_work_service, "execute_configured_device_command", execute ) + rpc = _mock_runtime_workspace_open(runtime_work_service, monkeypatch) response = await runtime_work_service.prepare_device_workspace( db=test_db, @@ -429,6 +452,7 @@ async def execute(**kwargs): "/repo/Wegent", ], ) in calls + rpc.assert_awaited_once() @pytest.mark.asyncio @@ -458,6 +482,7 @@ async def execute(**kwargs): monkeypatch.setattr( runtime_work_service, "execute_configured_device_command", execute ) + rpc = _mock_runtime_workspace_open(runtime_work_service, monkeypatch) with pytest.raises(HTTPException) as exc: await runtime_work_service.prepare_device_workspace( @@ -474,6 +499,7 @@ async def execute(**kwargs): assert exc.value.status_code == 409 assert "other repository" in exc.value.detail assert test_db.query(Kind).filter(Kind.kind == "DeviceWorkspace").count() == 0 + rpc.assert_not_awaited() @pytest.mark.asyncio @@ -572,26 +598,24 @@ async def test_list_runtime_work_groups_executor_workspaces_without_project_mapp assert response.total_local_tasks == 3 assert [project_work.project.name for project_work in response.projects] == [ - "spike", "Wegent", + "spike", ] assert [project_work.project.key for project_work in response.projects] == [ - "device-1:/tmp/spike", "device-1:/repo/Wegent", + "device-1:/tmp/spike", ] - assert response.projects[0].device_workspaces[0].workspace_path == "/tmp/spike" + assert response.projects[0].device_workspaces[0].workspace_path == "/repo/Wegent" assert response.projects[0].device_workspaces[0].id is None assert response.projects[0].device_workspaces[0].project_id is None assert response.projects[0].device_workspaces[0].mapped is True assert ( - response.projects[1].device_workspaces[0].local_tasks[0].local_task_id + response.projects[0].device_workspaces[0].local_tasks[0].local_task_id == "codex-1" ) - assert len(response.unmapped_device_workspaces) == 1 - assert response.unmapped_device_workspaces[0].workspace_kind == "chat" - assert ( - response.unmapped_device_workspaces[0].local_tasks[0].local_task_id == "chat-1" - ) + assert len(response.chats) == 1 + assert response.chats[0].workspace_kind == "chat" + assert response.chats[0].local_tasks[0].local_task_id == "chat-1" rpc.assert_awaited_once() assert test_db.query(TaskResource).count() == 0 @@ -626,6 +650,9 @@ async def test_list_runtime_work_keeps_empty_executor_workspaces( "workspaces": [ { "workspacePath": "/Users/crystal/Documents/hello-0", + "label": "Hello project", + "workspaceSource": "remote", + "remoteHostId": "remote-ssh-discovered:10.201.3.200", "localTasks": [], } ] @@ -640,14 +667,94 @@ async def test_list_runtime_work_keeps_empty_executor_workspaces( assert response.total_local_tasks == 0 assert [project_work.project.name for project_work in response.projects] == [ - "hello-0" + "Hello project" ] workspace = response.projects[0].device_workspaces[0] assert workspace.workspace_path == "/Users/crystal/Documents/hello-0" + assert workspace.label == "Hello project" + assert workspace.workspace_source == "remote" + assert workspace.remote_host_id == "remote-ssh-discovered:10.201.3.200" assert workspace.local_tasks == [] assert workspace.mapped is True +@pytest.mark.asyncio +async def test_list_runtime_work_orders_local_devices_first_and_keeps_executor_order( + test_db, + test_user, + monkeypatch, +): + from app.services import runtime_work_service + + monkeypatch.setattr( + runtime_work_service.device_service, + "get_all_devices", + AsyncMock( + return_value=[ + { + "device_id": "cloud-device", + "name": "Cloud", + "status": "online", + "device_type": "cloud", + }, + { + "device_id": "local-device", + "name": "MacBook", + "status": "online", + "device_type": "local", + }, + ] + ), + ) + + async def rpc_side_effect(**kwargs): + if kwargs["device_id"] == "cloud-device": + return { + "workspaces": [ + { + "workspacePath": "/cloud/remote-project", + "label": "remote-project", + "localTasks": [], + } + ] + } + return { + "workspaces": [ + { + "workspacePath": "/local/weekly-report-2", + "label": "weekly-report-2", + "localTasks": [], + }, + { + "workspacePath": "/local/Wegent", + "label": "Wegent", + "localTasks": [], + }, + ] + } + + monkeypatch.setattr( + runtime_work_service.runtime_rpc_service, + "call", + AsyncMock(side_effect=rpc_side_effect), + ) + + response = await runtime_work_service.list_runtime_work( + db=test_db, + user_id=test_user.id, + ) + + assert [project_work.project.name for project_work in response.projects] == [ + "weekly-report-2", + "Wegent", + "remote-project", + ] + assert [ + project_work.device_workspaces[0].device_id + for project_work in response.projects + ] == ["local-device", "local-device", "cloud-device"] + + @pytest.mark.asyncio async def test_list_runtime_work_preserves_executor_workspace_kind( test_db, @@ -707,7 +814,7 @@ async def test_list_runtime_work_preserves_executor_workspace_kind( assert workspace.worktree_id == "42" assert task.workspace_kind == "worktree" assert task.worktree_id == "42" - assert response.unmapped_device_workspaces == [] + assert response.chats == [] @pytest.mark.asyncio @@ -741,11 +848,61 @@ async def test_list_runtime_work_skips_offline_devices( ) assert response.projects == [] - assert response.unmapped_device_workspaces == [] + assert response.chats == [] assert response.total_local_tasks == 0 rpc.assert_not_awaited() +@pytest.mark.asyncio +async def test_list_runtime_work_queries_online_devices_concurrently( + test_db, + test_user, + monkeypatch, +): + from app.services import runtime_work_service + + monkeypatch.setattr( + runtime_work_service.device_service, + "get_all_devices", + AsyncMock( + return_value=[ + { + "device_id": "device-1", + "name": "MacBook", + "status": "online", + "device_type": "local", + }, + { + "device_id": "device-2", + "name": "Remote", + "status": "online", + "device_type": "remote", + }, + ] + ), + ) + + async def rpc_side_effect(**_kwargs): + await asyncio.sleep(0.2) + return {"workspaces": []} + + rpc = AsyncMock(side_effect=rpc_side_effect) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) + + started_at = time.perf_counter() + response = await runtime_work_service.list_runtime_work( + db=test_db, + user_id=test_user.id, + ) + elapsed = time.perf_counter() - started_at + + assert response.projects == [] + assert response.chats == [] + assert response.total_local_tasks == 0 + assert rpc.await_count == 2 + assert elapsed < 0.35 + + @pytest.mark.asyncio async def test_open_runtime_transcript_dispatches_to_owned_mapped_device_without_task_rows( test_db, @@ -924,6 +1081,292 @@ async def test_archive_runtime_task_dispatches_to_owned_device_without_task_rows assert test_db.query(TaskResource).count() == 0 +@pytest.mark.asyncio +async def test_rename_runtime_task_dispatches_to_owned_device( + test_db, + test_user, + monkeypatch, +): + from app.schemas.runtime_work import RuntimeTaskAddress, RuntimeTaskRenameRequest + from app.services import runtime_work_service + + monkeypatch.setattr( + runtime_work_service.device_service, + "get_device_by_device_id", + lambda db, user_id, device_id: object(), + ) + rpc = AsyncMock( + return_value={ + "success": True, + "accepted": True, + "localTaskId": "codex-1", + "workspacePath": "/repo/Wegent", + } + ) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) + + response = await runtime_work_service.rename_runtime_task( + db=test_db, + user_id=test_user.id, + request=RuntimeTaskRenameRequest( + address=RuntimeTaskAddress( + deviceId="device-1", + workspacePath="/repo/Wegent", + localTaskId="codex-1", + ), + title=" 对齐需求核心点 ", + ), + ) + + assert response.accepted is True + rpc.assert_awaited_once_with( + user_id=test_user.id, + device_id="device-1", + method="runtime.tasks.rename", + payload={ + "deviceId": "device-1", + "workspacePath": "/repo/Wegent", + "localTaskId": "codex-1", + "title": "对齐需求核心点", + }, + timeout_seconds=30, + ) + + +@pytest.mark.asyncio +async def test_list_archived_conversations_dispatches_to_online_device( + test_db, + test_user, + monkeypatch, +): + from app.schemas.runtime_work import ( + ArchivedConversationsListRequest, + DeviceWorkspaceUpsert, + ) + from app.services import runtime_work_service + + project = _project(test_db, test_user.id) + runtime_work_service.upsert_device_workspace( + db=test_db, + user_id=test_user.id, + payload=DeviceWorkspaceUpsert( + projectId=project.id, + deviceId="device-1", + workspacePath="/repo/Wegent", + ), + ) + monkeypatch.setattr( + runtime_work_service.device_service, + "get_all_devices", + AsyncMock( + return_value=[ + { + "device_id": "device-1", + "name": "MacBook", + "status": "online", + "device_type": "local", + "client_ip": "192.168.1.24", + } + ] + ), + ) + rpc = AsyncMock( + return_value={ + "success": True, + "items": [ + { + "id": "codex-1", + "localTaskId": "codex-1", + "title": "Archived thread", + "workspacePath": "/repo/Wegent", + "runtime": "codex", + "source": "local", + "createdAt": "2026-06-21T02:15:37Z", + "updatedAt": "2026-06-21T02:15:58Z", + } + ], + } + ) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) + + response = await runtime_work_service.list_archived_conversations( + db=test_db, + user_id=test_user.id, + request=ArchivedConversationsListRequest(), + ) + + assert response.total == 1 + assert response.items[0].id == "device-1:codex-1" + assert response.items[0].project_id == project.id + assert response.items[0].project_name == project.name + assert response.items[0].source == "local" + assert response.items[0].device_name == "MacBook" + assert response.items[0].device_address == "192.168.1.24" + assert response.project_groups[0].count == 1 + rpc.assert_awaited_once_with( + user_id=test_user.id, + device_id="device-1", + method="runtime.archived_conversations.list", + payload={}, + timeout_seconds=30, + ) + + +@pytest.mark.asyncio +async def test_list_archived_conversations_local_filter_skips_non_local_devices( + test_db, + test_user, + monkeypatch, +): + from app.schemas.runtime_work import ArchivedConversationsListRequest + from app.services import runtime_work_service + + monkeypatch.setattr( + runtime_work_service.device_service, + "get_all_devices", + AsyncMock( + return_value=[ + { + "device_id": "remote-device", + "name": "Remote executor", + "status": "online", + "device_type": "remote", + }, + { + "device_id": "cloud-device", + "name": "Cloud executor", + "status": "online", + "device_type": "cloud", + }, + ] + ), + ) + rpc = AsyncMock(return_value={"success": True, "items": []}) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) + + response = await runtime_work_service.list_archived_conversations( + db=test_db, + user_id=test_user.id, + request=ArchivedConversationsListRequest(source="local"), + ) + + assert response.total == 0 + assert response.items == [] + rpc.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_unarchive_conversation_dispatches_to_owned_device( + test_db, + test_user, + monkeypatch, +): + from app.schemas.runtime_work import RuntimeTaskAddress + from app.services import runtime_work_service + + monkeypatch.setattr( + runtime_work_service.device_service, + "get_device_by_device_id", + lambda db, user_id, device_id: object(), + ) + rpc = AsyncMock( + return_value={ + "success": True, + "accepted": True, + "localTaskId": "codex-1", + "workspacePath": "/repo/Wegent", + } + ) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) + + response = await runtime_work_service.unarchive_conversation( + db=test_db, + user_id=test_user.id, + address=RuntimeTaskAddress( + deviceId="device-1", + workspacePath="/repo/Wegent", + localTaskId="codex-1", + ), + ) + + assert response.accepted is True + rpc.assert_awaited_once_with( + user_id=test_user.id, + device_id="device-1", + method="runtime.archived_conversations.unarchive", + payload={ + "deviceId": "device-1", + "workspacePath": "/repo/Wegent", + "localTaskId": "codex-1", + }, + timeout_seconds=30, + ) + + +@pytest.mark.asyncio +async def test_delete_archived_conversations_bulk_groups_by_device( + test_db, + test_user, + monkeypatch, +): + from app.schemas.runtime_work import ( + RuntimeArchivedConversationBulkRequest, + RuntimeTaskAddress, + ) + from app.services import runtime_work_service + + monkeypatch.setattr( + runtime_work_service.device_service, + "get_device_by_device_id", + lambda db, user_id, device_id: object(), + ) + rpc = AsyncMock(return_value={"success": True, "deletedCount": 2}) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) + + response = await runtime_work_service.delete_archived_conversations_bulk( + db=test_db, + user_id=test_user.id, + request=RuntimeArchivedConversationBulkRequest( + items=[ + RuntimeTaskAddress( + deviceId="device-1", + workspacePath="/repo/Wegent", + localTaskId="codex-1", + ), + RuntimeTaskAddress( + deviceId="device-1", + workspacePath="/repo/Wegent", + localTaskId="codex-2", + ), + ] + ), + ) + + assert response.accepted is True + assert response.requested_count == 2 + assert response.deleted_count == 2 + rpc.assert_awaited_once_with( + user_id=test_user.id, + device_id="device-1", + method="runtime.archived_conversations.delete_bulk", + payload={ + "items": [ + { + "deviceId": "device-1", + "workspacePath": "/repo/Wegent", + "localTaskId": "codex-1", + }, + { + "deviceId": "device-1", + "workspacePath": "/repo/Wegent", + "localTaskId": "codex-2", + }, + ] + }, + timeout_seconds=30, + ) + + @pytest.mark.asyncio async def test_send_runtime_message_normalizes_runtime_rpc_failure_without_task_rows( test_db, @@ -1087,7 +1530,6 @@ async def test_open_runtime_workspace_dispatches_to_owned_device_without_task_ro "accepted": True, "workspacePath": "/Users/crystal/Documents/hello-0", "runtime": "codex", - "threadId": "thread-1", } ) monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) @@ -1103,7 +1545,7 @@ async def test_open_runtime_workspace_dispatches_to_owned_device_without_task_ro ) assert response.accepted is True - assert response.thread_id == "thread-1" + assert response.thread_id is None assert response.workspace_path == "/Users/crystal/Documents/hello-0" rpc.assert_awaited_once_with( user_id=test_user.id, @@ -1118,6 +1560,104 @@ async def test_open_runtime_workspace_dispatches_to_owned_device_without_task_ro assert test_db.query(TaskResource).count() == 0 +@pytest.mark.asyncio +async def test_rename_runtime_workspace_dispatches_to_owned_device( + test_db, + test_user, + monkeypatch, +): + from app.schemas.runtime_work import RuntimeWorkspaceRenameRequest + from app.services import runtime_work_service + + monkeypatch.setattr( + runtime_work_service.device_service, + "get_device_by_device_id", + lambda db, user_id, device_id: object(), + ) + rpc = AsyncMock( + return_value={ + "success": True, + "accepted": True, + "workspacePath": "/Users/crystal/Documents/hello-0", + "runtime": "codex", + } + ) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) + + response = await runtime_work_service.rename_runtime_workspace( + db=test_db, + user_id=test_user.id, + request=RuntimeWorkspaceRenameRequest( + deviceId="device-1", + workspacePath="/Users/crystal/Documents/hello-0/", + runtime="codex", + name=" Hello project ", + ), + ) + + assert response.accepted is True + assert response.workspace_path == "/Users/crystal/Documents/hello-0" + rpc.assert_awaited_once_with( + user_id=test_user.id, + device_id="device-1", + method="runtime.workspaces.rename", + payload={ + "runtime": "codex", + "workspacePath": "/Users/crystal/Documents/hello-0", + "label": "Hello project", + }, + timeout_seconds=60, + ) + + +@pytest.mark.asyncio +async def test_remove_runtime_workspace_dispatches_to_owned_device( + test_db, + test_user, + monkeypatch, +): + from app.schemas.runtime_work import RuntimeWorkspaceRemoveRequest + from app.services import runtime_work_service + + monkeypatch.setattr( + runtime_work_service.device_service, + "get_device_by_device_id", + lambda db, user_id, device_id: object(), + ) + rpc = AsyncMock( + return_value={ + "success": True, + "accepted": True, + "workspacePath": "/Users/crystal/Documents/hello-0", + "runtime": "codex", + } + ) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) + + response = await runtime_work_service.remove_runtime_workspace( + db=test_db, + user_id=test_user.id, + request=RuntimeWorkspaceRemoveRequest( + deviceId="device-1", + workspacePath="/Users/crystal/Documents/hello-0/", + runtime="codex", + ), + ) + + assert response.accepted is True + assert response.workspace_path == "/Users/crystal/Documents/hello-0" + rpc.assert_awaited_once_with( + user_id=test_user.id, + device_id="device-1", + method="runtime.workspaces.remove", + payload={ + "runtime": "codex", + "workspacePath": "/Users/crystal/Documents/hello-0", + }, + timeout_seconds=60, + ) + + @pytest.mark.asyncio async def test_create_runtime_task_uses_device_workspace_id_as_trusted_target( test_db, @@ -1732,7 +2272,7 @@ async def rpc(**kwargs): @pytest.mark.asyncio -async def test_fork_runtime_task_uses_target_git_project_for_unmapped_worktree( +async def test_fork_runtime_task_uses_target_git_project_for_non_project_worktree( test_db, test_user, monkeypatch, diff --git a/executor/agents/codex/config_builder.py b/executor/agents/codex/config_builder.py index a9ef413d1e..20ce45af37 100644 --- a/executor/agents/codex/config_builder.py +++ b/executor/agents/codex/config_builder.py @@ -74,6 +74,7 @@ class CodeXConfig: thread_config: dict[str, Any] effort: Optional[str] summary: Optional[str] + use_user_config: bool env: Optional[dict[str, str]] = None @@ -126,6 +127,7 @@ def build_codex_config( thread_config=_build_thread_config(reasoning, service_tier), effort=reasoning.get("effort"), summary=reasoning.get("summary"), + use_user_config=True, env=proxy_env, ) @@ -167,6 +169,7 @@ def build_codex_config( thread_config=_build_thread_config(reasoning, service_tier), effort=reasoning.get("effort"), summary=reasoning.get("summary"), + use_user_config=False, env=proxy_env, ) diff --git a/executor/runtime_work/codex_discovery.py b/executor/runtime_work/codex_discovery.py index dee7feaf0f..067df118e9 100644 --- a/executor/runtime_work/codex_discovery.py +++ b/executor/runtime_work/codex_discovery.py @@ -8,6 +8,7 @@ import json import os import re +import sqlite3 import time from collections import deque from dataclasses import dataclass @@ -71,32 +72,64 @@ def __init__( def discover(self) -> list[LocalTaskRecord]: try: - records = self._discover_with_sdk() + records = self._discover_with_sdk(archived=False) except Exception: logger.exception("Failed to list Codex threads through SDK") return [] return _sort_local_tasks(records) - def _discover_with_sdk(self) -> list[LocalTaskRecord]: + def discover_archived( + self, + *, + workspace_path: Optional[str] = None, + search_term: Optional[str] = None, + ) -> list[LocalTaskRecord]: + try: + records = self._discover_with_sdk( + archived=True, + workspace_path=workspace_path, + search_term=search_term, + ) + except Exception: + logger.exception("Failed to list archived Codex threads through SDK") + return [] + + return _sort_local_tasks(records) + + def _discover_with_sdk( + self, + *, + archived: bool, + workspace_path: Optional[str] = None, + search_term: Optional[str] = None, + ) -> list[LocalTaskRecord]: client = self._create_codex_client() with client as codex: response = codex.thread_list( limit=self.limit, - archived=False, + archived=archived, sort_direction=_codex_enum_value("SortDirection", "desc"), sort_key=_codex_enum_value("ThreadSortKey", "updated_at"), use_state_db_only=True, ) - return [ + records = [ task for task in ( - _thread_to_local_task(thread) + _thread_to_local_task( + thread, + status="archived" if archived else "active", + ) for thread in getattr(response, "data", []) ) if task is not None ] + return _filter_local_tasks( + records, + workspace_path=workspace_path, + search_term=search_term, + ) def _create_codex_client(self) -> Any: if self.codex_client_factory is not None: @@ -240,10 +273,93 @@ def _resolve_session_path( return path return None - def archive_thread(self, thread_id: str) -> None: + def archive_thread(self, thread_id: str) -> dict[str, Any]: + """Archive a Codex thread through the Codex SDK.""" + + normalized_thread_id = _required_thread_id(thread_id) client = self._create_codex_client() with client as codex: - codex.thread_archive(thread_id) + codex.thread_archive(normalized_thread_id) + return { + "threadId": normalized_thread_id, + "sdkUpdated": True, + } + + def unarchive_thread(self, thread_id: str) -> None: + client = self._create_codex_client() + with client as codex: + codex.thread_unarchive(thread_id) + + def rename_thread(self, thread_id: str, title: str) -> dict[str, Any]: + """Rename a Codex thread in the local Codex state used by Codex App.""" + + normalized_thread_id = _required_thread_id(thread_id) + normalized_title = _required_title(title) + sdk_updated = self._rename_thread_with_sdk( + normalized_thread_id, + normalized_title, + ) + state_path = _find_codex_state_path(self.codex_home) + state_result = {"updated": False, "matched": False} + if state_path is not None: + state_result = _rename_thread_state( + state_path, + normalized_thread_id, + normalized_title, + ) + return { + "threadId": normalized_thread_id, + "title": normalized_title, + "sdkUpdated": sdk_updated, + "stateUpdated": state_result["updated"], + "stateMatched": state_result["matched"], + "statePath": str(state_path) if state_path is not None else None, + } + + def _rename_thread_with_sdk(self, thread_id: str, title: str) -> bool: + client = self._create_codex_client() + with client as codex: + if _try_call_codex_rename(codex, thread_id, title): + return True + return False + + def delete_archived_thread( + self, + thread_id: str, + *, + session_path: Optional[str] = None, + ) -> dict[str, Any]: + normalized_thread_id = _required_thread_id(thread_id) + state_path = _find_codex_state_path(self.codex_home) + thread_state = None + deleted_state = False + + if state_path is not None: + thread_state, deleted_state = _delete_archived_thread_state( + state_path, + normalized_thread_id, + ) + + deleted_files = [] + for candidate in _unique_texts( + session_path, + _thread_state_text(thread_state, "rollout_path"), + ): + deleted_path = _delete_codex_owned_file(self.codex_home, candidate) + if deleted_path: + deleted_files.append(deleted_path) + self._transcript_cache.pop(deleted_path, None) + + return { + "threadId": normalized_thread_id, + "deletedState": deleted_state, + "deletedFiles": deleted_files, + "workspacePath": _thread_state_text(thread_state, "cwd"), + "title": _thread_state_text(thread_state, "title") + or _thread_state_text(thread_state, "preview"), + "createdAt": _codex_time_to_iso(_thread_state_value(thread_state, "created_at")), + "updatedAt": _codex_time_to_iso(_thread_state_value(thread_state, "updated_at")), + } async def stream_message( self, @@ -284,6 +400,7 @@ async def open_workspace(self, workspace_path: str) -> dict[str, Any]: thread = await codex.thread_start( cwd=normalized_workspace_path, service_name="wegent", + thread_source=_codex_thread_source_user(), ) thread_id = _object_text(thread, "id", "session_id") if not thread_id: @@ -321,7 +438,10 @@ async def stream_new_thread( ) client_config = self._codex_config_type()( codex_bin=codex_config.codex_bin, - config_overrides=codex_config.config_overrides, + config_overrides=_codex_lite_config_overrides( + codex_config.config_overrides, + use_user_config=_codex_config_uses_user_config(codex_config), + ), cwd=cwd, env=_codex_env(self.codex_home, codex_config.env), ) @@ -386,7 +506,11 @@ async def stream_new_thread( _cleanup_paths(attachment_result.model_input_files) -def _thread_to_local_task(thread: Any) -> Optional[LocalTaskRecord]: +def _thread_to_local_task( + thread: Any, + *, + status: str = "active", +) -> Optional[LocalTaskRecord]: thread_id = _object_text(thread, "id", "session_id") cwd = _object_text(thread, "cwd") if not thread_id or not cwd: @@ -419,10 +543,323 @@ def _thread_to_local_task(thread: Any) -> Optional[LocalTaskRecord]: updated_at=updated_at, running=_is_thread_running(thread) or _is_session_transcript_running(session_path), - status="active", + status=status, ) +def _filter_local_tasks( + records: list[LocalTaskRecord], + *, + workspace_path: Optional[str], + search_term: Optional[str], +) -> list[LocalTaskRecord]: + normalized_workspace = ( + normalize_workspace_path(workspace_path) if workspace_path else None + ) + normalized_search = search_term.strip().lower() if search_term else None + filtered = [] + for record in records: + if normalized_workspace and record.workspace_path != normalized_workspace: + continue + if normalized_search and normalized_search not in " ".join( + [ + record.title, + record.local_task_id, + record.workspace_path, + ] + ).lower(): + continue + filtered.append(record) + return filtered + + +def _required_thread_id(thread_id: str) -> str: + if not isinstance(thread_id, str) or not thread_id.strip(): + raise ValueError("thread_id is required") + return thread_id.strip() + + +def _required_title(title: str) -> str: + if not isinstance(title, str) or not title.strip(): + raise ValueError("title is required") + return title.strip() + + +def _try_call_codex_rename(codex: Any, thread_id: str, title: str) -> bool: + for method_name in ("thread_rename", "thread_update"): + method = getattr(codex, method_name, None) + if method is None: + continue + for kwargs in ({"title": title}, {"name": title}): + try: + method(thread_id, **kwargs) + return True + except TypeError: + continue + except Exception: + logger.exception("Failed to rename Codex thread through SDK") + return False + try: + method(thread_id, title) + return True + except TypeError: + continue + except Exception: + logger.exception("Failed to rename Codex thread through SDK") + return False + return False + + +def _find_codex_state_path(codex_home: Path) -> Optional[Path]: + roots = [ + codex_home, + codex_home / "sqlite", + codex_home / ".codex" / "sqlite", + ] + candidates = [ + root / "state_5.sqlite" + for root in roots + if root is not None + ] + for root in roots: + with contextlib.suppress(OSError): + candidates.extend( + sorted( + root.glob("state_*.sqlite"), + key=lambda path: path.name, + reverse=True, + ) + ) + seen = set() + for candidate in candidates: + candidate = candidate.expanduser() + if candidate in seen: + continue + seen.add(candidate) + if candidate.is_file(): + return candidate + return None + + +def _rename_thread_state( + state_path: Path, + thread_id: str, + title: str, +) -> dict[str, bool]: + try: + connection = sqlite3.connect(str(state_path), timeout=5.0) + connection.row_factory = sqlite3.Row + try: + with connection: + row = connection.execute( + "SELECT * FROM threads WHERE id = ?", + (thread_id,), + ).fetchone() + if row is None: + return {"updated": False, "matched": False} + + columns = _sqlite_table_columns(connection, "threads") + title_columns = [ + column for column in ("title", "preview") if column in columns + ] + if not title_columns: + return {"updated": False, "matched": False} + if all( + _sqlite_row_text(row, column) == title + for column in title_columns + ): + return {"updated": False, "matched": True} + + assignments = [] + values: list[Any] = [] + for column in title_columns: + assignments.append(f"{column} = ?") + values.append(title) + if "updated_at" in columns: + assignments.append("updated_at = ?") + values.append(_codex_now_for_column(row["updated_at"])) + if "updated_at_ms" in columns: + assignments.append("updated_at_ms = ?") + values.append(int(time.time() * 1000)) + + values.append(thread_id) + cursor = connection.execute( + f"UPDATE threads SET {', '.join(assignments)} WHERE id = ?", + tuple(values), + ) + updated = cursor.rowcount > 0 + return {"updated": updated, "matched": updated} + finally: + connection.close() + except sqlite3.Error as exc: + raise ValueError(f"Failed to rename Codex thread state: {exc}") from exc + + +def _sqlite_row_text(row: sqlite3.Row, column: str) -> Optional[str]: + value = row[column] + return value if isinstance(value, str) else None + + +def _codex_now_for_column(existing_value: Any) -> Any: + if isinstance(existing_value, int): + return int(time.time()) + if isinstance(existing_value, float): + return time.time() + if isinstance(existing_value, str): + return utc_now_iso() + return int(time.time()) + + +def _delete_archived_thread_state( + state_path: Path, + thread_id: str, +) -> tuple[Optional[dict[str, Any]], bool]: + try: + connection = sqlite3.connect(str(state_path), timeout=5.0) + connection.row_factory = sqlite3.Row + try: + with connection: + row = connection.execute( + "SELECT * FROM threads WHERE id = ?", + (thread_id,), + ).fetchone() + if row is None: + return None, False + + thread_state = dict(row) + archived = int(thread_state.get("archived") or 0) + if archived != 1: + raise ValueError("Only archived Codex threads can be deleted") + + _delete_codex_thread_child_rows(connection, thread_id) + cursor = connection.execute( + "DELETE FROM threads WHERE id = ?", + (thread_id,), + ) + return thread_state, cursor.rowcount > 0 + finally: + connection.close() + except sqlite3.Error as exc: + raise ValueError(f"Failed to delete Codex thread state: {exc}") from exc + + +def _delete_codex_thread_child_rows( + connection: sqlite3.Connection, + thread_id: str, +) -> None: + if _sqlite_table_exists(connection, "thread_dynamic_tools"): + columns = _sqlite_table_columns(connection, "thread_dynamic_tools") + if "thread_id" in columns: + connection.execute( + "DELETE FROM thread_dynamic_tools WHERE thread_id = ?", + (thread_id,), + ) + + if not _sqlite_table_exists(connection, "thread_spawn_edges"): + return + + columns = _sqlite_table_columns(connection, "thread_spawn_edges") + clauses = [] + values = [] + for column in ("thread_id", "parent_thread_id", "child_thread_id"): + if column in columns: + clauses.append(f"{column} = ?") + values.append(thread_id) + if clauses: + connection.execute( + f"DELETE FROM thread_spawn_edges WHERE {' OR '.join(clauses)}", + tuple(values), + ) + + +def _sqlite_table_exists( + connection: sqlite3.Connection, + table_name: str, +) -> bool: + row = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (table_name,), + ).fetchone() + return row is not None + + +def _sqlite_table_columns( + connection: sqlite3.Connection, + table_name: str, +) -> set[str]: + return { + str(row[1]) + for row in connection.execute(f"PRAGMA table_info({table_name})").fetchall() + } + + +def _delete_codex_owned_file(codex_home: Path, raw_path: str) -> Optional[str]: + if not isinstance(raw_path, str) or not raw_path.strip(): + return None + + path = Path(raw_path).expanduser() + if not path.is_absolute(): + path = codex_home / path + resolved_path = path.resolve(strict=False) + if not any( + _path_is_relative_to(resolved_path, root) + for root in _codex_owned_roots(codex_home) + ): + logger.warning("Skipping Codex file delete outside Codex home: %s", path) + return None + + if not path.is_file(): + return None + path.unlink() + return str(resolved_path) + + +def _codex_owned_roots(codex_home: Path) -> list[Path]: + roots = [codex_home, codex_home / ".codex"] + if codex_home.name == ".codex": + roots.append(codex_home.parent / ".codex") + return [root.expanduser().resolve(strict=False) for root in roots] + + +def _path_is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def _unique_texts(*values: Optional[str]) -> list[str]: + seen = set() + result = [] + for value in values: + if not isinstance(value, str) or not value.strip(): + continue + normalized = value.strip() + if normalized in seen: + continue + seen.add(normalized) + result.append(normalized) + return result + + +def _thread_state_text( + thread_state: Optional[dict[str, Any]], + key: str, +) -> Optional[str]: + value = _thread_state_value(thread_state, key) + return value.strip() if isinstance(value, str) and value.strip() else None + + +def _thread_state_value( + thread_state: Optional[dict[str, Any]], + key: str, +) -> Any: + if not isinstance(thread_state, dict): + return None + return thread_state.get(key) + + def _codex_thread_workspace_kind(cwd: str) -> str: return "chat" if _is_codex_app_conversation_path(cwd) else "workspace" @@ -643,14 +1080,53 @@ def _thread_start_kwargs( "model": codex_config.model, "sandbox": sandbox, "service_name": "wegent", + "thread_source": _codex_thread_source_user(), } if cwd: kwargs["cwd"] = cwd - if codex_config.model_provider: + if not _codex_config_uses_user_config(codex_config) and codex_config.model_provider: kwargs["model_provider"] = codex_config.model_provider return kwargs +def _codex_config_uses_user_config(codex_config: Any) -> bool: + return bool(getattr(codex_config, "use_user_config", False)) + + +def _codex_lite_config_overrides( + overrides: Iterable[str], + *, + use_user_config: bool, +) -> tuple[str, ...]: + if not use_user_config: + return tuple(override for override in overrides if isinstance(override, str)) + return tuple( + override + for override in overrides + if isinstance(override, str) and not _is_model_provider_override(override) + ) + + +def _is_model_provider_override(override: str) -> bool: + normalized = override.strip() + return normalized.startswith( + ( + "forced_login_method=", + "model_provider=", + "model_providers.", + ) + ) + + +def _codex_thread_source_user() -> Any: + try: + from openai_codex.generated.v2_all import ThreadSource + + return ThreadSource.user + except (ImportError, AttributeError): + return "user" + + def _turn_reasoning_kwargs(codex_config: Any) -> dict[str, Any]: kwargs: dict[str, Any] = {} if codex_config.effort: diff --git a/executor/runtime_work/codex_global_state.py b/executor/runtime_work/codex_global_state.py new file mode 100644 index 0000000000..ee2a97cbeb --- /dev/null +++ b/executor/runtime_work/codex_global_state.py @@ -0,0 +1,421 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Partial reader for Codex App global project state.""" + +import json +import os +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Literal, Optional +from urllib.parse import quote + +from executor.runtime_work.local_task_store import normalize_workspace_path + +CODEX_GLOBAL_STATE_FILENAME = ".codex-global-state.json" +SAVED_WORKSPACE_ROOTS_KEY = "electron-saved-workspace-roots" +WORKSPACE_ROOT_LABELS_KEY = "electron-workspace-root-labels" +PROJECT_ORDER_KEY = "project-order" +CodexProjectSource = Literal["local", "remote"] + + +@dataclass(frozen=True) +class CodexRemoteProject: + id: str + host_id: str + remote_path: str + label: Optional[str] = None + + +@dataclass(frozen=True) +class CodexGlobalProject: + key: str + name: str + workspace_path: str + source: CodexProjectSource + host_id: Optional[str] = None + order_index: Optional[int] = None + + +@dataclass(frozen=True) +class CodexGlobalProjectIndex: + projects: list[CodexGlobalProject] + projects_by_key: dict[str, CodexGlobalProject] + projectless_thread_ids: set[str] + thread_workspace_root_hints: dict[str, str] + path_projects: list[tuple[str, CodexGlobalProject]] + + def project_for_thread( + self, + thread_id: str, + workspace_path: str, + ) -> Optional[CodexGlobalProject]: + if thread_id in self.projectless_thread_ids: + return None + + hinted_root = self.thread_workspace_root_hints.get(thread_id) + if hinted_root: + hinted_project = self.projects_by_key.get(_normalize_path_or_raw(hinted_root)) + if hinted_project is not None: + return hinted_project + + normalized_path = normalize_workspace_path(workspace_path) + for root, project in self.path_projects: + if _path_is_within_normalized(normalized_path, root): + return project + return None + + +@dataclass(frozen=True) +class CodexGlobalState: + saved_workspace_roots: list[str] + remote_projects: list[CodexRemoteProject] + project_order: list[str] + workspace_root_labels: dict[str, str] + projectless_thread_ids: set[str] + thread_workspace_root_hints: dict[str, str] + + @property + def has_project_roots(self) -> bool: + return bool(self.saved_workspace_roots) + + def projects(self) -> list[CodexGlobalProject]: + order_index = {key: index for index, key in enumerate(self.project_order)} + projects: list[CodexGlobalProject] = [] + for root in self.saved_workspace_roots: + projects.append( + CodexGlobalProject( + key=root, + name=self.workspace_root_labels.get(root) or _path_basename(root), + workspace_path=root, + source="local", + order_index=order_index.get(root), + ) + ) + return sorted( + projects, + key=lambda project: ( + project.order_index is None, + project.order_index if project.order_index is not None else 0, + project.name.lower(), + project.key, + ), + ) + + def project_index(self) -> CodexGlobalProjectIndex: + projects = self.projects() + projects_by_key: dict[str, CodexGlobalProject] = {} + path_projects: list[tuple[str, CodexGlobalProject]] = [] + for project in projects: + projects_by_key[project.key] = project + projects_by_key[project.workspace_path] = project + path_projects.append((project.workspace_path, project)) + return CodexGlobalProjectIndex( + projects=projects, + projects_by_key=projects_by_key, + projectless_thread_ids=self.projectless_thread_ids, + thread_workspace_root_hints=self.thread_workspace_root_hints, + path_projects=sorted( + path_projects, + key=lambda item: len(item[0]), + reverse=True, + ), + ) + + def project_for_thread( + self, + thread_id: str, + workspace_path: str, + ) -> Optional[CodexGlobalProject]: + return self.project_index().project_for_thread(thread_id, workspace_path) + + +def load_codex_global_state(codex_home: Path) -> Optional[CodexGlobalState]: + payload = _read_global_state_payload(codex_home) + if payload is None: + return None + + saved_roots = _normalized_text_list(payload.get(SAVED_WORKSPACE_ROOTS_KEY)) + project_order = _text_list(payload.get(PROJECT_ORDER_KEY)) + workspace_root_labels = _string_map(payload.get(WORKSPACE_ROOT_LABELS_KEY)) + thread_workspace_root_hints = { + thread_id: _normalize_path_or_raw(root) + for thread_id, root in _string_map(payload.get("thread-workspace-root-hints")).items() + } + return CodexGlobalState( + saved_workspace_roots=saved_roots, + remote_projects=_remote_projects(payload.get("remote-projects")), + project_order=project_order, + workspace_root_labels={ + _normalize_path_or_raw(root): label for root, label in workspace_root_labels.items() + }, + projectless_thread_ids=set(_text_list(payload.get("projectless-thread-ids"))), + thread_workspace_root_hints=thread_workspace_root_hints, + ) + + +def ensure_codex_global_project( + codex_home: Path, + workspace_path: str, + *, + label: Optional[str] = None, +) -> CodexGlobalProject: + """Add a local project root to Codex global state without creating a thread.""" + + normalized_workspace = normalize_workspace_path(workspace_path) + + def mutate(payload: dict[str, Any]) -> None: + _upsert_text_list( + payload, + SAVED_WORKSPACE_ROOTS_KEY, + normalized_workspace, + prepend=True, + ) + _upsert_text_list( + payload, + PROJECT_ORDER_KEY, + normalized_workspace, + prepend=True, + ) + _set_workspace_label(payload, normalized_workspace, label) + + _update_global_state_payload(codex_home, mutate) + return CodexGlobalProject( + key=normalized_workspace, + name=label or _path_basename(normalized_workspace), + workspace_path=normalized_workspace, + source="local", + ) + + +def notify_running_codex_app_workspace_root(workspace_path: str) -> bool: + """Ask a running macOS Codex App to register the project in its UI state.""" + + if sys.platform != "darwin" or not _is_codex_app_running(): + return False + + normalized_workspace = normalize_workspace_path(workspace_path) + deeplink = f"codex://new?path={quote(normalized_workspace, safe='')}" + try: + result = subprocess.run( + ["open", "-g", deeplink], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=3, + ) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 + + +def rename_codex_global_project( + codex_home: Path, + workspace_path: str, + label: str, +) -> CodexGlobalProject: + """Update the display label for a local project root.""" + + normalized_workspace = normalize_workspace_path(workspace_path) + normalized_label = label.strip() + if not normalized_label: + raise ValueError("label is required") + + def mutate(payload: dict[str, Any]) -> None: + _upsert_text_list(payload, SAVED_WORKSPACE_ROOTS_KEY, normalized_workspace) + _upsert_text_list(payload, PROJECT_ORDER_KEY, normalized_workspace) + _set_workspace_label(payload, normalized_workspace, normalized_label) + + _update_global_state_payload(codex_home, mutate) + return CodexGlobalProject( + key=normalized_workspace, + name=normalized_label, + workspace_path=normalized_workspace, + source="local", + ) + + +def remove_codex_global_project(codex_home: Path, workspace_path: str) -> str: + """Remove a local project root from Codex global state without deleting threads.""" + + normalized_workspace = normalize_workspace_path(workspace_path) + + def mutate(payload: dict[str, Any]) -> None: + _remove_text_list_item(payload, SAVED_WORKSPACE_ROOTS_KEY, normalized_workspace) + _remove_text_list_item(payload, PROJECT_ORDER_KEY, normalized_workspace) + labels = payload.get(WORKSPACE_ROOT_LABELS_KEY) + if isinstance(labels, dict): + labels.pop(normalized_workspace, None) + + _update_global_state_payload(codex_home, mutate) + return normalized_workspace + + +def _read_global_state_payload(codex_home: Path) -> Optional[dict[str, Any]]: + path = Path(codex_home).expanduser() / CODEX_GLOBAL_STATE_FILENAME + if not path.is_file(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return payload if isinstance(payload, dict) else None + + +def _is_codex_app_running() -> bool: + try: + result = subprocess.run( + ["pgrep", "-f", "/Applications/Codex.app/Contents"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=1, + ) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 + + +def _update_global_state_payload( + codex_home: Path, + mutate: Callable[[dict[str, Any]], None], +) -> dict[str, Any]: + codex_home = Path(codex_home).expanduser() + path = codex_home / CODEX_GLOBAL_STATE_FILENAME + payload = _read_global_state_payload(codex_home) or {} + mutate(payload) + codex_home.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=codex_home, + prefix=f".{CODEX_GLOBAL_STATE_FILENAME}.", + suffix=".tmp", + delete=False, + ) as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + temporary_path = Path(handle.name) + os.replace(temporary_path, path) + return payload + + +def _upsert_text_list( + payload: dict[str, Any], + key: str, + item: str, + *, + prepend: bool = False, +) -> None: + values = _text_list(payload.get(key)) + if item not in values: + if prepend: + values.insert(0, item) + else: + values.append(item) + payload[key] = values + + +def _remove_text_list_item( + payload: dict[str, Any], + key: str, + item: str, +) -> None: + payload[key] = [value for value in _text_list(payload.get(key)) if value != item] + + +def _set_workspace_label( + payload: dict[str, Any], + workspace_path: str, + label: Optional[str], +) -> None: + normalized_label = label.strip() if isinstance(label, str) else "" + labels = payload.get(WORKSPACE_ROOT_LABELS_KEY) + if not isinstance(labels, dict): + labels = {} + if normalized_label: + labels[workspace_path] = normalized_label + else: + labels.pop(workspace_path, None) + payload[WORKSPACE_ROOT_LABELS_KEY] = labels + + +def _remote_projects(value: Any) -> list[CodexRemoteProject]: + if not isinstance(value, list): + return [] + + projects = [] + for item in value: + if not isinstance(item, dict): + continue + project_id = _clean_text(item.get("id")) + host_id = _clean_text(item.get("hostId")) + remote_path = _clean_text(item.get("remotePath")) + if not project_id or not host_id or not remote_path: + continue + projects.append( + CodexRemoteProject( + id=project_id, + host_id=host_id, + remote_path=normalize_workspace_path(remote_path), + label=_clean_text(item.get("label")), + ) + ) + return projects + + +def _normalized_text_list(value: Any) -> list[str]: + return [_normalize_path_or_raw(item) for item in _text_list(value)] + + +def _text_list(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + result = [] + seen = set() + for item in value: + text = _clean_text(item) + if not text or text in seen: + continue + seen.add(text) + result.append(text) + return result + + +def _string_map(value: Any) -> dict[str, str]: + if not isinstance(value, dict): + return {} + result = {} + for key, item in value.items(): + clean_key = _clean_text(key) + clean_item = _clean_text(item) + if clean_key and clean_item: + result[clean_key] = clean_item + return result + + +def _clean_text(value: Any) -> Optional[str]: + return value.strip() if isinstance(value, str) and value.strip() else None + + +def _normalize_path_or_raw(value: str) -> str: + try: + return normalize_workspace_path(value) + except ValueError: + return value.strip() + + +def _path_basename(path: str) -> str: + normalized = path.rstrip(os.sep) + return Path(normalized).name or normalized + + +def _path_is_within_normalized(path: str, root: str) -> bool: + try: + return os.path.commonpath([path, root]) == root + except ValueError: + return False diff --git a/executor/runtime_work/local_task_store.py b/executor/runtime_work/local_task_store.py index 63cd134c35..a405a7be85 100644 --- a/executor/runtime_work/local_task_store.py +++ b/executor/runtime_work/local_task_store.py @@ -231,6 +231,29 @@ def update_task( self._write_index(index) return normalized + def delete_task( + self, + local_task_id: str, + workspace_path: Optional[str] = None, + ) -> Optional[LocalTaskRecord]: + """Delete one task atomically and return the removed record.""" + + with self._lock: + index = self._read_index() + payload = index["tasks"].get(local_task_id) + if payload is None: + return None + + current = self._payload_to_record(payload) + if workspace_path and current.workspace_path != normalize_workspace_path( + workspace_path + ): + raise KeyError(f"Local task not found in workspace: {local_task_id}") + + index["tasks"].pop(local_task_id, None) + self._write_index(index) + return current + @classmethod def _lock_for(cls, path: Path) -> threading.RLock: with cls._locks_guard: diff --git a/executor/runtime_work/rpc_handler.py b/executor/runtime_work/rpc_handler.py index 3398674d32..03c4dfd892 100644 --- a/executor/runtime_work/rpc_handler.py +++ b/executor/runtime_work/rpc_handler.py @@ -19,10 +19,19 @@ from executor.config import config from executor.runtime_work.agent_adapter import RuntimeAgentAdapter from executor.runtime_work.codex_discovery import CodexSessionDiscovery +from executor.runtime_work.codex_global_state import ( + CodexGlobalProject, + CodexGlobalProjectIndex, + CodexGlobalState, + ensure_codex_global_project, + load_codex_global_state, + notify_running_codex_app_workspace_root, + remove_codex_global_project, + rename_codex_global_project, +) from executor.runtime_work.local_task_store import ( LocalTaskRecord, LocalTaskStore, - LocalWorkspaceRecord, normalize_workspace_path, utc_now_iso, ) @@ -174,6 +183,12 @@ async def handle_runtime_rpc(self, data: dict[str, Any]) -> dict[str, Any]: if method == "runtime.workspaces.open": response = await self._open_workspace(payload) return _compress_runtime_rpc_response(response, method=method) + if method == "runtime.workspaces.rename": + response = self._rename_workspace(payload) + return _compress_runtime_rpc_response(response, method=method) + if method == "runtime.workspaces.remove": + response = self._remove_workspace(payload) + return _compress_runtime_rpc_response(response, method=method) if method == "runtime.tasks.transcript": response = await self._transcript(payload) return _compress_runtime_rpc_response(response, method=method) @@ -183,6 +198,21 @@ async def handle_runtime_rpc(self, data: dict[str, Any]) -> dict[str, Any]: if method == "runtime.tasks.archive": response = await self._archive(payload) return _compress_runtime_rpc_response(response, method=method) + if method == "runtime.tasks.rename": + response = await self._rename(payload) + return _compress_runtime_rpc_response(response, method=method) + if method == "runtime.archived_conversations.list": + response = self._list_archived_conversations(payload) + return _compress_runtime_rpc_response(response, method=method) + if method == "runtime.archived_conversations.unarchive": + response = await self._unarchive_archived_conversation(payload) + return _compress_runtime_rpc_response(response, method=method) + if method == "runtime.archived_conversations.delete": + response = await self._delete_archived_conversation(payload) + return _compress_runtime_rpc_response(response, method=method) + if method == "runtime.archived_conversations.delete_bulk": + response = await self._delete_archived_conversations_bulk(payload) + return _compress_runtime_rpc_response(response, method=method) if method == "runtime.tasks.status": response = self._status(payload) return _compress_runtime_rpc_response(response, method=method) @@ -218,41 +248,229 @@ async def handle_runtime_rpc(self, data: dict[str, Any]) -> dict[str, Any]: return self._error(str(exc), code="internal_error") def _list_tasks(self, payload: dict[str, Any]) -> dict[str, Any]: + started_at = time.perf_counter() + discover_started_at = time.perf_counter() discovered_tasks = self._refresh_discovered_tasks() + discover_ms = int((time.perf_counter() - discover_started_at) * 1000) workspace_path = payload.get("workspacePath") include_archived = bool(payload.get("includeArchived", False)) + store_started_at = time.perf_counter() store_tasks = self.store.list_tasks( workspace_path=workspace_path, include_archived=include_archived, ) - store_workspaces = self.store.list_workspaces( - workspace_path=workspace_path, - include_archived=include_archived, - ) + store_ms = int((time.perf_counter() - store_started_at) * 1000) + visible_started_at = time.perf_counter() tasks = self._list_visible_tasks( store_tasks=store_tasks, discovered_tasks=discovered_tasks, workspace_path=workspace_path, include_archived=include_archived, ) + visible_ms = int((time.perf_counter() - visible_started_at) * 1000) + global_state_started_at = time.perf_counter() + codex_global_state = self._load_codex_global_state() + codex_project_index = ( + codex_global_state.project_index() + if codex_global_state is not None + else None + ) + global_state_ms = int((time.perf_counter() - global_state_started_at) * 1000) + group_started_at = time.perf_counter() grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) - for workspace in store_workspaces: - grouped[workspace.workspace_path] = [] + workspace_labels: dict[str, str] = {} + workspace_metadata: dict[str, dict[str, str]] = {} + for project in self._codex_global_projects(codex_project_index, workspace_path): + grouped[project.workspace_path] = [] + workspace_labels[project.workspace_path] = project.name + workspace_metadata[project.workspace_path] = { + "workspaceSource": project.source, + } + if project.host_id: + workspace_metadata[project.workspace_path]["remoteHostId"] = ( + project.host_id + ) for task in tasks: - grouped[task.workspace_path].append(self._task_summary(task)) + group_workspace = self._task_group_workspace(task, codex_project_index) + if group_workspace is None: + continue + grouped[group_workspace].append(self._task_summary(task)) + group_ms = int((time.perf_counter() - group_started_at) * 1000) + workspaces = self._workspace_summaries( + grouped, + workspace_labels, + workspace_metadata, + ) + logger.info( + "[RuntimeWorkRpc] Listed runtime tasks: workspace_path=%s include_archived=%s discovered_count=%s store_task_count=%s visible_task_count=%s workspace_count=%s elapsed_ms=%s discover_ms=%s store_ms=%s visible_ms=%s global_state_ms=%s group_ms=%s global_state_loaded=%s", + workspace_path, + include_archived, + len(discovered_tasks), + len(store_tasks), + len(tasks), + len(workspaces), + int((time.perf_counter() - started_at) * 1000), + discover_ms, + store_ms, + visible_ms, + global_state_ms, + group_ms, + codex_global_state is not None, + ) return { "success": True, - "workspaces": [ + "workspaces": workspaces, + } + + def _list_archived_conversations(self, payload: dict[str, Any]) -> dict[str, Any]: + workspace_path = payload.get("workspacePath") + if workspace_path is not None and not isinstance(workspace_path, str): + raise ValueError("workspacePath must be a string") + + search_term = payload.get("search") + if search_term is not None and not isinstance(search_term, str): + raise ValueError("search must be a string") + + codex_project_index = self._codex_project_index() + archived_tasks = [ + task + for task in self._list_archived_tasks( + workspace_path=workspace_path, + search_term=search_term, + ) + if self._is_visible_runtime_task_scope(task, codex_project_index) + ] + grouped: dict[str, int] = defaultdict(int) + for task in archived_tasks: + grouped[task.workspace_path] += 1 + + return { + "success": True, + "items": [ + self._archived_conversation_summary(task) for task in archived_tasks + ], + "groups": [ { - "workspacePath": workspace_path, - "localTasks": workspace_tasks, + "workspacePath": workspace, + "count": count, } - for workspace_path, workspace_tasks in sorted(grouped.items()) + for workspace, count in sorted(grouped.items()) ], } + def _load_codex_global_state(self) -> Optional[CodexGlobalState]: + codex_home = self._codex_home() + if codex_home is None: + return None + return load_codex_global_state(codex_home) + + def _codex_project_index(self) -> Optional[CodexGlobalProjectIndex]: + codex_global_state = self._load_codex_global_state() + if codex_global_state is None: + return None + return codex_global_state.project_index() + + def _codex_home(self) -> Optional[Any]: + if self.codex_discovery is None: + return None + return getattr(self.codex_discovery, "codex_home", None) + + def _codex_global_projects( + self, + codex_project_index: Optional[CodexGlobalProjectIndex], + workspace_path: Any, + ) -> list[CodexGlobalProject]: + if codex_project_index is None: + return [] + normalized_workspace = ( + normalize_workspace_path(workspace_path) + if isinstance(workspace_path, str) and workspace_path.strip() + else None + ) + return [ + project + for project in codex_project_index.projects + if normalized_workspace is None + or project.workspace_path == normalized_workspace + ] + + def _task_group_workspace( + self, + task: LocalTaskRecord, + codex_project_index: Optional[CodexGlobalProjectIndex], + ) -> Optional[str]: + if task.workspace_kind == "chat": + return task.workspace_path + if not self._is_codex_runtime(task.runtime): + return None + if codex_project_index is None or not codex_project_index.projects: + return None + + thread_id = self._sdk_codex_thread_id(task) or task.local_task_id + project = codex_project_index.project_for_thread(thread_id, task.workspace_path) + if project is not None: + return project.workspace_path + return None + + def _is_visible_runtime_task_scope( + self, + task: LocalTaskRecord, + codex_project_index: Optional[CodexGlobalProjectIndex], + ) -> bool: + return self._task_group_workspace(task, codex_project_index) is not None + + def _workspace_summaries( + self, + grouped: dict[str, list[dict[str, Any]]], + workspace_labels: dict[str, str], + workspace_metadata: dict[str, dict[str, str]], + ) -> list[dict[str, Any]]: + summaries = [] + for workspace_path, workspace_tasks in grouped.items(): + summary = { + "workspacePath": workspace_path, + "localTasks": workspace_tasks, + } + summary.update(workspace_metadata.get(workspace_path, {})) + label = workspace_labels.get(workspace_path) + if label: + summary["label"] = label + summaries.append(summary) + return summaries + + def _list_archived_tasks( + self, + *, + workspace_path: Any, + search_term: Any, + ) -> list[LocalTaskRecord]: + tasks: list[LocalTaskRecord] = [] + if self.codex_discovery is not None: + discover_archived = getattr( + self.codex_discovery, + "discover_archived", + None, + ) + if callable(discover_archived): + tasks.extend( + discover_archived( + workspace_path=workspace_path, + search_term=search_term, + ) + ) + + tasks.extend( + task + for task in self.store.list_tasks( + workspace_path=workspace_path, + include_archived=True, + ) + if task.status == "archived" and not self._is_sdk_codex_task(task) + ) + return self._dedupe_and_sort_tasks(tasks) + async def _open_workspace(self, payload: dict[str, Any]) -> dict[str, Any]: runtime = str(payload.get("runtime") or "codex").strip() if not self._is_codex_runtime(runtime): @@ -265,44 +483,126 @@ async def _open_workspace(self, payload: dict[str, Any]) -> dict[str, Any]: raise ValueError("workspacePath is required") normalized_workspace_path = normalize_workspace_path(workspace_path) - open_workspace = getattr(self.codex_discovery, "open_workspace", None) - if not callable(open_workspace): + codex_home = self._codex_home() + if codex_home is None: return self._error( "Codex SDK workspace adapter is not available", code="unsupported_runtime", ) - result = await self._maybe_await(open_workspace(normalized_workspace_path)) - if not isinstance(result, dict): - result = {} - thread_id = result.get("threadId") - runtime_handle = {"openedBy": "wegent"} - if isinstance(thread_id, str) and thread_id.strip(): - runtime_handle["threadId"] = thread_id.strip() - self.store.upsert_workspace( - LocalWorkspaceRecord( - workspace_path=normalized_workspace_path, - runtime="codex", - title=self._workspace_title(result, normalized_workspace_path), - runtime_handle=runtime_handle, + label = payload.get("label") + project = ensure_codex_global_project( + codex_home, + normalized_workspace_path, + label=label if isinstance(label, str) else None, + ) + codex_app_notified = notify_running_codex_app_workspace_root( + normalized_workspace_path + ) + refreshed_state = load_codex_global_state(codex_home) + persisted_roots = ( + refreshed_state.saved_workspace_roots if refreshed_state is not None else [] + ) + if project.workspace_path not in persisted_roots: + return self._error( + "Codex global project state was not persisted", + code="codex_global_state_not_persisted", ) + logger.info( + "[RuntimeWorkRpc] Registered Codex workspace: codex_home=%s " + "workspace_path=%s codex_app_notified=%s saved_root_count=%s", + codex_home, + project.workspace_path, + codex_app_notified, + len(persisted_roots), + ) + return { + "success": True, + "accepted": True, + "runtime": "codex", + "workspacePath": project.workspace_path, + } + + def _rename_workspace(self, payload: dict[str, Any]) -> dict[str, Any]: + runtime = str(payload.get("runtime") or "codex").strip() + if not self._is_codex_runtime(runtime): + return self._error( + "Only Codex runtime workspaces can be renamed without a turn", + code="unsupported_runtime", + ) + workspace_path = payload.get("workspacePath") or payload.get("workspace_path") + if not isinstance(workspace_path, str) or not workspace_path.strip(): + raise ValueError("workspacePath is required") + label = payload.get("label") or payload.get("name") + if not isinstance(label, str) or not label.strip(): + raise ValueError("label is required") + + codex_home = self._codex_home() + if codex_home is None: + return self._error( + "Codex SDK workspace adapter is not available", + code="unsupported_runtime", + ) + + project = rename_codex_global_project( + codex_home, + normalize_workspace_path(workspace_path), + label.strip(), + ) + return { + "success": True, + "accepted": True, + "runtime": "codex", + "workspacePath": project.workspace_path, + } + + def _remove_workspace(self, payload: dict[str, Any]) -> dict[str, Any]: + runtime = str(payload.get("runtime") or "codex").strip() + if not self._is_codex_runtime(runtime): + return self._error( + "Only Codex runtime workspaces can be removed without a turn", + code="unsupported_runtime", + ) + workspace_path = payload.get("workspacePath") or payload.get("workspace_path") + if not isinstance(workspace_path, str) or not workspace_path.strip(): + raise ValueError("workspacePath is required") + + codex_home = self._codex_home() + if codex_home is None: + return self._error( + "Codex SDK workspace adapter is not available", + code="unsupported_runtime", + ) + + normalized_workspace_path = remove_codex_global_project( + codex_home, + normalize_workspace_path(workspace_path), ) return { "success": True, "accepted": True, "runtime": "codex", "workspacePath": normalized_workspace_path, - "threadId": runtime_handle.get("threadId"), } def _refresh_discovered_tasks(self) -> list[LocalTaskRecord]: if self.codex_discovery is None: return [] + started_at = time.perf_counter() try: - return self.codex_discovery.discover() + records = self.codex_discovery.discover() except Exception: - logger.exception("Failed to discover Codex runtime tasks") + logger.exception( + "Failed to discover Codex runtime tasks: elapsed_ms=%s", + int((time.perf_counter() - started_at) * 1000), + ) return [] + logger.info( + "[RuntimeWorkRpc] Codex discovery completed: record_count=%s elapsed_ms=%s", + len(records), + int((time.perf_counter() - started_at) * 1000), + ) + return records async def poll_codex_updates_once(self) -> None: """Detect native Codex thread updates and report them to Backend.""" @@ -442,6 +742,7 @@ def _list_visible_tasks( include_archived: bool, ) -> list[LocalTaskRecord]: visible_tasks: list[LocalTaskRecord] = [] + title_overrides = self._codex_title_overrides(store_tasks) for task in store_tasks: if not self._is_codex_runtime(task.runtime): visible_tasks.append(task) @@ -453,7 +754,7 @@ def _list_visible_tasks( if thread_id } visible_tasks.extend( - task + self._apply_codex_title_override(task, title_overrides) for task in self._sdk_codex_task_records.values() if task.local_task_id not in discovered_ids and self._include_discovered_task( @@ -463,7 +764,7 @@ def _list_visible_tasks( ) ) visible_tasks.extend( - task + self._apply_codex_title_override(task, title_overrides) for task in discovered_tasks if task.local_task_id not in active_memory_thread_ids if self._include_discovered_task( @@ -481,6 +782,27 @@ def _list_visible_tasks( reverse=True, ) + def _codex_title_overrides( + self, + store_tasks: list[LocalTaskRecord], + ) -> dict[tuple[str, str], LocalTaskRecord]: + return { + (task.local_task_id, task.workspace_path): task + for task in store_tasks + if self._is_codex_runtime(task.runtime) + and task.runtime_handle.get("titleOverride") is True + } + + def _apply_codex_title_override( + self, + task: LocalTaskRecord, + title_overrides: dict[tuple[str, str], LocalTaskRecord], + ) -> LocalTaskRecord: + override = title_overrides.get((task.local_task_id, task.workspace_path)) + if override is None: + return task + return replace(task, title=override.title, updated_at=override.updated_at) + def _include_discovered_task( self, task: LocalTaskRecord, @@ -900,6 +1222,162 @@ async def _archive(self, payload: dict[str, Any]) -> dict[str, Any]: "workspacePath": archived.workspace_path, } + async def _rename(self, payload: dict[str, Any]) -> dict[str, Any]: + task = self._load_payload_task(payload) + title = payload.get("title") + if not isinstance(title, str) or not title.strip(): + raise ValueError("title is required") + normalized_title = title.strip() + + def rename_record(current: LocalTaskRecord) -> LocalTaskRecord: + runtime_handle = current.runtime_handle + if self._is_codex_runtime(current.runtime): + runtime_handle = {**current.runtime_handle, "titleOverride": True} + return replace(current, title=normalized_title, runtime_handle=runtime_handle) + + codex_rename: Optional[dict[str, Any]] = None + if self._is_codex_runtime(task.runtime) and self.codex_discovery is not None: + thread_id = self._sdk_codex_thread_id(task) or task.local_task_id + codex_rename = self.codex_discovery.rename_thread( + thread_id, + normalized_title, + ) + + try: + renamed = self.store.update_task( + task.local_task_id, + rename_record, + workspace_path=task.workspace_path, + ) + except KeyError: + if not self._is_codex_runtime(task.runtime): + raise + renamed = rename_record(task) + self.store.upsert_task(renamed) + + if task.local_task_id in self._sdk_codex_task_records: + self._remember_sdk_codex_task(renamed) + return { + "success": True, + "accepted": True, + "localTaskId": renamed.local_task_id, + "workspacePath": renamed.workspace_path, + "codexRename": codex_rename, + } + + async def _unarchive_archived_conversation( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + task = self._load_archived_payload_task(payload) + + if self._is_codex_runtime(task.runtime) and self.codex_discovery is not None: + thread_id = task.runtime_handle.get("threadId") or task.local_task_id + unarchive_thread = getattr(self.codex_discovery, "unarchive_thread", None) + if isinstance(thread_id, str) and thread_id.strip() and unarchive_thread: + await self._maybe_await(unarchive_thread(thread_id)) + + if self._is_sdk_codex_task(task): + active_task = replace(task, status="active", updated_at=utc_now_iso()) + self._remember_sdk_codex_task(active_task) + return { + "success": True, + "accepted": True, + "localTaskId": task.local_task_id, + "workspacePath": task.workspace_path, + } + + unarchived = self.store.update_task( + task.local_task_id, + lambda current: replace(current, running=False, status="active"), + workspace_path=task.workspace_path, + ) + return { + "success": True, + "accepted": True, + "localTaskId": unarchived.local_task_id, + "workspacePath": unarchived.workspace_path, + } + + async def _delete_archived_conversation( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + task = self._load_archived_payload_task(payload) + deleted_files: list[str] = [] + deleted_state = False + + if self._is_codex_runtime(task.runtime) and self.codex_discovery is not None: + thread_id = task.runtime_handle.get("threadId") or task.local_task_id + delete_thread = getattr( + self.codex_discovery, + "delete_archived_thread", + None, + ) + if isinstance(thread_id, str) and thread_id.strip() and delete_thread: + result = await self._maybe_await( + delete_thread( + thread_id, + session_path=task.runtime_handle.get("sessionPath"), + ) + ) + if isinstance(result, dict): + deleted_state = bool(result.get("deletedState")) + deleted_files = [ + item + for item in result.get("deletedFiles", []) + if isinstance(item, str) + ] + + removed = self.store.delete_task( + task.local_task_id, + workspace_path=task.workspace_path, + ) + self._sdk_codex_task_records.pop(task.local_task_id, None) + return { + "success": True, + "accepted": True, + "deleted": bool(deleted_state or deleted_files or removed is not None), + "deletedState": deleted_state, + "deletedFiles": deleted_files, + "localTaskId": task.local_task_id, + "workspacePath": task.workspace_path, + } + + async def _delete_archived_conversations_bulk( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + items = payload.get("items") + if not isinstance(items, list): + raise ValueError("items is required") + + results = [] + deleted_count = 0 + for item in items: + if not isinstance(item, dict): + results.append( + { + "success": False, + "error": "Invalid archived conversation item", + } + ) + continue + try: + result = await self._delete_archived_conversation(item) + except (KeyError, ValueError) as exc: + result = self._error(str(exc), code="bad_request") + if result.get("success") and result.get("deleted"): + deleted_count += 1 + results.append(result) + + return { + "success": True, + "accepted": True, + "deletedCount": deleted_count, + "results": results, + } + def _status(self, payload: dict[str, Any]) -> dict[str, Any]: task = self._load_payload_task(payload) return {"success": True, "task": self._task_summary(task)} @@ -1586,6 +2064,64 @@ def _find_discovered_task( return task return None + def _load_archived_payload_task(self, payload: dict[str, Any]) -> LocalTaskRecord: + local_task_id = payload.get("localTaskId") + if not isinstance(local_task_id, str) or not local_task_id.strip(): + raise ValueError("localTaskId is required") + + workspace_path = payload.get("workspacePath") + if workspace_path is not None and not isinstance(workspace_path, str): + raise ValueError("workspacePath must be a string") + + codex_project_index = self._codex_project_index() + + try: + task = self.store.get_task(local_task_id, workspace_path=workspace_path) + if task.status == "archived" and self._is_visible_runtime_task_scope( + task, + codex_project_index, + ): + return task + except KeyError: + pass + + for task in self._list_archived_tasks( + workspace_path=workspace_path, + search_term=None, + ): + if task.local_task_id != local_task_id.strip(): + continue + if not self._is_visible_runtime_task_scope(task, codex_project_index): + continue + return task + + raise KeyError(f"Archived local task not found: {local_task_id}") + + def _dedupe_and_sort_tasks( + self, + tasks: list[LocalTaskRecord], + ) -> list[LocalTaskRecord]: + by_key: dict[tuple[str, str], LocalTaskRecord] = {} + for task in tasks: + by_key[(task.workspace_path, task.local_task_id)] = task + return sorted( + by_key.values(), + key=lambda task: ( + self._parse_task_time(task.updated_at), + self._parse_task_time(task.created_at), + ), + reverse=True, + ) + + def _archived_conversation_summary( + self, + task: LocalTaskRecord, + ) -> dict[str, Any]: + summary = self._task_summary(task) + summary["id"] = task.local_task_id + summary["source"] = "local" + return summary + def _task_summary(self, task: LocalTaskRecord) -> dict[str, Any]: summary = { "localTaskId": task.local_task_id, diff --git a/executor/tests/agents/test_codex_config_builder.py b/executor/tests/agents/test_codex_config_builder.py index 3f085dfb2e..58a8930757 100644 --- a/executor/tests/agents/test_codex_config_builder.py +++ b/executor/tests/agents/test_codex_config_builder.py @@ -46,6 +46,7 @@ def test_build_codex_config_maps_provider_and_reasoning(): assert config.model == "gpt-5.5" assert config.model_provider == "wecode-openai" + assert config.use_user_config is False assert "forced_login_method=api" in config.config_overrides assert "model_provider=wecode-openai" in config.config_overrides assert ( @@ -197,6 +198,7 @@ def test_build_codex_config_uses_user_runtime_config(monkeypatch): assert config.model == "gpt-5.5" assert config.model_provider is None + assert config.use_user_config is True assert config.config_overrides == ("model=gpt-5.5",) assert config.thread_config == { "model_reasoning_effort": "high", @@ -233,6 +235,7 @@ def test_build_codex_config_adds_project_header_to_user_runtime_provider(): ) assert config.model_provider == "openai" + assert config.use_user_config is True assert ( 'model_providers.openai.http_headers.wecode-project="42"' in config.config_overrides diff --git a/executor/tests/runtime_work/test_local_task_store.py b/executor/tests/runtime_work/test_local_task_store.py index 8264e20b40..0b4fcd5bce 100644 --- a/executor/tests/runtime_work/test_local_task_store.py +++ b/executor/tests/runtime_work/test_local_task_store.py @@ -4,8 +4,10 @@ import asyncio import json +import sqlite3 import sys from datetime import datetime, timezone +from pathlib import Path from types import ModuleType, SimpleNamespace import pytest @@ -16,19 +18,6 @@ def discover(self): return [] -class OpenWorkspaceDiscovery(EmptyDiscovery): - def __init__(self): - self.opened = [] - - async def open_workspace(self, workspace_path): - self.opened.append(workspace_path) - return { - "threadId": "thread-1", - "workspacePath": workspace_path, - "title": "hello-0", - } - - class SequenceDiscovery: def __init__(self, batches): self.batches = list(batches) @@ -45,6 +34,8 @@ def __init__(self, threads): self.threads = threads self.calls = [] self.archived_thread_ids = [] + self.unarchived_thread_ids = [] + self.renamed_thread_titles = [] def __enter__(self): return self @@ -60,6 +51,14 @@ def thread_archive(self, thread_id): self.archived_thread_ids.append(thread_id) return SimpleNamespace(id=thread_id) + def thread_unarchive(self, thread_id): + self.unarchived_thread_ids.append(thread_id) + return SimpleNamespace(id=thread_id) + + def thread_rename(self, thread_id, title=None, name=None): + self.renamed_thread_titles.append((thread_id, title or name)) + return SimpleNamespace(id=thread_id, title=title or name) + def _codex_discovery_for_threads(codex_home, *threads): from executor.runtime_work.codex_discovery import CodexSessionDiscovery @@ -72,6 +71,31 @@ def _codex_discovery_for_threads(codex_home, *threads): return discovery, fake_codex +def _write_codex_global_state( + codex_home, + *, + saved_roots=None, + labels=None, + project_order=None, + projectless_thread_ids=None, + thread_workspace_root_hints=None, + remote_projects=None, +): + codex_home.mkdir(parents=True, exist_ok=True) + payload = { + "electron-saved-workspace-roots": saved_roots or [], + "electron-workspace-root-labels": labels or {}, + "project-order": project_order or [], + "projectless-thread-ids": projectless_thread_ids or [], + "thread-workspace-root-hints": thread_workspace_root_hints or {}, + "remote-projects": remote_projects or [], + } + (codex_home / ".codex-global-state.json").write_text( + json.dumps(payload), + encoding="utf-8", + ) + + def _write_codex_session(path, *items): path.write_text( "\n".join(json.dumps(item, ensure_ascii=False) for item in items), @@ -79,6 +103,111 @@ def _write_codex_session(path, *items): ) +def _write_archived_codex_state(codex_home, *, thread_id, session_path): + state_dir = codex_home / "sqlite" + state_dir.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(state_dir / "state_5.sqlite") + try: + connection.execute( + """ + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, + model_provider TEXT NOT NULL, + cwd TEXT NOT NULL, + title TEXT NOT NULL, + sandbox_policy TEXT NOT NULL, + approval_mode TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + connection.execute( + """ + CREATE TABLE thread_dynamic_tools ( + thread_id TEXT NOT NULL, + position INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + input_schema TEXT NOT NULL, + PRIMARY KEY(thread_id, position) + ) + """ + ) + connection.execute( + """ + INSERT INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, + cwd, title, sandbox_policy, approval_mode, archived + ) VALUES (?, ?, 1, 2, 'sdk', 'openai', '/repo/Wegent', + 'Archived thread', '{}', 'never', 1) + """, + (thread_id, str(session_path)), + ) + connection.execute( + """ + INSERT INTO thread_dynamic_tools ( + thread_id, position, name, description, input_schema + ) VALUES (?, 0, 'tool', 'desc', '{}') + """, + (thread_id,), + ) + connection.commit() + finally: + connection.close() + + +def _write_codex_thread_state( + codex_home, + *, + thread_id, + session_path, + cwd="/repo/Wegent", + title="Original title", + preview="Original preview", + archived=0, +): + state_dir = codex_home / "sqlite" + state_dir.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(state_dir / "state_5.sqlite") + try: + connection.execute( + """ + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, + model_provider TEXT NOT NULL, + cwd TEXT NOT NULL, + title TEXT NOT NULL, + sandbox_policy TEXT NOT NULL, + approval_mode TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0, + preview TEXT NOT NULL DEFAULT '', + updated_at_ms INTEGER + ) + """ + ) + connection.execute( + """ + INSERT INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, + cwd, title, sandbox_policy, approval_mode, archived, preview, + updated_at_ms + ) VALUES (?, ?, 1, 2, 'sdk', 'openai', ?, ?, '{}', 'never', ?, ?, 2000) + """, + (thread_id, str(session_path), cwd, title, archived, preview), + ) + connection.commit() + finally: + connection.close() + + async def _drain_runtime_adapter(adapter): while adapter._running_tasks: await asyncio.gather(*adapter._running_tasks) @@ -232,7 +361,9 @@ def test_local_task_store_orders_tasks_by_parsed_updated_time(tmp_path): @pytest.mark.asyncio -async def test_runtime_work_handler_lists_tasks_by_workspace(tmp_path): +async def test_runtime_work_handler_ignores_runtime_tasks_without_codex_projects( + tmp_path, +): from executor.runtime_work.local_task_store import LocalTaskRecord, LocalTaskStore from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler @@ -267,19 +398,17 @@ async def test_runtime_work_handler_lists_tasks_by_workspace(tmp_path): codex_discovery=EmptyDiscovery(), ).handle_runtime_rpc({"method": "runtime.tasks.list", "payload": {}}) - assert result["success"] is True - workspaces = {item["workspacePath"]: item for item in result["workspaces"]} - assert workspaces["/repo/Wegent"]["localTasks"][0]["localTaskId"] == "claude-repo-1" - assert workspaces["/repo/Other"]["localTasks"][0]["runtime"] == "claude_code" + assert result == {"success": True, "workspaces": []} @pytest.mark.asyncio -async def test_runtime_work_handler_opens_and_lists_empty_codex_workspace(tmp_path): +async def test_runtime_work_handler_manages_codex_global_project_metadata(tmp_path): from executor.runtime_work.local_task_store import LocalTaskStore from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + codex_home = tmp_path / "codex-home" store = LocalTaskStore(tmp_path / "index.json") - discovery = OpenWorkspaceDiscovery() + discovery, _fake_codex = _codex_discovery_for_threads(codex_home) handler = RuntimeWorkRpcHandler(store=store, codex_discovery=discovery) open_result = await handler.handle_runtime_rpc( @@ -300,18 +429,100 @@ async def test_runtime_work_handler_opens_and_lists_empty_codex_workspace(tmp_pa "accepted": True, "runtime": "codex", "workspacePath": "/Users/crystal/Documents/hello-0", - "threadId": "thread-1", } - assert discovery.opened == ["/Users/crystal/Documents/hello-0"] assert list_result == { "success": True, "workspaces": [ { "workspacePath": "/Users/crystal/Documents/hello-0", "localTasks": [], + "workspaceSource": "local", + "label": "hello-0", } ], } + payload = json.loads((codex_home / ".codex-global-state.json").read_text()) + assert payload["electron-saved-workspace-roots"] == [ + "/Users/crystal/Documents/hello-0" + ] + assert payload["project-order"] == ["/Users/crystal/Documents/hello-0"] + + rename_result = await handler.handle_runtime_rpc( + { + "method": "runtime.workspaces.rename", + "payload": { + "runtime": "codex", + "workspacePath": "/Users/crystal/Documents/hello-0", + "label": "Hello project", + }, + } + ) + assert rename_result == { + "success": True, + "accepted": True, + "runtime": "codex", + "workspacePath": "/Users/crystal/Documents/hello-0", + } + payload = json.loads((codex_home / ".codex-global-state.json").read_text()) + assert payload["electron-workspace-root-labels"] == { + "/Users/crystal/Documents/hello-0": "Hello project" + } + + remove_result = await handler.handle_runtime_rpc( + { + "method": "runtime.workspaces.remove", + "payload": { + "runtime": "codex", + "workspacePath": "/Users/crystal/Documents/hello-0", + }, + } + ) + assert remove_result == { + "success": True, + "accepted": True, + "runtime": "codex", + "workspacePath": "/Users/crystal/Documents/hello-0", + } + payload = json.loads((codex_home / ".codex-global-state.json").read_text()) + assert payload["electron-saved-workspace-roots"] == [] + assert payload["project-order"] == [] + assert payload["electron-workspace-root-labels"] == {} + + +@pytest.mark.asyncio +async def test_runtime_work_handler_places_new_codex_project_first(tmp_path): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + codex_home = tmp_path / "codex-home" + _write_codex_global_state( + codex_home, + saved_roots=["/Users/crystal/Documents/old-project"], + project_order=["/Users/crystal/Documents/old-project"], + ) + store = LocalTaskStore(tmp_path / "index.json") + discovery, _fake_codex = _codex_discovery_for_threads(codex_home) + handler = RuntimeWorkRpcHandler(store=store, codex_discovery=discovery) + + await handler.handle_runtime_rpc( + { + "method": "runtime.workspaces.open", + "payload": { + "runtime": "codex", + "workspacePath": "/Users/crystal/Documents/New Project", + }, + } + ) + + payload = json.loads((codex_home / ".codex-global-state.json").read_text()) + assert payload["electron-saved-workspace-roots"] == [ + "/Users/crystal/Documents/New Project", + "/Users/crystal/Documents/old-project", + ] + assert payload["project-order"] == [ + "/Users/crystal/Documents/New Project", + "/Users/crystal/Documents/old-project", + ] @pytest.mark.asyncio @@ -320,6 +531,7 @@ async def test_runtime_work_handler_lists_codex_tasks_from_sdk_only(tmp_path): from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler codex_home = tmp_path / "codex-home" + _write_codex_global_state(codex_home, saved_roots=["/repo/Wegent"]) thread_id = "019ee7f6-456a-78a1-96b1-66451afc310e" discovery, _fake_codex = _codex_discovery_for_threads( codex_home, @@ -359,6 +571,98 @@ async def test_runtime_work_handler_lists_codex_tasks_from_sdk_only(tmp_path): assert tasks[0]["runtime"] == "codex" +@pytest.mark.asyncio +async def test_runtime_work_handler_uses_codex_global_projects(tmp_path): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + codex_home = tmp_path / "codex-home" + _write_codex_global_state( + codex_home, + saved_roots=["/repo/empty", "/repo/Wegent"], + labels={"/repo/Wegent": "Wegent"}, + project_order=["/repo/Wegent", "/repo/empty"], + ) + discovery, _fake_codex = _codex_discovery_for_threads( + codex_home, + SimpleNamespace( + id="thread-in-project", + cwd="/repo/Wegent/wework", + name="Implement archive", + preview="hi", + path=str(tmp_path / "thread-in-project.jsonl"), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:15:58Z", + ), + SimpleNamespace( + id="thread-outside-projects", + cwd="/repo/Other", + name="Outside", + preview="outside", + path=str(tmp_path / "thread-outside-projects.jsonl"), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:15:58Z", + ), + ) + + result = await RuntimeWorkRpcHandler( + store=LocalTaskStore(tmp_path / "index.json"), + codex_discovery=discovery, + ).handle_runtime_rpc({"method": "runtime.tasks.list", "payload": {}}) + + assert result["success"] is True + assert [workspace["workspacePath"] for workspace in result["workspaces"]] == [ + "/repo/Wegent", + "/repo/empty", + ] + workspaces = {item["workspacePath"]: item for item in result["workspaces"]} + assert workspaces["/repo/Wegent"]["label"] == "Wegent" + assert workspaces["/repo/Wegent"]["workspaceSource"] == "local" + assert [ + task["localTaskId"] for task in workspaces["/repo/Wegent"]["localTasks"] + ] == ["thread-in-project"] + assert workspaces["/repo/empty"]["localTasks"] == [] + + +@pytest.mark.asyncio +async def test_runtime_work_handler_ignores_remote_codex_global_projects(tmp_path): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + codex_home = tmp_path / "codex-home" + _write_codex_global_state( + codex_home, + project_order=["remote-project-id"], + remote_projects=[ + { + "id": "remote-project-id", + "hostId": "remote-ssh-discovered:10.201.3.200", + "remotePath": "/home/ubuntu/workspace/Wegent", + "label": "Wegent", + } + ], + ) + discovery, _fake_codex = _codex_discovery_for_threads( + codex_home, + SimpleNamespace( + id="remote-thread", + cwd="/home/ubuntu/workspace/Wegent/wework", + name="Remote work", + preview="hi", + path=str(tmp_path / "remote-thread.jsonl"), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:15:58Z", + ), + ) + + result = await RuntimeWorkRpcHandler( + store=LocalTaskStore(tmp_path / "index.json"), + codex_discovery=discovery, + ).handle_runtime_rpc({"method": "runtime.tasks.list", "payload": {}}) + + assert result == {"success": True, "workspaces": []} + + @pytest.mark.asyncio async def test_runtime_work_handler_ignores_stale_cached_codex_tasks( tmp_path, @@ -412,8 +716,10 @@ async def test_runtime_work_handler_ignores_store_codex_and_keeps_live_discovery from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler sdk_thread_id = "019ee7f6-456a-78a1-96b1-66451afc310e" + codex_home = tmp_path / "codex-home" + _write_codex_global_state(codex_home, saved_roots=["/repo/Wegent"]) discovery, _fake_codex = _codex_discovery_for_threads( - tmp_path / "codex-home", + codex_home, SimpleNamespace( id=sdk_thread_id, cwd="/repo/Wegent", @@ -511,6 +817,322 @@ async def test_runtime_work_handler_archives_codex_thread_through_sdk(tmp_path): handler.store.get_task(thread_id, workspace_path="/repo/Wegent") +@pytest.mark.asyncio +async def test_runtime_work_handler_renames_discovered_codex_thread(tmp_path): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + thread_id = "019ee7f6-456a-78a1-96b1-66451afc310e" + codex_home = tmp_path / "codex-home" + _write_codex_global_state(codex_home, saved_roots=["/repo/Wegent"]) + discovery, _fake_codex = _codex_discovery_for_threads( + codex_home, + SimpleNamespace( + id=thread_id, + cwd="/repo/Wegent", + name=None, + preview="original title", + path=str(tmp_path / "thread.jsonl"), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:15:58Z", + ), + ) + handler = RuntimeWorkRpcHandler( + store=LocalTaskStore(tmp_path / "index.json"), + codex_discovery=discovery, + ) + + result = await handler.handle_runtime_rpc( + { + "method": "runtime.tasks.rename", + "payload": { + "workspacePath": "/repo/Wegent", + "localTaskId": thread_id, + "title": "对齐需求核心点", + }, + } + ) + + assert result["success"] is True + assert result["accepted"] is True + renamed = handler.store.get_task(thread_id, workspace_path="/repo/Wegent") + assert renamed.title == "对齐需求核心点" + assert renamed.runtime_handle["titleOverride"] is True + + listed = await handler.handle_runtime_rpc( + {"method": "runtime.tasks.list", "payload": {}} + ) + + tasks = listed["workspaces"][0]["localTasks"] + assert tasks[0]["localTaskId"] == thread_id + assert tasks[0]["title"] == "对齐需求核心点" + + +@pytest.mark.asyncio +async def test_runtime_work_handler_renames_codex_thread_state_for_codex_app(tmp_path): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + codex_home = tmp_path / "codex-home" + session_path = tmp_path / "thread.jsonl" + thread_id = "019ee7f6-456a-78a1-96b1-66451afc310e" + _write_codex_thread_state( + codex_home, + thread_id=thread_id, + session_path=session_path, + title="打开微博网站", + preview="打开微博网站", + ) + discovery, fake_codex = _codex_discovery_for_threads( + codex_home, + SimpleNamespace( + id=thread_id, + cwd="/repo/Wegent", + name=None, + preview="打开微博网站", + path=str(session_path), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:15:58Z", + ), + ) + handler = RuntimeWorkRpcHandler( + store=LocalTaskStore(tmp_path / "index.json"), + codex_discovery=discovery, + ) + + result = await handler.handle_runtime_rpc( + { + "method": "runtime.tasks.rename", + "payload": { + "workspacePath": "/repo/Wegent", + "localTaskId": thread_id, + "title": "open-weibo-com", + }, + } + ) + + assert result["success"] is True + assert result["codexRename"]["stateUpdated"] is True + assert fake_codex.renamed_thread_titles == [(thread_id, "open-weibo-com")] + + connection = sqlite3.connect(codex_home / "sqlite" / "state_5.sqlite") + try: + row = connection.execute( + "SELECT title, preview, updated_at, updated_at_ms FROM threads WHERE id = ?", + (thread_id,), + ).fetchone() + finally: + connection.close() + + title, preview, updated_at, updated_at_ms = row + assert title == "open-weibo-com" + assert preview == "open-weibo-com" + assert updated_at > 2 + assert updated_at_ms > 2000 + + +def test_codex_discovery_lists_archived_threads(tmp_path): + thread_id = "019ee7f6-456a-78a1-96b1-66451afc310e" + discovery, fake_codex = _codex_discovery_for_threads( + tmp_path / "codex-home", + SimpleNamespace( + id=thread_id, + cwd="/repo/Wegent", + preview="archived", + path=str(tmp_path / "codex-home" / "sessions" / "thread.jsonl"), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:15:58Z", + ), + ) + + records = discovery.discover_archived(workspace_path="/repo/Wegent") + + assert fake_codex.calls[0]["archived"] is True + assert [record.local_task_id for record in records] == [thread_id] + assert records[0].status == "archived" + + +@pytest.mark.asyncio +async def test_runtime_work_handler_unarchives_codex_thread_through_sdk(tmp_path): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + thread_id = "019ee7f6-456a-78a1-96b1-66451afc310e" + codex_home = tmp_path / "codex-home" + _write_codex_global_state(codex_home, saved_roots=["/repo/Wegent"]) + discovery, fake_codex = _codex_discovery_for_threads( + codex_home, + SimpleNamespace( + id=thread_id, + cwd="/repo/Wegent", + preview="archived", + path=str(tmp_path / "codex-home" / "sessions" / "thread.jsonl"), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:15:58Z", + ), + ) + handler = RuntimeWorkRpcHandler( + store=LocalTaskStore(tmp_path / "index.json"), + codex_discovery=discovery, + ) + + result = await handler.handle_runtime_rpc( + { + "method": "runtime.archived_conversations.unarchive", + "payload": { + "workspacePath": "/repo/Wegent", + "localTaskId": thread_id, + }, + } + ) + + assert result == { + "success": True, + "accepted": True, + "localTaskId": thread_id, + "workspacePath": "/repo/Wegent", + } + assert fake_codex.unarchived_thread_ids == [thread_id] + + +@pytest.mark.asyncio +async def test_runtime_work_handler_filters_archived_threads_to_visible_projects_and_chats( + tmp_path, +): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + codex_home = tmp_path / "codex-home" + _write_codex_global_state(codex_home, saved_roots=["/repo/Wegent"]) + project_thread_id = "019ee7f6-456a-78a1-96b1-66451afc310e" + other_thread_id = "029ee7f6-456a-78a1-96b1-66451afc310e" + chat_thread_id = "039ee7f6-456a-78a1-96b1-66451afc310e" + chat_workspace = Path.home() / "Documents" / "Codex" / "2026-06-23" / "chat-1" + discovery, fake_codex = _codex_discovery_for_threads( + codex_home, + SimpleNamespace( + id=project_thread_id, + cwd="/repo/Wegent", + preview="project archived", + path=str(codex_home / "sessions" / "project.jsonl"), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:15:58Z", + ), + SimpleNamespace( + id=other_thread_id, + cwd="/repo/Other", + preview="outside archived", + path=str(codex_home / "sessions" / "other.jsonl"), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:16:58Z", + ), + SimpleNamespace( + id=chat_thread_id, + cwd=str(chat_workspace), + preview="chat archived", + path=str(codex_home / "sessions" / "chat.jsonl"), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:17:58Z", + ), + ) + handler = RuntimeWorkRpcHandler( + store=LocalTaskStore(tmp_path / "index.json"), + codex_discovery=discovery, + ) + + listed = await handler.handle_runtime_rpc( + {"method": "runtime.archived_conversations.list", "payload": {}} + ) + rejected = await handler.handle_runtime_rpc( + { + "method": "runtime.archived_conversations.unarchive", + "payload": { + "workspacePath": "/repo/Other", + "localTaskId": other_thread_id, + }, + } + ) + + assert {item["localTaskId"] for item in listed["items"]} == { + project_thread_id, + chat_thread_id, + } + assert {group["workspacePath"] for group in listed["groups"]} == { + "/repo/Wegent", + str(chat_workspace), + } + assert rejected["success"] is False + assert rejected["code"] == "not_found" + assert fake_codex.unarchived_thread_ids == [] + + +@pytest.mark.asyncio +async def test_runtime_work_handler_deletes_archived_codex_state(tmp_path): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + codex_home = tmp_path / "codex-home" + _write_codex_global_state(codex_home, saved_roots=["/repo/Wegent"]) + session_path = codex_home / "sessions" / "thread.jsonl" + session_path.parent.mkdir(parents=True) + session_path.write_text("{}", encoding="utf-8") + thread_id = "019ee7f6-456a-78a1-96b1-66451afc310e" + _write_archived_codex_state( + codex_home, + thread_id=thread_id, + session_path=session_path, + ) + discovery, _fake_codex = _codex_discovery_for_threads( + codex_home, + SimpleNamespace( + id=thread_id, + cwd="/repo/Wegent", + preview="archived", + path=str(session_path), + created_at="2026-06-21T02:15:37Z", + updated_at="2026-06-21T02:15:58Z", + ), + ) + handler = RuntimeWorkRpcHandler( + store=LocalTaskStore(tmp_path / "index.json"), + codex_discovery=discovery, + ) + + result = await handler.handle_runtime_rpc( + { + "method": "runtime.archived_conversations.delete", + "payload": { + "workspacePath": "/repo/Wegent", + "localTaskId": thread_id, + }, + } + ) + + assert result["success"] is True + assert result["deleted"] is True + assert result["deletedState"] is True + assert not session_path.exists() + + connection = sqlite3.connect(codex_home / "sqlite" / "state_5.sqlite") + try: + assert ( + connection.execute( + "SELECT COUNT(*) FROM threads WHERE id = ?", + (thread_id,), + ).fetchone()[0] + == 0 + ) + assert ( + connection.execute( + "SELECT COUNT(*) FROM thread_dynamic_tools WHERE thread_id = ?", + (thread_id,), + ).fetchone()[0] + == 0 + ) + finally: + connection.close() + + @pytest.mark.asyncio async def test_runtime_work_handler_continues_discovered_codex_thread_through_sdk( tmp_path, @@ -1225,6 +1847,120 @@ def __init__(self, config): assert (tmp_path / "codex-home").is_dir() +@pytest.mark.asyncio +async def test_codex_discovery_opens_workspace_as_user_thread(tmp_path): + from executor.runtime_work.codex_discovery import CodexSessionDiscovery + + class FakeAsyncCodex: + def __init__(self): + self.thread_start_kwargs = None + + async def __aenter__(self): + return self + + async def __aexit__(self, _exc_type, _exc, _tb): + return None + + async def thread_start(self, **kwargs): + self.thread_start_kwargs = kwargs + return SimpleNamespace(id="codex-thread-1") + + fake_codex = FakeAsyncCodex() + discovery = CodexSessionDiscovery(codex_home=tmp_path / "codex-home") + discovery._create_async_codex_client = lambda: fake_codex + + result = await discovery.open_workspace("/repo/Wegent") + + assert result["threadId"] == "codex-thread-1" + assert getattr(fake_codex.thread_start_kwargs["thread_source"], "value", None) == "user" + + +def test_codex_discovery_thread_start_kwargs_keeps_provider_for_managed_config(): + from executor.runtime_work.codex_discovery import _thread_start_kwargs + + kwargs = _thread_start_kwargs( + approval_mode=object(), + codex_config=SimpleNamespace( + thread_config={}, + model="gpt-5.5", + model_provider="wecode-openai", + use_user_config=False, + ), + cwd="/repo/Wegent", + developer_instructions=None, + sandbox=object(), + ) + + assert getattr(kwargs["thread_source"], "value", None) == "user" + assert kwargs["model_provider"] == "wecode-openai" + + +def test_codex_discovery_thread_start_kwargs_omits_provider_for_user_config(): + from executor.runtime_work.codex_discovery import _thread_start_kwargs + + kwargs = _thread_start_kwargs( + approval_mode=object(), + codex_config=SimpleNamespace( + thread_config={}, + model="gpt-5.5", + model_provider="wecode-openai", + use_user_config=True, + ), + cwd="/repo/Wegent", + developer_instructions=None, + sandbox=object(), + ) + + assert getattr(kwargs["thread_source"], "value", None) == "user" + assert "model_provider" not in kwargs + + +def test_codex_lite_config_overrides_keep_managed_provider(): + from executor.runtime_work.codex_discovery import _codex_lite_config_overrides + + overrides = _codex_lite_config_overrides( + ( + "forced_login_method=api", + "model=gpt-5.5", + "model_provider=wecode-openai", + "model_providers.wecode-openai.base_url=http://127.0.0.1:3456/v1", + 'model_providers.wecode-openai.http_headers.wecode-project="42"', + "experimental_use_exec_command_tool=true", + ), + use_user_config=False, + ) + + assert overrides == ( + "forced_login_method=api", + "model=gpt-5.5", + "model_provider=wecode-openai", + "model_providers.wecode-openai.base_url=http://127.0.0.1:3456/v1", + 'model_providers.wecode-openai.http_headers.wecode-project="42"', + "experimental_use_exec_command_tool=true", + ) + + +def test_codex_lite_config_overrides_keep_codex_app_provider_for_user_config(): + from executor.runtime_work.codex_discovery import _codex_lite_config_overrides + + overrides = _codex_lite_config_overrides( + ( + "forced_login_method=api", + "model=gpt-5.5", + "model_provider=wecode-openai", + "model_providers.wecode-openai.base_url=http://127.0.0.1:3456/v1", + 'model_providers.wecode-openai.http_headers.wecode-project="42"', + "experimental_use_exec_command_tool=true", + ), + use_user_config=True, + ) + + assert overrides == ( + "model=gpt-5.5", + "experimental_use_exec_command_tool=true", + ) + + def test_codex_discovery_reads_user_visible_transcript(tmp_path): from executor.runtime_work.codex_discovery import CodexSessionDiscovery @@ -1538,7 +2274,7 @@ async def test_runtime_work_handler_refreshes_codex_sessions_before_listing(tmp_ from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler codex_home = tmp_path / "codex-home" - codex_home.mkdir() + _write_codex_global_state(codex_home, saved_roots=["/repo/Wegent"]) thread_id = "018f2d6b-8c7a-7abc-9def-0123456789ac" discovery, _fake_codex = _codex_discovery_for_threads( codex_home, diff --git a/wework/src/api/runtimeWork.test.ts b/wework/src/api/runtimeWork.test.ts index b548b1bcd4..9247c09876 100644 --- a/wework/src/api/runtimeWork.test.ts +++ b/wework/src/api/runtimeWork.test.ts @@ -6,14 +6,14 @@ describe('createRuntimeWorkApi', () => { test('lists runtime work without client origin query', async () => { const get = vi.fn().mockResolvedValue({ projects: [], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, }) const api = createRuntimeWorkApi({ get } as unknown as HttpClient) await expect(api.listRuntimeWork()).resolves.toEqual({ projects: [], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, }) @@ -100,7 +100,6 @@ describe('createRuntimeWorkApi', () => { deviceId: 'device-1', workspacePath: '/Users/crystal/Documents/hello-0', runtime: 'codex', - threadId: 'thread-1', }) const api = createRuntimeWorkApi({ post } as unknown as HttpClient) @@ -109,19 +108,80 @@ describe('createRuntimeWorkApi', () => { deviceId: 'device-1', workspacePath: '/Users/crystal/Documents/hello-0', runtime: 'codex', + label: 'Hello project', }) ).resolves.toEqual({ accepted: true, deviceId: 'device-1', workspacePath: '/Users/crystal/Documents/hello-0', runtime: 'codex', - threadId: 'thread-1', }) expect(post).toHaveBeenCalledWith('/runtime-work/workspaces/open', { deviceId: 'device-1', workspacePath: '/Users/crystal/Documents/hello-0', runtime: 'codex', + label: 'Hello project', + }) + }) + + test('renames a runtime workspace project', async () => { + const post = vi.fn().mockResolvedValue({ + accepted: true, + deviceId: 'device-1', + workspacePath: '/Users/crystal/Documents/hello-0', + runtime: 'codex', + }) + const api = createRuntimeWorkApi({ post } as unknown as HttpClient) + + await expect( + api.renameRuntimeWorkspace({ + deviceId: 'device-1', + workspacePath: '/Users/crystal/Documents/hello-0', + runtime: 'codex', + name: 'Hello project', + }) + ).resolves.toEqual({ + accepted: true, + deviceId: 'device-1', + workspacePath: '/Users/crystal/Documents/hello-0', + runtime: 'codex', + }) + + expect(post).toHaveBeenCalledWith('/runtime-work/workspaces/rename', { + deviceId: 'device-1', + workspacePath: '/Users/crystal/Documents/hello-0', + runtime: 'codex', + name: 'Hello project', + }) + }) + + test('removes a runtime workspace project', async () => { + const post = vi.fn().mockResolvedValue({ + accepted: true, + deviceId: 'device-1', + workspacePath: '/Users/crystal/Documents/hello-0', + runtime: 'codex', + }) + const api = createRuntimeWorkApi({ post } as unknown as HttpClient) + + await expect( + api.removeRuntimeWorkspace({ + deviceId: 'device-1', + workspacePath: '/Users/crystal/Documents/hello-0', + runtime: 'codex', + }) + ).resolves.toEqual({ + accepted: true, + deviceId: 'device-1', + workspacePath: '/Users/crystal/Documents/hello-0', + runtime: 'codex', + }) + + expect(post).toHaveBeenCalledWith('/runtime-work/workspaces/remove', { + deviceId: 'device-1', + workspacePath: '/Users/crystal/Documents/hello-0', + runtime: 'codex', }) }) diff --git a/wework/src/api/runtimeWork.ts b/wework/src/api/runtimeWork.ts index 919815ece9..5d5ac370ff 100644 --- a/wework/src/api/runtimeWork.ts +++ b/wework/src/api/runtimeWork.ts @@ -1,4 +1,6 @@ import type { + ArchivedConversationsListRequest, + ArchivedConversationsListResponse, BindRuntimeTaskIMSessionsRequest, BindRuntimeTaskIMSessionsResponse, DeleteDeviceWorkspaceRequest, @@ -9,6 +11,9 @@ import type { DeviceWorkspaceUpsert, RuntimeGlobalIMNotificationUpdateRequest, RuntimeIMNotificationSettingsResponse, + RuntimeArchiveProjectConversationsRequest, + RuntimeArchivedConversationBulkRequest, + RuntimeArchivedConversationBulkResponse, RuntimeSendRequest, RuntimeSendResponse, RuntimeTaskAddress, @@ -17,12 +22,15 @@ import type { RuntimeTaskCreateResponse, RuntimeTaskForkRequest, RuntimeTaskForkResponse, + RuntimeTaskRenameRequest, RuntimeTaskIMNotificationSubscriptionRequest, RuntimeTaskIMNotificationSubscriptionResponse, RuntimeTranscriptRequest, RuntimeTranscriptResponse, RuntimeWorkspaceOpenRequest, RuntimeWorkspaceOpenResponse, + RuntimeWorkspaceRemoveRequest, + RuntimeWorkspaceRenameRequest, RuntimeWorkListResponse, } from '@/types/api' import type { HttpClient } from './http' @@ -59,6 +67,16 @@ export function createRuntimeWorkApi(client: HttpClient) { openRuntimeWorkspace(data: RuntimeWorkspaceOpenRequest): Promise { return client.post('/runtime-work/workspaces/open', data) }, + renameRuntimeWorkspace( + data: RuntimeWorkspaceRenameRequest + ): Promise { + return client.post('/runtime-work/workspaces/rename', data) + }, + removeRuntimeWorkspace( + data: RuntimeWorkspaceRemoveRequest + ): Promise { + return client.post('/runtime-work/workspaces/remove', data) + }, bindRuntimeTaskImSessions( data: BindRuntimeTaskIMSessionsRequest ): Promise { @@ -85,6 +103,36 @@ export function createRuntimeWorkApi(client: HttpClient) { archiveRuntimeTask(address: RuntimeTaskAddress): Promise { return client.post('/runtime-work/archive', address) }, + renameRuntimeTask(data: RuntimeTaskRenameRequest): Promise { + return client.post('/runtime-work/rename', data) + }, + listArchivedConversations( + data: ArchivedConversationsListRequest = {} + ): Promise { + return client.post('/runtime-work/archived-conversations/list', data) + }, + archiveConversation(address: RuntimeTaskAddress): Promise { + return client.post('/runtime-work/archived-conversations/archive', address) + }, + archiveProjectConversations( + data: RuntimeArchiveProjectConversationsRequest + ): Promise { + return client.post('/runtime-work/archived-conversations/archive-project', data) + }, + archiveAllConversations(): Promise { + return client.post('/runtime-work/archived-conversations/archive-all', {}) + }, + unarchiveConversation(address: RuntimeTaskAddress): Promise { + return client.post('/runtime-work/archived-conversations/unarchive', address) + }, + deleteArchivedConversation(address: RuntimeTaskAddress): Promise { + return client.post('/runtime-work/archived-conversations/delete', address) + }, + deleteArchivedConversationsBulk( + data: RuntimeArchivedConversationBulkRequest + ): Promise { + return client.post('/runtime-work/archived-conversations/delete-bulk', data) + }, createRuntimeTask(data: RuntimeTaskCreateRequest): Promise { return client.post('/runtime-work/create', data) }, diff --git a/wework/src/components/chat/ChatInput.test.tsx b/wework/src/components/chat/ChatInput.test.tsx index e60932fde3..205e818220 100644 --- a/wework/src/components/chat/ChatInput.test.tsx +++ b/wework/src/components/chat/ChatInput.test.tsx @@ -133,7 +133,7 @@ function runtimeWork( }, ], })), - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, } } diff --git a/wework/src/components/chat/composer/ProjectWorkBar.test.tsx b/wework/src/components/chat/composer/ProjectWorkBar.test.tsx index 6ded4a76c3..6684671dbd 100644 --- a/wework/src/components/chat/composer/ProjectWorkBar.test.tsx +++ b/wework/src/components/chat/composer/ProjectWorkBar.test.tsx @@ -97,7 +97,7 @@ const runtimeWork: RuntimeWorkListResponse = { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, } @@ -111,7 +111,7 @@ describe('ProjectWorkBar', () => { devices={[device]} runtimeWork={{ projects: [], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, }} currentProjectId={undefined} @@ -156,7 +156,7 @@ describe('ProjectWorkBar', () => { devices={[device]} runtimeWork={{ projects: [], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, }} currentProjectId={undefined} @@ -340,7 +340,7 @@ describe('ProjectWorkBar', () => { devices={[remoteDevice, localDevice]} runtimeWork={{ projects: [], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, }} currentProjectId={undefined} @@ -461,7 +461,7 @@ describe('ProjectWorkBar', () => { devices={[device]} runtimeWork={{ projects: [], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, }} currentProjectId={undefined} diff --git a/wework/src/components/common/ActionMenu.tsx b/wework/src/components/common/ActionMenu.tsx index 14c397ce6c..33dbc3685f 100644 --- a/wework/src/components/common/ActionMenu.tsx +++ b/wework/src/components/common/ActionMenu.tsx @@ -13,6 +13,7 @@ interface ActionMenuItem { onSelect: () => void | Promise testId: string danger?: boolean + disabled?: boolean } interface ActionMenuProps { @@ -52,6 +53,7 @@ export function ActionMenu({ } const handleItemSelect = async (item: ActionMenuItem) => { + if (item.disabled) return setOpen(false) setMenuPosition(null) await item.onSelect() @@ -100,10 +102,7 @@ export function ActionMenu({ const handlePointerDown = (event: PointerEvent) => { const target = event.target as Node - if ( - !containerRef.current?.contains(target) && - !menuRef.current?.contains(target) - ) { + if (!containerRef.current?.contains(target) && !menuRef.current?.contains(target)) { setOpen(false) setMenuPosition(null) } @@ -149,7 +148,9 @@ export function ActionMenu({ key={item.testId} type="button" data-testid={item.testId} + disabled={item.disabled} onPointerDown={event => { + if (item.disabled) return event.preventDefault() event.stopPropagation() pointerSelectionRef.current = true @@ -164,9 +165,8 @@ export function ActionMenu({ }} className={[ 'flex h-8 w-full items-center gap-2 rounded-lg px-3 text-left text-[13px] leading-[18px]', - item.danger - ? 'text-red-500 hover:bg-red-50' - : 'text-text-primary hover:bg-muted', + item.danger ? 'text-red-500 hover:bg-red-50' : 'text-text-primary hover:bg-muted', + item.disabled ? 'cursor-not-allowed opacity-45 hover:bg-transparent' : '', ].join(' ')} > @@ -174,7 +174,7 @@ export function ActionMenu({ ))} , - document.body, + document.body )} ) diff --git a/wework/src/components/common/TextInputDialog.tsx b/wework/src/components/common/TextInputDialog.tsx index 6274ca5fc2..832a16bc8e 100644 --- a/wework/src/components/common/TextInputDialog.tsx +++ b/wework/src/components/common/TextInputDialog.tsx @@ -2,11 +2,13 @@ import { X } from 'lucide-react' import { useState } from 'react' import { createPortal } from 'react-dom' import { useEscapeKey } from '@/hooks/useEscapeKey' +import { useTranslation } from '@/hooks/useTranslation' interface TextInputDialogProps { open: boolean title: string label: string + description?: string initialValue: string confirmLabel: string cancelLabel: string @@ -16,10 +18,7 @@ interface TextInputDialogProps { onSubmit: (value: string) => Promise | void } -export function TextInputDialog({ - open, - ...props -}: TextInputDialogProps) { +export function TextInputDialog({ open, ...props }: TextInputDialogProps) { if (!open) return null return @@ -28,6 +27,7 @@ export function TextInputDialog({ function TextInputDialogContent({ title, label, + description, initialValue, confirmLabel, cancelLabel, @@ -36,8 +36,10 @@ function TextInputDialogContent({ onClose, onSubmit, }: Omit) { + const { t } = useTranslation('common') const [value, setValue] = useState(initialValue) const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) const trimmedValue = value.trim() @@ -55,10 +57,7 @@ function TextInputDialogContent({ className="w-full max-w-[420px] rounded-lg border border-[#d8d8d8] bg-white p-5 shadow-2xl" >
-

+

{title}

+ {description && ( +

{description}

+ )} @@ -78,9 +80,14 @@ function TextInputDialogContent({ data-testid={inputTestId} value={value} autoFocus - onChange={event => setValue(event.target.value)} + onFocus={event => event.currentTarget.select()} + onChange={event => { + setValue(event.target.value) + setError(null) + }} className="mt-2 h-9 w-full rounded-md border border-[#d8d8d8] px-3 text-[13px] outline-none focus:border-[#14b8a6] focus:ring-2 focus:ring-[#14b8a6]/20" /> + {error &&

{error}

}
, - document.body, + document.body ) } diff --git a/wework/src/components/layout/DesktopSidebar.test.tsx b/wework/src/components/layout/DesktopSidebar.test.tsx index d002a21dc3..c3e0531c00 100644 --- a/wework/src/components/layout/DesktopSidebar.test.tsx +++ b/wework/src/components/layout/DesktopSidebar.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, test, vi } from 'vitest' import '@/i18n' @@ -35,33 +35,11 @@ function renderSidebar(overrides: Partial[0]> devices: [localDevice()], onCollapse: vi.fn(), onNewChat: vi.fn(), - onSelectProject: vi.fn(), onStartNewProjectChat: vi.fn(), onOpenPlugins: vi.fn(), - onCreateProject: vi.fn(), - onCreateGitWorkspaceProject: vi.fn(), - onPrepareDeviceWorkspace: vi.fn().mockResolvedValue({ - preparedAction: 'selected', - mapping: { - id: 19, - userId: 1, - projectId: 7, - deviceId: 'local-device', - workspacePath: '/Users/alice', - repoUrl: null, - repoRootFingerprint: null, - label: null, - createdAt: '2026-06-21T00:00:00', - updatedAt: '2026-06-21T00:00:00', - lastSeenAt: null, - }, - }), - onListGitRepositories: vi.fn().mockResolvedValue([]), - onListGitBranches: vi.fn().mockResolvedValue([]), onUpdateProjectName: vi.fn(), onRemoveProject: vi.fn(), onGetDeviceHomeDirectory: vi.fn().mockResolvedValue('/Users/alice'), - onGetProjectWorkspaceRoot: vi.fn().mockResolvedValue('/Users/alice/dev'), onListDeviceDirectories: vi.fn().mockResolvedValue([]), onCreateDeviceDirectory: vi.fn(), onOpenSettings: vi.fn(), @@ -78,14 +56,14 @@ describe('DesktopSidebar', () => { localStorage.clear() }) - test('does not render unmapped runtime workspace groups', async () => { + test('does not render non-chat runtime workspace groups', async () => { const onOpenRuntimeLocalTask = vi.fn() renderSidebar({ projects: [], runtimeWork: { projects: [], - unmappedDeviceWorkspaces: [ + chats: [ { deviceId: 'local-device', deviceName: 'Local Mac', @@ -107,7 +85,7 @@ describe('DesktopSidebar', () => { onOpenRuntimeLocalTask, }) - expect(screen.queryByTestId('unmapped-runtime-section')).not.toBeInTheDocument() + expect(screen.queryByTestId('non-chat-runtime-section')).not.toBeInTheDocument() expect(screen.queryByTestId('runtime-workspace-row-/tmp/spike')).not.toBeInTheDocument() expect(screen.queryByTestId('runtime-local-task-row-claude-1')).not.toBeInTheDocument() expect(screen.getByTestId('runtime-chat-section')).toHaveTextContent('对话') @@ -135,7 +113,7 @@ describe('DesktopSidebar', () => { projects: [], runtimeWork: { projects: [], - unmappedDeviceWorkspaces: [ + chats: [ { deviceId: 'local-device', deviceName: 'Local Mac', @@ -199,6 +177,62 @@ describe('DesktopSidebar', () => { }) }) + test('renames a runtime conversation from double click dialog', async () => { + const user = userEvent.setup() + const onOpenRuntimeLocalTask = vi.fn() + const onRenameRuntimeLocalTask = vi.fn().mockResolvedValue(undefined) + + renderSidebar({ + projects: [], + runtimeWork: { + projects: [], + chats: [ + { + deviceId: 'local-device', + deviceName: 'Local Mac', + deviceStatus: 'online', + available: true, + workspacePath: '/workspace/chats/chat-rename', + workspaceKind: 'chat', + localTasks: [ + { + localTaskId: 'codex-rename', + workspacePath: '/workspace/chats/chat-rename', + workspaceKind: 'chat', + title: '对齐需求核心点', + runtime: 'codex', + }, + ], + }, + ], + totalLocalTasks: 1, + }, + onOpenRuntimeLocalTask, + onRenameRuntimeLocalTask, + }) + + await user.dblClick(screen.getByTestId('runtime-local-task-row-codex-rename')) + + expect(screen.getByTestId('rename-runtime-local-task-input-codex-rename')).toHaveValue( + '对齐需求核心点' + ) + expect(screen.getByText('保持简短且易于识别')).toBeInTheDocument() + + await user.clear(screen.getByTestId('rename-runtime-local-task-input-codex-rename')) + await user.type(screen.getByTestId('rename-runtime-local-task-input-codex-rename'), '对齐方案') + await user.click(screen.getByTestId('confirm-rename-runtime-local-task-codex-rename')) + + await waitFor(() => { + expect(onRenameRuntimeLocalTask).toHaveBeenCalledWith( + { + deviceId: 'local-device', + localTaskId: 'codex-rename', + }, + '对齐方案' + ) + }) + }) + test('renders project runtime tasks directly under projects and opens by address', async () => { const onOpenRuntimeLocalTask = vi.fn() @@ -230,7 +264,7 @@ describe('DesktopSidebar', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 1, }, onOpenRuntimeLocalTask, @@ -253,6 +287,36 @@ describe('DesktopSidebar', () => { }) }) + test('marks remote runtime projects separately from local projects', () => { + renderSidebar({ + runtimeWork: { + projects: [ + { + project: { id: 7, key: 'remote-project-id', name: 'Wegent' }, + deviceWorkspaces: [ + { + id: 91, + deviceId: 'remote-device', + deviceName: '10.201.3.200', + deviceStatus: 'online', + available: true, + workspacePath: '/home/ubuntu/workspace/Wegent', + workspaceSource: 'remote', + remoteHostId: 'remote-ssh-discovered:10.201.3.200', + localTasks: [], + }, + ], + }, + ], + chats: [], + totalLocalTasks: 0, + }, + }) + + expect(screen.getByTestId('project-remote-folder-icon-7')).toBeInTheDocument() + expect(screen.queryByTestId('project-folder-icon-7')).not.toBeInTheDocument() + }) + test('shows running status on running runtime tasks only', async () => { renderSidebar({ runtimeWork: { @@ -290,7 +354,7 @@ describe('DesktopSidebar', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 2, }, }) @@ -364,7 +428,7 @@ describe('DesktopSidebar', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 2, }, }) @@ -383,15 +447,223 @@ describe('DesktopSidebar', () => { ).not.toBeInTheDocument() }) - test('shows hover actions to mark and archive project runtime tasks', async () => { + test('shows hover actions and an undo notice before archiving project runtime tasks', async () => { const user = userEvent.setup() const onArchiveRuntimeLocalTask = vi.fn().mockResolvedValue(undefined) + const originalSetTimeout = window.setTimeout + const originalClearTimeout = window.clearTimeout + const archiveTimerId = 2200 + let archiveTimerCallback: (() => void) | null = null + const setTimeoutSpy = vi + .spyOn(window, 'setTimeout') + .mockImplementation((handler: TimerHandler, timeout?: number) => { + if (timeout === archiveTimerId && typeof handler === 'function') { + archiveTimerCallback = handler + return archiveTimerId + } + return originalSetTimeout(handler, timeout) + }) + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout').mockImplementation((id?: number) => { + if (id === archiveTimerId) { + archiveTimerCallback = null + return + } + originalClearTimeout(id) + }) + + try { + renderSidebar({ + runtimeWork: { + projects: [ + { + project: { id: 7, name: 'Wegent' }, + totalLocalTasks: 1, + deviceWorkspaces: [ + { + id: 91, + deviceId: 'local-device', + deviceName: 'Local Mac', + deviceStatus: 'online', + available: true, + workspacePath: '/repo/Wegent', + localTasks: [ + { + localTaskId: 'codex-1', + workspacePath: '/repo/Wegent', + title: 'Fix reconnect', + runtime: 'codex', + updatedAt: '2026-06-20T02:00:00Z', + }, + ], + }, + ], + }, + ], + chats: [], + totalLocalTasks: 1, + }, + onArchiveRuntimeLocalTask, + }) + + await user.click(screen.getByTestId('project-item-button')) + const taskRow = screen.getByTestId('runtime-local-task-row-codex-1') + const rowChildren = Array.from(taskRow.children) + + expect(screen.queryByTestId('runtime-local-task-mark-codex-1')).not.toBeInTheDocument() + expect(screen.getByTestId('runtime-local-task-archive-codex-1')).toBeInTheDocument() + expect(rowChildren).toHaveLength(2) + expect(rowChildren[1]).toHaveAttribute('data-testid', 'runtime-local-task-trailing-codex-1') + expect(screen.getByTestId('runtime-local-task-time-codex-1').parentElement).toBe( + rowChildren[1] + ) + expect( + screen.queryByTestId('runtime-local-task-device-marker-codex-1') + ).not.toBeInTheDocument() + expect(screen.getByTestId('runtime-local-task-hover-actions-codex-1').parentElement).toBe( + rowChildren[1] + ) + expect(screen.queryByTestId('runtime-local-task-pin-icon-codex-1')).not.toBeInTheDocument() + expect(screen.getByTestId('runtime-local-task-archive-icon-codex-1')).toBeInTheDocument() + expect( + screen.getByTestId('runtime-local-task-hover-actions-codex-1').className + ).not.toContain('focus-within') + expect(screen.getByTestId('runtime-local-task-time-codex-1').className).not.toContain( + 'focus-within' + ) + + expect(taskRow).not.toHaveAttribute('data-marked') + expect(taskRow.className).not.toContain('color-sidebar-marked') + + await user.click(screen.getByTestId('runtime-local-task-archive-codex-1')) + + expect(onArchiveRuntimeLocalTask).not.toHaveBeenCalled() + expect(screen.getByTestId('runtime-local-task-archive-toast-codex-1')).toHaveTextContent( + '撤销' + ) + + await user.click(screen.getByTestId('runtime-local-task-archive-undo-codex-1')) + + expect(onArchiveRuntimeLocalTask).not.toHaveBeenCalled() + expect(archiveTimerCallback).toBeNull() + expect( + screen.queryByTestId('runtime-local-task-archive-toast-codex-1') + ).not.toBeInTheDocument() + + await user.click(screen.getByTestId('runtime-local-task-archive-codex-1')) + expect(screen.getByTestId('runtime-local-task-archive-toast-codex-1')).toBeInTheDocument() + expect(archiveTimerCallback).toBeTypeOf('function') + + const runArchiveTimer = archiveTimerCallback + await act(async () => { + runArchiveTimer?.() + await Promise.resolve() + }) + + await waitFor(() => + expect(onArchiveRuntimeLocalTask).toHaveBeenCalledWith({ + deviceId: 'local-device', + localTaskId: 'codex-1', + }) + ) + expect( + screen.queryByTestId('runtime-local-task-archive-toast-codex-1') + ).not.toBeInTheDocument() + } finally { + setTimeoutSpy.mockRestore() + clearTimeoutSpy.mockRestore() + } + }) + + test('opens centered archive confirmation dialog for project archive', async () => { + const user = userEvent.setup() + const confirmSpy = vi.spyOn(window, 'confirm') + const onArchiveProjectConversations = vi.fn().mockResolvedValue(undefined) renderSidebar({ runtimeWork: { projects: [ { - project: { id: 7, name: 'Wegent' }, + project: { id: 7, key: 'project:7', name: 'Wegent' }, + totalLocalTasks: 2, + deviceWorkspaces: [ + { + id: 91, + deviceId: 'local-device', + deviceName: 'Local Mac', + deviceStatus: 'online', + available: true, + workspacePath: '/repo/Wegent', + localTasks: [ + { + localTaskId: 'codex-1', + workspacePath: '/repo/Wegent', + title: 'Fix reconnect', + runtime: 'codex', + }, + { + localTaskId: 'codex-2', + workspacePath: '/repo/Wegent', + title: 'Follow up', + runtime: 'codex', + }, + ], + }, + ], + }, + ], + chats: [], + totalLocalTasks: 2, + }, + onArchiveProjectConversations, + }) + + await user.click(screen.getByTestId('project-menu-7')) + await user.click(screen.getByTestId('archive-project-conversations-7')) + + const dialog = screen.getByTestId('archive-project-conversations-dialog-7') + expect(dialog).toHaveTextContent('归档 2 个对话?') + expect(dialog).toHaveTextContent('这会将 Wegent 中的对话归档') + expect(confirmSpy).not.toHaveBeenCalled() + + await user.click(screen.getByTestId('archive-project-conversations-dialog-7-confirm-button')) + + await waitFor(() => { + expect(onArchiveProjectConversations).toHaveBeenCalledWith('project:7') + }) + expect(confirmSpy).not.toHaveBeenCalled() + + confirmSpy.mockRestore() + }) + + test('renames a project from the project row menu', async () => { + const user = userEvent.setup() + const onUpdateProjectName = vi.fn().mockResolvedValue(undefined) + + renderSidebar({ onUpdateProjectName }) + + await user.click(screen.getByTestId('project-menu-7')) + await user.click(screen.getByTestId('rename-project-7')) + await user.clear(screen.getByTestId('rename-project-input')) + await user.type(screen.getByTestId('rename-project-input'), 'weekly-mail') + await user.click(screen.getByTestId('confirm-rename-project-button')) + + await waitFor(() => { + expect(onUpdateProjectName).toHaveBeenCalledWith(7, 'weekly-mail') + }) + }) + + test('keeps runtime project rename and remove actions enabled without move project action', async () => { + const user = userEvent.setup() + const onUpdateProjectName = vi.fn().mockResolvedValue(undefined) + const onRemoveProject = vi.fn().mockResolvedValue(undefined) + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + renderSidebar({ + projects: [], + runtimeWork: { + projects: [ + { + project: { id: 7, key: 'project:7', name: 'Wegent' }, totalLocalTasks: 1, deviceWorkspaces: [ { @@ -407,53 +679,43 @@ describe('DesktopSidebar', () => { workspacePath: '/repo/Wegent', title: 'Fix reconnect', runtime: 'codex', - updatedAt: '2026-06-20T02:00:00Z', }, ], }, ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 1, }, - onArchiveRuntimeLocalTask, + onUpdateProjectName, + onRemoveProject, }) - await user.click(screen.getByTestId('project-item-button')) - const taskRow = screen.getByTestId('runtime-local-task-row-codex-1') - const rowChildren = Array.from(taskRow.children) + await user.click(screen.getByTestId('project-menu-7')) - expect(screen.getByTestId('runtime-local-task-mark-codex-1')).toBeInTheDocument() - expect(screen.getByTestId('runtime-local-task-archive-codex-1')).toBeInTheDocument() - expect(rowChildren).toHaveLength(2) - expect(rowChildren[1]).toHaveAttribute('data-testid', 'runtime-local-task-trailing-codex-1') - expect(screen.getByTestId('runtime-local-task-time-codex-1').parentElement).toBe(rowChildren[1]) - expect(screen.queryByTestId('runtime-local-task-device-marker-codex-1')).not.toBeInTheDocument() - expect(screen.getByTestId('runtime-local-task-hover-actions-codex-1').parentElement).toBe( - rowChildren[1] - ) - expect(screen.getByTestId('runtime-local-task-pin-icon-codex-1')).toBeInTheDocument() - expect(screen.getByTestId('runtime-local-task-archive-icon-codex-1')).toBeInTheDocument() - expect(screen.getByTestId('runtime-local-task-hover-actions-codex-1').className).not.toContain( - 'focus-within' - ) - expect(screen.getByTestId('runtime-local-task-time-codex-1').className).not.toContain( - 'focus-within' - ) + expect(screen.getByTestId('rename-project-7')).not.toBeDisabled() + expect(screen.getByTestId('remove-project-7')).not.toBeDisabled() + expect(screen.queryByTestId('move-project-7')).not.toBeInTheDocument() - await user.click(screen.getByTestId('runtime-local-task-mark-codex-1')) + await user.click(screen.getByTestId('rename-project-7')) + await user.clear(screen.getByTestId('rename-project-input')) + await user.type(screen.getByTestId('rename-project-input'), 'weekly-mail') + await user.click(screen.getByTestId('confirm-rename-project-button')) - expect(taskRow).toHaveAttribute('data-marked', 'true') - expect(taskRow.className).toContain('color-sidebar-marked') - expect(taskRow.className).toContain('color-sidebar-marked-hover') + await waitFor(() => { + expect(onUpdateProjectName).toHaveBeenCalledWith(7, 'weekly-mail') + }) - await user.click(screen.getByTestId('runtime-local-task-archive-codex-1')) + await user.click(screen.getByTestId('project-menu-7')) + await user.click(screen.getByTestId('remove-project-7')) - expect(onArchiveRuntimeLocalTask).toHaveBeenCalledWith({ - deviceId: 'local-device', - localTaskId: 'codex-1', + expect(confirmSpy).toHaveBeenCalled() + await waitFor(() => { + expect(onRemoveProject).toHaveBeenCalledWith(7) }) + + confirmSpy.mockRestore() }) test('shows a global IM notification quick toggle near settings', async () => { @@ -523,62 +785,110 @@ describe('DesktopSidebar', () => { expect(onToggleGlobalImNotification).not.toHaveBeenCalled() }) - test('edits an existing project by associating another device workspace', async () => { + test('shows archive all menus on project and chat headers with chat create action', async () => { const user = userEvent.setup() - const onCreateProject = vi.fn() - const onPrepareDeviceWorkspace = vi.fn().mockResolvedValue({ - preparedAction: 'selected', - mapping: { - id: 20, - userId: 1, - projectId: 7, - deviceId: 'second-device', - workspacePath: '/Users/alice', - repoUrl: null, - repoRootFingerprint: null, - label: null, - createdAt: '2026-06-21T00:00:00', - updatedAt: '2026-06-21T00:00:00', - lastSeenAt: null, + const onArchiveProjectsConversations = vi.fn().mockResolvedValue(undefined) + const onArchiveChatConversations = vi.fn().mockResolvedValue(undefined) + const onNewChat = vi.fn() + + renderSidebar({ + onNewChat, + onArchiveProjectsConversations, + onArchiveChatConversations, + runtimeWork: { + projects: [ + { + project: { id: 7, key: 'project:7', name: 'Wegent' }, + totalLocalTasks: 1, + deviceWorkspaces: [ + { + id: 91, + deviceId: 'local-device', + deviceName: 'Local Mac', + deviceStatus: 'online', + available: true, + workspacePath: '/repo/Wegent', + localTasks: [ + { + localTaskId: 'codex-1', + workspacePath: '/repo/Wegent', + title: 'Fix reconnect', + runtime: 'codex', + }, + ], + }, + ], + }, + ], + chats: [ + { + id: null, + deviceId: 'local-device', + deviceName: 'Local Mac', + deviceStatus: 'online', + available: true, + workspacePath: '/workspace/chats/chat-1', + workspaceKind: 'chat', + localTasks: [ + { + localTaskId: 'chat-1', + workspacePath: '/workspace/chats/chat-1', + workspaceKind: 'chat', + title: 'Hello', + runtime: 'codex', + }, + ], + }, + ], + totalLocalTasks: 2, }, }) - renderSidebar({ - devices: [ - localDevice(), - localDevice({ - id: 2, - device_id: 'second-device', - name: 'Second Mac', - is_default: false, - }), - ], - onCreateProject, - onPrepareDeviceWorkspace, - onGetDeviceHomeDirectory: vi.fn().mockResolvedValue('/Users/alice'), - onListDeviceDirectories: vi.fn().mockResolvedValue([]), + await user.click(screen.getByTestId('projects-section-menu')) + expect(screen.getByTestId('projects-section-archive-all-chats')).toHaveTextContent( + '归档所有聊天' + ) + await user.click(screen.getByTestId('projects-section-archive-all-chats')) + + expect(screen.getByTestId('projects-section-archive-conversations-dialog')).toHaveTextContent( + '归档 1 个对话?' + ) + expect(screen.getByTestId('projects-section-archive-conversations-dialog')).toHaveTextContent( + '项目中的对话' + ) + await user.click( + screen.getByTestId('projects-section-archive-conversations-dialog-confirm-button') + ) + await waitFor(() => { + expect(onArchiveProjectsConversations).toHaveBeenCalledWith(['project:7']) }) - await user.click(screen.getByTestId('project-menu-7')) - await user.click(screen.getByTestId('edit-project-7')) + await user.click(screen.getByTestId('runtime-chat-section-new-chat-button')) + expect(onNewChat).toHaveBeenCalledTimes(1) - await user.click(await screen.findByTestId('project-device-tab-second-device')) - await user.click(screen.getByTestId('project-folder-select-button')) - await waitFor(() => - expect(screen.getByTestId('device-folder-path-input')).toHaveValue('/Users/alice') + await user.click(screen.getByTestId('runtime-chat-section-menu')) + expect(screen.getByTestId('runtime-chat-section-archive-all-chats')).toHaveTextContent( + '归档所有聊天' ) - await user.click(screen.getByTestId('confirm-device-folder-picker-button')) - await user.click(screen.getByTestId('create-project-button')) - - await waitFor(() => - expect(onPrepareDeviceWorkspace).toHaveBeenCalledWith({ - projectId: 7, - deviceId: 'second-device', - workspacePath: '/Users/alice', - action: 'select', - }) + await user.click(screen.getByTestId('runtime-chat-section-archive-all-chats')) + expect( + screen.getByTestId('runtime-chat-section-archive-conversations-dialog') + ).toHaveTextContent('归档 1 个对话?') + expect( + screen.getByTestId('runtime-chat-section-archive-conversations-dialog') + ).toHaveTextContent('对话列表中的对话') + await user.click( + screen.getByTestId('runtime-chat-section-archive-conversations-dialog-confirm-button') ) - expect(onCreateProject).not.toHaveBeenCalled() + + await waitFor(() => { + expect(onArchiveChatConversations).toHaveBeenCalledWith([ + { + deviceId: 'local-device', + localTaskId: 'chat-1', + }, + ]) + }) }) test('shows a subscribed runtime task notification toggle outside hover actions', async () => { @@ -613,7 +923,7 @@ describe('DesktopSidebar', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 1, }, imNotificationSettings: { @@ -678,7 +988,7 @@ describe('DesktopSidebar', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, }, }) @@ -720,7 +1030,7 @@ describe('DesktopSidebar', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 1, }, onOpenRuntimeLocalTask, @@ -816,7 +1126,7 @@ describe('DesktopSidebar', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 6, }, }) @@ -848,13 +1158,13 @@ describe('DesktopSidebar', () => { expect(screen.queryByText('Oldest hidden task')).not.toBeInTheDocument() }) - test('selects a project when its sidebar row is clicked', async () => { - const onSelectProject = vi.fn() - - renderSidebar({ onSelectProject }) + test('toggles a project when its sidebar row is clicked', async () => { + renderSidebar() + const button = screen.getByTestId('project-item-button') + expect(button).toHaveAttribute('aria-expanded', 'false') await userEvent.click(screen.getByTestId('project-item-button')) - expect(onSelectProject).toHaveBeenCalledWith(7) + expect(button).toHaveAttribute('aria-expanded', 'true') }) }) diff --git a/wework/src/components/layout/DesktopSidebar.tsx b/wework/src/components/layout/DesktopSidebar.tsx index fe62f43274..5d51be2612 100644 --- a/wework/src/components/layout/DesktopSidebar.tsx +++ b/wework/src/components/layout/DesktopSidebar.tsx @@ -8,7 +8,6 @@ import { GitCompareArrows, Loader2, MessageSquarePlus, - Pin, Plus, RotateCw, Settings, @@ -20,26 +19,18 @@ import type { KeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from 're import { createPortal } from 'react-dom' import { ActionMenu } from '@/components/common/ActionMenu' import { TextInputDialog } from '@/components/common/TextInputDialog' -import { ProjectCreateDialog } from '@/components/projects/ProjectCreateDialog' import { ProjectFolderIcon } from '@/components/projects/ProjectFolderIcon' import { StandaloneBlankProjectDialog, StandaloneFolderProjectDialog, type StandaloneWorkspaceDialogMode, } from '@/components/projects/StandaloneProjectDialogs' +import { useEscapeKey } from '@/hooks/useEscapeKey' import { useTranslation } from '@/hooks/useTranslation' import { runtimeProjectUiId } from '@/lib/runtime-project' import { cn } from '@/lib/utils' import type { - CreateGitWorkspaceProjectRequest, - CreateProjectRequest, - DeleteDeviceWorkspaceRequest, - DeviceWorkspacePrepareRequest, - DeviceWorkspacePrepareResponse, - DeviceWorkspaceResponse, DeviceInfo, - GitBranch, - GitRepoInfo, LocalTaskSummary, ProjectWithTasks, RuntimeDeviceWorkspace, @@ -49,7 +40,6 @@ import type { RuntimeWorkListResponse, User as UserProfile, } from '@/types/api' -import type { DeviceUpgradeState } from '@/types/device-events' import { DesktopSettingsMenu } from './DesktopSettingsMenu' import { DesktopTopBar } from './DesktopTopBar' import { DesktopWindowControls } from './DesktopWindowControls' @@ -76,37 +66,32 @@ interface DesktopSidebarProps { standaloneWorkspacePath?: string | null imNotificationSettings?: RuntimeIMNotificationSettingsResponse | null preferredDeviceId?: string | null - upgradingDevices?: Record activeItem?: 'chat' | 'plugins' | 'automation' onCollapse: () => void onNewChat: () => void - onSelectProject: (projectId: number) => void onStartNewProjectChat: (projectId: number) => void onOpenRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise | void + onRenameRuntimeLocalTask?: (address: RuntimeTaskAddress, title: string) => Promise | void onArchiveRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise | void + onArchiveProjectConversations?: (runtimeProjectKey: string) => Promise | void + onArchiveProjectsConversations?: (runtimeProjectKeys: string[]) => Promise | void + onArchiveChatConversations?: (addresses: RuntimeTaskAddress[]) => Promise | void onToggleRuntimeTaskNotification?: ( address: RuntimeTaskAddress, subscribed: boolean ) => Promise | void onToggleGlobalImNotification?: () => Promise | void onOpenGlobalImNotificationSettings?: () => Promise | void - onRememberExecutionDevice?: (deviceId: string) => void onOpenPlugins: () => void onRefreshDevices?: () => Promise - onUpgradeDevice?: (deviceId: string) => Promise - onOpenStandaloneWorkspace?: (deviceId: string, workspacePath: string) => void - onCreateProject: (data: CreateProjectRequest) => Promise - onCreateGitWorkspaceProject: (data: CreateGitWorkspaceProjectRequest) => Promise - onPrepareDeviceWorkspace: ( - data: DeviceWorkspacePrepareRequest - ) => Promise - onDeleteDeviceWorkspace: (data: DeleteDeviceWorkspaceRequest) => Promise - onListGitRepositories: () => Promise - onListGitBranches: (repo: GitRepoInfo) => Promise + onOpenStandaloneWorkspace?: ( + deviceId: string, + workspacePath: string, + label?: string + ) => Promise | void onUpdateProjectName: (projectId: number, name: string) => Promise onRemoveProject: (projectId: number) => Promise onGetDeviceHomeDirectory: (deviceId: string) => Promise - onGetProjectWorkspaceRoot: (deviceId: string) => Promise onListDeviceDirectories: (deviceId: string, path: string) => Promise onCreateDeviceDirectory: (deviceId: string, path: string) => Promise onOpenSettings: (options?: { autoOpenAddCloudDeviceDialog?: boolean }) => void @@ -119,8 +104,21 @@ type ProjectCreateMenuPosition = { left: number } +interface ArchiveConversationsConfirmDialogProps { + open: boolean + title: string + description: string + confirmLabel: string + cancelLabel: string + submitting: boolean + testId: string + onClose: () => void + onConfirm: () => Promise | void +} + const PROJECT_CREATE_MENU_WIDTH = 248 const PROJECT_CREATE_MENU_MARGIN = 8 +const RUNTIME_ARCHIVE_UNDO_DELAY_MS = 2200 function getStandaloneDeviceLabel(device: DeviceInfo): string { return device.name || device.device_id @@ -193,6 +191,76 @@ function standaloneRuntimeProjectWork( } } +function ArchiveConversationsConfirmDialog({ + open, + title, + description, + confirmLabel, + cancelLabel, + submitting, + testId, + onClose, + onConfirm, +}: ArchiveConversationsConfirmDialogProps) { + useEscapeKey(onClose, open && !submitting) + + if (!open) return null + + return createPortal( +
+
+
+

+ {title} +

+ +
+

{description}

+
+ + +
+
+
, + document.body + ) +} + const SIDEBAR_ROW_METADATA_CLASS = 'flex items-center gap-1 text-xs text-[rgb(var(--color-sidebar-text-muted))] group-hover/task:invisible' const SIDEBAR_RUNNING_SPINNER_CLASS = @@ -469,6 +537,12 @@ function getRuntimeProjectDeviceState( } } +function isRuntimeRemoteProject(runtimeProjectWork: RuntimeProjectWork | undefined): boolean { + return Boolean( + runtimeProjectWork?.deviceWorkspaces.some(workspace => workspace.workspaceSource === 'remote') + ) +} + function shouldShowProjectDeviceStatus( deviceState: SidebarDeviceState | null, devices: DeviceInfo[] @@ -481,31 +555,6 @@ function getRuntimeWorkspaceDeviceColor(workspace: RuntimeDeviceWorkspace): stri return getSidebarDeviceColor(getSidebarDeviceColorKey(workspace.deviceName, workspace.deviceId)) } -function getProjectDeviceWorkspaces( - runtimeWork: RuntimeWorkListResponse | null | undefined, - projectId: number | undefined -): DeviceWorkspaceResponse[] { - if (!runtimeWork || projectId === undefined) return [] - const projectWork = runtimeWork.projects.find( - item => runtimeProjectUiId(item.project) === projectId - ) - return ( - projectWork?.deviceWorkspaces.map((workspace, index) => ({ - id: workspace.id ?? -(index + 1), - userId: 0, - projectId, - deviceId: workspace.deviceId, - workspacePath: workspace.workspacePath, - repoUrl: workspace.repoUrl ?? null, - repoRootFingerprint: workspace.repoRootFingerprint ?? null, - label: workspace.label ?? null, - lastSeenAt: null, - createdAt: '', - updatedAt: '', - })) ?? [] - ) -} - function getSidebarDeviceStatusLabel( t: ReturnType['t'], status: SidebarDeviceStatus @@ -624,6 +673,7 @@ function RuntimeLocalTaskRow({ imNotificationSettings, showDeviceMarker, onOpenRuntimeLocalTask, + onRenameRuntimeLocalTask, onArchiveRuntimeLocalTask, onToggleRuntimeTaskNotification, }: { @@ -634,6 +684,7 @@ function RuntimeLocalTaskRow({ imNotificationSettings?: RuntimeIMNotificationSettingsResponse | null showDeviceMarker: boolean onOpenRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise | void + onRenameRuntimeLocalTask?: (address: RuntimeTaskAddress, title: string) => Promise | void onArchiveRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise | void onToggleRuntimeTaskNotification?: ( address: RuntimeTaskAddress, @@ -641,13 +692,17 @@ function RuntimeLocalTaskRow({ ) => Promise | void }) { const { t } = useTranslation('common') - const [marked, setMarked] = useState(false) const [archiving, setArchiving] = useState(false) + const [archivePending, setArchivePending] = useState(false) + const [archiveNoticeOpen, setArchiveNoticeOpen] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const archiveDelayRef = useRef | null>(null) const worktreeTask = isRuntimeWorktreeTask(task) const workspaceTitle = getRuntimeTaskWorkspaceTitle(workspace) const deviceColor = getRuntimeWorkspaceDeviceColor(workspace) const disabled = !workspace.available || !onOpenRuntimeLocalTask - const archiveDisabled = !workspace.available || !onArchiveRuntimeLocalTask || archiving + const archiveDisabled = + !workspace.available || !onArchiveRuntimeLocalTask || archiving || archivePending const taskAddress = getRuntimeTaskAddress(workspace, task) const notificationsSubscribed = isRuntimeTaskNotificationSubscribed( imNotificationSettings, @@ -658,19 +713,39 @@ function RuntimeLocalTaskRow({ if (disabled) return void onOpenRuntimeLocalTask?.(taskAddress) } - const handleToggleMark = (event: ReactMouseEvent) => { - event.stopPropagation() - event.currentTarget.blur() - setMarked(value => !value) - } + useEffect(() => { + return () => { + if (archiveDelayRef.current !== null) { + window.clearTimeout(archiveDelayRef.current) + } + } + }, []) const handleArchive = (event: ReactMouseEvent) => { event.stopPropagation() event.currentTarget.blur() if (archiveDisabled) return - setArchiving(true) - void Promise.resolve(onArchiveRuntimeLocalTask?.(taskAddress)).finally(() => { - setArchiving(false) - }) + setArchivePending(true) + setArchiveNoticeOpen(true) + archiveDelayRef.current = window.setTimeout(() => { + archiveDelayRef.current = null + setArchivePending(false) + setArchiveNoticeOpen(false) + setArchiving(true) + void Promise.resolve(onArchiveRuntimeLocalTask?.(taskAddress)).finally(() => { + setArchiving(false) + }) + }, RUNTIME_ARCHIVE_UNDO_DELAY_MS) + } + const handleUndoArchive = () => { + if (archiveDelayRef.current !== null) { + window.clearTimeout(archiveDelayRef.current) + archiveDelayRef.current = null + } + setArchivePending(false) + setArchiveNoticeOpen(false) + } + const handleDismissArchiveNotice = () => { + setArchiveNoticeOpen(false) } const handleToggleNotification = (event: ReactMouseEvent) => { event.stopPropagation() @@ -703,130 +778,154 @@ function RuntimeLocalTaskRow({ ) return ( -
handleSidebarRowKeyDown(event, handleOpen)} - className={cn( - 'group/task relative flex h-8 min-w-0 items-center rounded-md pr-2 text-[13px] leading-[18px]', - indentClassName, - disabled ? 'cursor-not-allowed opacity-55' : 'cursor-default', - selected - ? 'bg-[rgb(var(--color-sidebar-active))] text-text-primary' - : marked - ? 'bg-[rgb(var(--color-sidebar-marked))] text-[rgb(var(--color-sidebar-text-primary))] hover:bg-[rgb(var(--color-sidebar-marked-hover))]' + <> +
{ + event.stopPropagation() + if (!disabled && onRenameRuntimeLocalTask) { + setRenameOpen(true) + } + }} + onKeyDown={event => handleSidebarRowKeyDown(event, handleOpen)} + className={cn( + 'group/task relative flex h-8 min-w-0 items-center rounded-md pr-2 text-[13px] leading-[18px]', + indentClassName, + disabled ? 'cursor-not-allowed opacity-55' : 'cursor-default', + selected + ? 'bg-[rgb(var(--color-sidebar-active))] text-text-primary' : 'text-[rgb(var(--color-sidebar-text-primary))] hover:bg-[rgb(var(--color-sidebar-hover))]' - )} - > - - {task.title} - - + + {task.title} + - {worktreeTask && ( - - )} - {notificationsSubscribed && - renderNotificationButton( - `runtime-local-task-notify-${task.localTaskId}`, - `runtime-local-task-notify-icon-${task.localTaskId}` + + {worktreeTask && ( + )} - - {task.running ? ( + {notificationsSubscribed && + renderNotificationButton( + `runtime-local-task-notify-${task.localTaskId}`, + `runtime-local-task-notify-icon-${task.localTaskId}` + )} + + {task.running ? ( + + + ) : ( + formatRelativeSidebarTime(getRuntimeTaskTime(task)) + )} + + {showDeviceMarker && ( - - ) : ( - formatRelativeSidebarTime(getRuntimeTaskTime(task)) + data-testid={`runtime-local-task-device-marker-${task.localTaskId}`} + title={workspaceTitle} + aria-label={workspaceTitle} + className="h-3.5 w-0.5 shrink-0 rounded-full" + style={{ backgroundColor: deviceColor }} + /> )} - {showDeviceMarker && ( - - )} - - - {renderNotificationButton( - notificationsSubscribed - ? `runtime-local-task-notify-hover-${task.localTaskId}` - : `runtime-local-task-notify-${task.localTaskId}`, - notificationsSubscribed - ? `runtime-local-task-notify-hover-icon-${task.localTaskId}` - : `runtime-local-task-notify-icon-${task.localTaskId}` - )} - - + + - -
+
+ setRenameOpen(false)} + onSubmit={title => onRenameRuntimeLocalTask?.(taskAddress, title)} + /> + {archiveNoticeOpen && + createPortal( +
+ + {t('workbench.archive_runtime_task_pending', ',稍后将归档')} + +
, + document.body + )} + ) } @@ -834,37 +933,35 @@ function ProjectItem({ project, expanded, onToggleProject, - onSelectProject, devices, runtimeProjectWork, - runtimeOnlyProject, currentRuntimeTask, imNotificationSettings, showDeviceMarker, onStartNewProjectChat, onRemoveProject, - onEditProject, onRenameProject, onOpenRuntimeLocalTask, + onRenameRuntimeLocalTask, onArchiveRuntimeLocalTask, + onArchiveProjectConversations, onToggleRuntimeTaskNotification, }: { project: ProjectWithTasks expanded: boolean onToggleProject: (projectId: number) => void - onSelectProject: (projectId: number) => void devices: DeviceInfo[] runtimeProjectWork?: RuntimeProjectWork - runtimeOnlyProject: boolean currentRuntimeTask?: RuntimeTaskAddress | null imNotificationSettings?: RuntimeIMNotificationSettingsResponse | null showDeviceMarker: boolean onStartNewProjectChat: (projectId: number) => void onRemoveProject: (projectId: number) => Promise - onEditProject: (project: ProjectWithTasks) => void onRenameProject: (project: ProjectWithTasks) => void onOpenRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise | void + onRenameRuntimeLocalTask?: (address: RuntimeTaskAddress, title: string) => Promise | void onArchiveRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise | void + onArchiveProjectConversations?: (runtimeProjectKey: string) => Promise | void onToggleRuntimeTaskNotification?: ( address: RuntimeTaskAddress, subscribed: boolean @@ -877,6 +974,8 @@ function ProjectItem({ [runtimeWorkspaces] ) const [runtimeTasksExpanded, setRuntimeTasksExpanded] = useState(false) + const [projectArchiving, setProjectArchiving] = useState(false) + const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false) const visibleRuntimeTaskItems = useMemo( () => getVisibleRuntimeSidebarTaskItems(runtimeTaskItems, runtimeTasksExpanded), [runtimeTaskItems, runtimeTasksExpanded] @@ -887,10 +986,33 @@ function ProjectItem({ getSidebarDeviceState(getProjectDeviceId(project), devices) const showProjectDeviceStatus = shouldShowProjectDeviceStatus(projectDeviceState, devices) const canStartProjectChat = isSidebarDeviceOnline(projectDeviceState) + const canArchiveProjectConversations = + Boolean(runtimeProjectWork?.project.key) && + runtimeTaskItems.length > 0 && + Boolean(onArchiveProjectConversations) && + !projectArchiving const newProjectChatTitle = projectDeviceState && !canStartProjectChat ? getDeviceUnavailableActionTitle(t, projectDeviceState) : t('workbench.new_project_chat', '新建项目对话') + const archiveConversationCount = runtimeTaskItems.length + const archiveProjectName = runtimeProjectWork?.project.name ?? project.name + const closeArchiveConfirm = () => { + if (!projectArchiving) { + setArchiveConfirmOpen(false) + } + } + const confirmArchiveProjectConversations = async () => { + const runtimeProjectKey = runtimeProjectWork?.project.key + if (!runtimeProjectKey || !onArchiveProjectConversations) return + setProjectArchiving(true) + try { + await onArchiveProjectConversations(runtimeProjectKey) + setArchiveConfirmOpen(false) + } finally { + setProjectArchiving(false) + } + } return (
@@ -902,7 +1024,6 @@ function ProjectItem({ type="button" data-testid="project-item-button" onClick={() => { - onSelectProject(project.id) onToggleProject(project.id) }} aria-expanded={expanded} @@ -910,6 +1031,7 @@ function ProjectItem({ > @@ -923,50 +1045,55 @@ function ProjectItem({ /> )} - {!runtimeOnlyProject && ( -
- onEditProject(project), - }, - { - label: t('workbench.rename_project', '重命名项目'), - icon: Edit3, - testId: `rename-project-${project.id}`, - onSelect: () => onRenameProject(project), - }, - { - label: t('workbench.remove_project', '移除'), - icon: X, - testId: `remove-project-${project.id}`, - danger: true, - onSelect: () => onRemoveProject(project.id), +
+ onRenameProject(project), + }, + { + label: projectArchiving + ? t('workbench.archiving_conversations', '归档中...') + : t('workbench.archive_project_conversations', '归档对话'), + icon: Archive, + testId: `archive-project-conversations-${project.id}`, + disabled: !canArchiveProjectConversations, + onSelect: () => setArchiveConfirmOpen(true), + }, + { + label: t('workbench.remove_project', '移除'), + icon: X, + testId: `remove-project-${project.id}`, + danger: true, + onSelect: () => { + if (window.confirm(t('workbench.remove_project_confirm', '确定移除该项目吗?'))) { + void onRemoveProject(project.id) + } }, - ]} - /> - -
- )} + }, + ]} + /> + +
{expanded && (
@@ -989,6 +1116,7 @@ function ProjectItem({ imNotificationSettings={imNotificationSettings} showDeviceMarker={showDeviceMarker} onOpenRuntimeLocalTask={onOpenRuntimeLocalTask} + onRenameRuntimeLocalTask={onRenameRuntimeLocalTask} onArchiveRuntimeLocalTask={onArchiveRuntimeLocalTask} onToggleRuntimeTaskNotification={onToggleRuntimeTaskNotification} /> @@ -1013,6 +1141,23 @@ function ProjectItem({ )}
)} + ) } @@ -1027,32 +1172,25 @@ export function DesktopSidebar({ standaloneWorkspacePath, imNotificationSettings, preferredDeviceId, - upgradingDevices = {}, activeItem = 'chat', onCollapse, onNewChat, - onSelectProject, onStartNewProjectChat, onOpenRuntimeLocalTask, + onRenameRuntimeLocalTask, onArchiveRuntimeLocalTask, + onArchiveProjectConversations, + onArchiveProjectsConversations, + onArchiveChatConversations, onToggleRuntimeTaskNotification, onToggleGlobalImNotification, onOpenGlobalImNotificationSettings, - onRememberExecutionDevice, onOpenPlugins, onRefreshDevices, - onUpgradeDevice, onOpenStandaloneWorkspace, - onCreateProject, - onCreateGitWorkspaceProject, - onPrepareDeviceWorkspace, - onDeleteDeviceWorkspace, - onListGitRepositories, - onListGitBranches, onUpdateProjectName, onRemoveProject, onGetDeviceHomeDirectory, - onGetProjectWorkspaceRoot, onListDeviceDirectories, onCreateDeviceDirectory, onOpenSettings, @@ -1072,6 +1210,9 @@ export function DesktopSidebar({ const storageScopeRef = useRef(storageScope) const [settingsMenuOpen, setSettingsMenuOpen] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) + const [archiveSectionMode, setArchiveSectionMode] = useState<'projects' | 'chats' | null>(null) + const [isArchivingProjectSection, setIsArchivingProjectSection] = useState(false) + const [isArchivingChatSection, setIsArchivingChatSection] = useState(false) const settingsMenuRef = useRef(null) const projectCreateMenuRef = useRef(null) const projectCreateMenuFloatingRef = useRef(null) @@ -1081,7 +1222,6 @@ export function DesktopSidebar({ const [blankProjectDialogOpen, setBlankProjectDialogOpen] = useState(false) const [standaloneWorkspaceDialogMode, setStandaloneWorkspaceDialogMode] = useState(null) - const [editingProject, setEditingProject] = useState(null) const [renamingProject, setRenamingProject] = useState(null) const [projectsExpanded, setProjectsExpanded] = useState(() => readStoredBoolean(projectsExpandedStorageKey, true) @@ -1119,17 +1259,32 @@ export function DesktopSidebar({ const runtimeWorkByProjectId = useMemo(() => { return new Map(filteredRuntimeProjects.map(item => [runtimeProjectUiId(item.project), item])) }, [filteredRuntimeProjects]) - const standaloneProjectId = standaloneProjectWork - ? runtimeProjectUiId(standaloneProjectWork.project) - : null - const unmappedWorkspaces = useMemo( - () => runtimeWork?.unmappedDeviceWorkspaces ?? [], - [runtimeWork?.unmappedDeviceWorkspaces] + const chatWorkspaces = useMemo(() => runtimeWork?.chats ?? [], [runtimeWork?.chats]) + const chatTaskItems = useMemo( + () => getRuntimeChatSidebarTaskItems(chatWorkspaces), + [chatWorkspaces] + ) + const projectSectionArchiveItems = useMemo(() => { + return filteredRuntimeProjects + .map(projectWork => ({ + key: projectWork.project.key, + count: getRuntimeSidebarTaskItems(projectWork.deviceWorkspaces).length, + })) + .filter(item => item.count > 0) + }, [filteredRuntimeProjects]) + const projectSectionArchiveKeys = useMemo( + () => projectSectionArchiveItems.map(item => item.key), + [projectSectionArchiveItems] ) - const unmappedChatTaskItems = useMemo( - () => getRuntimeChatSidebarTaskItems(unmappedWorkspaces), - [unmappedWorkspaces] + const projectSectionArchiveCount = useMemo( + () => projectSectionArchiveItems.reduce((total, item) => total + item.count, 0), + [projectSectionArchiveItems] ) + const chatSectionArchiveAddresses = useMemo( + () => chatTaskItems.map(({ workspace, task }) => getRuntimeTaskAddress(workspace, task)), + [chatTaskItems] + ) + const chatSectionArchiveCount = chatSectionArchiveAddresses.length const selectedRuntimeProjectId = useMemo(() => { if (!currentRuntimeTask) return null const projectWork = runtimeWork?.projects.find(item => @@ -1143,12 +1298,49 @@ export function DesktopSidebar({ }, [currentRuntimeTask, runtimeWork?.projects]) const selectedRuntimeChatVisible = useMemo(() => { if (!currentRuntimeTask) return false - return unmappedChatTaskItems.some(({ workspace, task }) => + return chatTaskItems.some(({ workspace, task }) => isRuntimeTaskSelected(currentRuntimeTask, workspace, task) ) - }, [currentRuntimeTask, unmappedChatTaskItems]) + }, [currentRuntimeTask, chatTaskItems]) const displayedProjectsExpanded = projectsExpanded || selectedRuntimeProjectId !== null const displayedChatsExpanded = chatsExpanded || selectedRuntimeChatVisible + const isArchiveSectionSubmitting = + archiveSectionMode === 'projects' ? isArchivingProjectSection : isArchivingChatSection + const archiveSectionDialogTestId = + archiveSectionMode === 'chats' + ? 'runtime-chat-section-archive-conversations-dialog' + : 'projects-section-archive-conversations-dialog' + const archiveSectionDialogCount = + archiveSectionMode === 'chats' ? chatSectionArchiveCount : projectSectionArchiveCount + const closeArchiveSectionDialog = () => { + if (!isArchiveSectionSubmitting) { + setArchiveSectionMode(null) + } + } + const confirmArchiveSectionConversations = async () => { + if (archiveSectionMode === 'projects') { + if (!onArchiveProjectsConversations || projectSectionArchiveKeys.length === 0) return + setIsArchivingProjectSection(true) + try { + await onArchiveProjectsConversations(projectSectionArchiveKeys) + setArchiveSectionMode(null) + } finally { + setIsArchivingProjectSection(false) + } + return + } + + if (archiveSectionMode === 'chats') { + if (!onArchiveChatConversations || chatSectionArchiveAddresses.length === 0) return + setIsArchivingChatSection(true) + try { + await onArchiveChatConversations(chatSectionArchiveAddresses) + setArchiveSectionMode(null) + } finally { + setIsArchivingChatSection(false) + } + } + } const displayedExpandedProjectIds = useMemo(() => { if (selectedRuntimeProjectId === null) return visibleExpandedProjectIds if (visibleExpandedProjectIds.has(selectedRuntimeProjectId)) return visibleExpandedProjectIds @@ -1168,7 +1360,6 @@ export function DesktopSidebar({ } const openProjectCreateMenu = (anchor: HTMLElement) => { - setEditingProject(null) setProjectCreateMenuOpen(open => { if (open) return false @@ -1186,12 +1377,6 @@ export function DesktopSidebar({ void onRefreshDevices?.().catch(() => undefined) } - const openProjectEditDialog = (project: ProjectWithTasks) => { - setEditingProject(project) - setProjectCreateMenuOpen(false) - void onRefreshDevices?.().catch(() => undefined) - } - useEffect(() => { if (storageScopeRef.current !== storageScope) return writeStoredBoolean(projectsExpandedStorageKey, projectsExpanded) @@ -1315,7 +1500,7 @@ export function DesktopSidebar({
@@ -1327,7 +1512,24 @@ export function DesktopSidebar({ iconTestId="projects-section-chevron-right" onToggle={() => setProjectsExpanded(expanded => !expanded)} > -
+
+ setArchiveSectionMode('projects'), + }, + ]} + triggerClassName="flex h-7 w-7 items-center justify-center rounded-md text-[rgb(var(--color-sidebar-text-secondary))] hover:bg-[rgb(var(--color-sidebar-hover))] hover:text-[rgb(var(--color-sidebar-text-primary))]" + /> +
{displayedChatsExpanded && (
- {unmappedChatTaskItems.length === 0 ? ( + {chatTaskItems.length === 0 ? (
) : ( - unmappedChatTaskItems.map(({ workspace, task }) => ( + chatTaskItems.map(({ workspace, task }) => ( @@ -1597,34 +1819,34 @@ export function DesktopSidebar({ onCreateDeviceDirectory={onCreateDeviceDirectory} onOpenStandaloneWorkspace={onOpenStandaloneWorkspace} /> - { - setEditingProject(null) - }} - onOpenCloudDeviceSettings={() => { - setEditingProject(null) - onOpenSettings({ autoOpenAddCloudDeviceDialog: true }) - }} - onCreateProject={onCreateProject} - onCreateGitWorkspaceProject={onCreateGitWorkspaceProject} - onPrepareDeviceWorkspace={onPrepareDeviceWorkspace} - onDeleteDeviceWorkspace={onDeleteDeviceWorkspace} - onUpdateProjectName={onUpdateProjectName} - preferredDeviceId={preferredDeviceId} - onSelectDevicePreference={onRememberExecutionDevice} - upgradingDevices={upgradingDevices} - onUpgradeDevice={onUpgradeDevice} - onGetDeviceHomeDirectory={onGetDeviceHomeDirectory} - onGetProjectWorkspaceRoot={onGetProjectWorkspaceRoot} - onListDeviceDirectories={onListDeviceDirectories} - onCreateDeviceDirectory={onCreateDeviceDirectory} - onListGitRepositories={onListGitRepositories} - onListGitBranches={onListGitBranches} + { ) expect(onOpenStandaloneWorkspace).toHaveBeenCalledWith( 'local-device', - '/home/ubuntu/Documents/New project' + '/home/ubuntu/Documents/New project', + 'New project' ) }) + test('keeps the blank project dialog open when runtime workspace registration fails', async () => { + const onGetDeviceHomeDirectory = vi.fn().mockResolvedValue('/home/ubuntu') + const onCreateDeviceDirectory = vi.fn().mockResolvedValue(undefined) + const onOpenStandaloneWorkspace = vi.fn().mockRejectedValue(new Error('register failed')) + + render( + + ) + + await userEvent.click(screen.getByTestId('projects-create-button')) + await userEvent.click(screen.getByTestId('project-create-blank-option')) + await userEvent.click(screen.getByTestId('save-standalone-blank-project-button')) + + expect(await screen.findByText('register failed')).toBeInTheDocument() + expect(screen.getByTestId('standalone-blank-project-dialog')).toBeInTheDocument() + }) + test('renames a blank project directory when the requested name already exists', async () => { const onGetDeviceHomeDirectory = vi.fn().mockResolvedValue('/home/ubuntu') const onListDeviceDirectories = vi.fn().mockResolvedValue(['New project', 'New project 2']) @@ -1319,7 +1357,8 @@ describe('DesktopWorkbenchLayout', () => { ) expect(onOpenStandaloneWorkspace).toHaveBeenCalledWith( 'local-device', - '/home/ubuntu/Documents/New project 3' + '/home/ubuntu/Documents/New project 3', + 'New project' ) }) @@ -1911,12 +1950,13 @@ describe('DesktopWorkbenchLayout', () => { expect(screen.getByTestId('sidebar-worklists-scroll')).toHaveClass( 'flex-1', 'overflow-y-auto', - 'scrollbar-none' + 'scrollbar-none', + '[overflow-anchor:none]' ) expect(screen.getByTestId('settings-button')).toHaveClass('h-9', 'w-full') }) - test('selects a project while toggling an empty project task list', async () => { + test('toggles an empty project task list without selecting the project', async () => { render() expect(screen.getByTestId('runtime-chat-empty')).toHaveTextContent('暂无会话') @@ -1925,15 +1965,14 @@ describe('DesktopWorkbenchLayout', () => { await userEvent.click(screen.getByTestId('project-item-button')) - expect(baseProps.onSelectProject).toHaveBeenNthCalledWith(1, 1) + expect(baseProps.onSelectProject).not.toHaveBeenCalled() expect(screen.getByTestId('project-local-tasks-empty-1')).toHaveTextContent('暂无会话') expect(screen.getByTestId('project-row-1')).not.toHaveClass('bg-white') await userEvent.click(screen.getByTestId('project-item-button')) expect(screen.queryByTestId('project-local-tasks-empty-1')).not.toBeInTheDocument() - expect(baseProps.onSelectProject).toHaveBeenNthCalledWith(2, 1) - expect(baseProps.onSelectProject).toHaveBeenCalledTimes(2) + expect(baseProps.onSelectProject).not.toHaveBeenCalled() }) test('opens the independent connection settings page from the settings menu', async () => { @@ -3485,7 +3524,7 @@ describe('DesktopWorkbenchLayout', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 1, }, }} diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index eb6282d54f..2ef7cb8147 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -75,10 +75,18 @@ interface DesktopWorkbenchLayoutProps { onStartNewProjectChat: (projectId: number) => void onOpenRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise onLoadOlderRuntimeTranscript?: () => Promise + onRenameRuntimeLocalTask?: (address: RuntimeTaskAddress, title: string) => Promise onArchiveRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise + onArchiveProjectConversations?: (runtimeProjectKey: string) => Promise + onArchiveProjectsConversations?: (runtimeProjectKeys: string[]) => Promise + onArchiveChatConversations?: (addresses: RuntimeTaskAddress[]) => Promise onForkCurrentRuntimeTask?: (target: RuntimeTaskForkTarget) => Promise onRememberExecutionDevice?: (deviceId: string) => void - onOpenStandaloneWorkspace?: (deviceId: string, workspacePath: string) => void + onOpenStandaloneWorkspace?: ( + deviceId: string, + workspacePath: string, + label?: string + ) => Promise | void onRefreshDevices?: () => Promise onUpgradeDevice?: (deviceId: string) => Promise onListImPrivateSessions?: () => Promise @@ -171,11 +179,14 @@ export function DesktopWorkbenchLayout({ onOpenPlugins, projectChat, projectWork, - onSelectProject, onStartNewProjectChat, onOpenRuntimeLocalTask, onLoadOlderRuntimeTranscript, + onRenameRuntimeLocalTask, onArchiveRuntimeLocalTask, + onArchiveProjectConversations, + onArchiveProjectsConversations, + onArchiveChatConversations, onForkCurrentRuntimeTask, onRememberExecutionDevice, onOpenStandaloneWorkspace, @@ -691,30 +702,24 @@ export function DesktopWorkbenchLayout({ activeItem={activeItem} onCollapse={() => setSidebarCollapsed(true)} onNewChat={onNewChat} - onSelectProject={onSelectProject} onStartNewProjectChat={onStartNewProjectChat} onOpenRuntimeLocalTask={onOpenRuntimeLocalTask} + onRenameRuntimeLocalTask={onRenameRuntimeLocalTask} onArchiveRuntimeLocalTask={onArchiveRuntimeLocalTask} + onArchiveProjectConversations={onArchiveProjectConversations} + onArchiveProjectsConversations={onArchiveProjectsConversations} + onArchiveChatConversations={onArchiveChatConversations} onToggleRuntimeTaskNotification={toggleRuntimeTaskNotification} onToggleGlobalImNotification={toggleGlobalImNotification} onOpenGlobalImNotificationSettings={() => openImNotificationTargetDialog({ type: 'global' }) } - onRememberExecutionDevice={onRememberExecutionDevice} onOpenStandaloneWorkspace={onOpenStandaloneWorkspace} onOpenPlugins={onOpenPlugins} onRefreshDevices={onRefreshDevices} - onUpgradeDevice={onUpgradeDevice} - onCreateProject={onCreateProject} - onCreateGitWorkspaceProject={onCreateGitWorkspaceProject} - onPrepareDeviceWorkspace={onPrepareDeviceWorkspace} - onDeleteDeviceWorkspace={onDeleteDeviceWorkspace} - onListGitRepositories={onListGitRepositories} - onListGitBranches={onListGitBranches} onUpdateProjectName={onUpdateProjectName} onRemoveProject={onRemoveProject} onGetDeviceHomeDirectory={onGetDeviceHomeDirectory} - onGetProjectWorkspaceRoot={onGetProjectWorkspaceRoot} onListDeviceDirectories={onListDeviceDirectories} onCreateDeviceDirectory={onCreateDeviceDirectory} onOpenSettings={options => { diff --git a/wework/src/components/layout/MobileDrawer.tsx b/wework/src/components/layout/MobileDrawer.tsx index f7b6e88725..0f636f3b91 100644 --- a/wework/src/components/layout/MobileDrawer.tsx +++ b/wework/src/components/layout/MobileDrawer.tsx @@ -154,13 +154,10 @@ export function MobileDrawer({ const items = runtimeWork?.projects ?? [] return new Map(items.map(item => [runtimeProjectUiId(item.project), item])) }, [runtimeWork]) - const unmappedWorkspaces = useMemo( - () => runtimeWork?.unmappedDeviceWorkspaces ?? [], - [runtimeWork] - ) - const unmappedChatTaskItems = useMemo( - () => getRuntimeChatSidebarTaskItems(unmappedWorkspaces), - [unmappedWorkspaces] + const chatWorkspaces = useMemo(() => runtimeWork?.chats ?? [], [runtimeWork]) + const chatTaskItems = useMemo( + () => getRuntimeChatSidebarTaskItems(chatWorkspaces), + [chatWorkspaces] ) if (!open) return null @@ -349,7 +346,7 @@ export function MobileDrawer({ {t('workbench.chats', '对话')}
- {unmappedChatTaskItems.length === 0 ? ( + {chatTaskItems.length === 0 ? (
) : ( - unmappedChatTaskItems.map(({ workspace, task }) => { + chatTaskItems.map(({ workspace, task }) => { const selectedTask = isRuntimeTaskSelected( currentRuntimeTask, workspace, diff --git a/wework/src/components/layout/MobileWorkbenchLayout.test.tsx b/wework/src/components/layout/MobileWorkbenchLayout.test.tsx index eee4b66a96..9f4adab340 100644 --- a/wework/src/components/layout/MobileWorkbenchLayout.test.tsx +++ b/wework/src/components/layout/MobileWorkbenchLayout.test.tsx @@ -81,7 +81,7 @@ function runtimeWork( }, ], })), - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, } } @@ -889,7 +889,7 @@ describe('MobileWorkbenchLayout', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 1, }, }} @@ -947,7 +947,7 @@ describe('MobileWorkbenchLayout', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 1, }, }} @@ -977,7 +977,7 @@ describe('MobileWorkbenchLayout', () => { ...baseState, runtimeWork: { projects: [], - unmappedDeviceWorkspaces: [ + chats: [ { deviceId: 'local-device', deviceName: 'Local Mac', @@ -1086,7 +1086,7 @@ describe('MobileWorkbenchLayout', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 6, }, }} diff --git a/wework/src/components/layout/MobileWorkbenchLayout.tsx b/wework/src/components/layout/MobileWorkbenchLayout.tsx index 67e675574f..1ac0ad54fb 100644 --- a/wework/src/components/layout/MobileWorkbenchLayout.tsx +++ b/wework/src/components/layout/MobileWorkbenchLayout.tsx @@ -78,7 +78,11 @@ interface MobileWorkbenchLayoutProps { onLoadOlderRuntimeTranscript?: () => Promise onArchiveRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise onForkCurrentRuntimeTask?: (target: RuntimeTaskForkTarget) => Promise - onOpenStandaloneWorkspace?: (deviceId: string, workspacePath: string) => void + onOpenStandaloneWorkspace?: ( + deviceId: string, + workspacePath: string, + label?: string + ) => Promise | void onCreateProject?: (data: CreateProjectRequest) => Promise onCreateGitWorkspaceProject?: ( data: CreateGitWorkspaceProjectRequest diff --git a/wework/src/components/layout/TaskForkDialog.test.tsx b/wework/src/components/layout/TaskForkDialog.test.tsx index 72b6880094..fb9acc6fb9 100644 --- a/wework/src/components/layout/TaskForkDialog.test.tsx +++ b/wework/src/components/layout/TaskForkDialog.test.tsx @@ -43,7 +43,7 @@ function runtimeWork(workspaces: RuntimeDeviceWorkspace[]): RuntimeWorkListRespo ), }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, } } @@ -207,11 +207,11 @@ describe('TaskForkDialog', () => { totalLocalTasks: 0, }, ], - unmappedDeviceWorkspaces: [ + chats: [ workspace({ - deviceId: 'unmapped-device', - deviceName: 'Unmapped Device', - workspacePath: '/workspace/unmapped-history', + deviceId: 'chat-device', + deviceName: 'Chat Device', + workspacePath: '/workspace/chat-history', }), ], totalLocalTasks: 1, @@ -229,7 +229,7 @@ describe('TaskForkDialog', () => { ) expect(screen.getByTestId('task-fork-target-local-2')).toHaveTextContent('Worktree') expect(screen.queryByTestId('task-fork-target-other-device')).not.toBeInTheDocument() - expect(screen.queryByTestId('task-fork-target-unmapped-device')).not.toBeInTheDocument() + expect(screen.queryByTestId('task-fork-target-chat-device')).not.toBeInTheDocument() }) test('binds an unbound device path to the current project before forking', async () => { diff --git a/wework/src/components/projects/DeviceFolderPicker.tsx b/wework/src/components/projects/DeviceFolderPicker.tsx index 703d725709..2b56a23cd3 100644 --- a/wework/src/components/projects/DeviceFolderPicker.tsx +++ b/wework/src/components/projects/DeviceFolderPicker.tsx @@ -26,7 +26,7 @@ interface DeviceFolderPickerProps { onGetDeviceHomeDirectory: (deviceId: string) => Promise onListDeviceDirectories: (deviceId: string, path: string) => Promise onCreateDeviceDirectory: (deviceId: string, path: string) => Promise - onConfirm: (result: DeviceFolderPickerResult) => void + onConfirm: (result: DeviceFolderPickerResult) => Promise | void onCancel: () => void } @@ -171,11 +171,22 @@ export function DeviceFolderPicker({ setError(null) if (mode === 'select') { - onConfirm({ - deviceId: device.device_id, - path: selectedPath || currentPath, - action: mode, - }) + setSubmitting(true) + try { + await onConfirm({ + deviceId: device.device_id, + path: selectedPath || currentPath, + action: mode, + }) + } catch (confirmError) { + setError( + confirmError instanceof Error + ? confirmError.message + : t('workbench.project_directory_select_failed', '项目打开失败') + ) + } finally { + setSubmitting(false) + } return } @@ -190,7 +201,7 @@ export function DeviceFolderPicker({ setSubmitting(true) try { await onCreateDeviceDirectory(device.device_id, nextPath) - onConfirm({ + await onConfirm({ deviceId: device.device_id, path: nextPath, action: mode, diff --git a/wework/src/components/projects/ProjectFolderIcon.test.tsx b/wework/src/components/projects/ProjectFolderIcon.test.tsx index 7ad0e1eb3c..af95871d27 100644 --- a/wework/src/components/projects/ProjectFolderIcon.test.tsx +++ b/wework/src/components/projects/ProjectFolderIcon.test.tsx @@ -22,4 +22,16 @@ describe('ProjectFolderIcon', () => { expect(screen.getByTestId('project-git-folder-icon-7')).toBeInTheDocument() }) + + test('marks remote runtime projects with a remote folder icon', () => { + const project: ProjectWithTasks = { + id: 8, + name: 'Wegent', + tasks: [], + } + + render() + + expect(screen.getByTestId('project-remote-folder-icon-8')).toBeInTheDocument() + }) }) diff --git a/wework/src/components/projects/ProjectFolderIcon.tsx b/wework/src/components/projects/ProjectFolderIcon.tsx index 3cb2b74fb3..bcb435c02c 100644 --- a/wework/src/components/projects/ProjectFolderIcon.tsx +++ b/wework/src/components/projects/ProjectFolderIcon.tsx @@ -1,26 +1,39 @@ -import { Folder, FolderGit2 } from 'lucide-react' +import { Folder, FolderGit2, Globe2 } from 'lucide-react' import { isGitWorkspaceProject } from '@/lib/projectClassification' +import { cn } from '@/lib/utils' import type { ProjectWithTasks } from '@/types/api' interface ProjectFolderIconProps { project: ProjectWithTasks className?: string testId?: string + remote?: boolean } -export function ProjectFolderIcon({ project, className, testId }: ProjectFolderIconProps) { +export function ProjectFolderIcon({ + project, + className, + testId, + remote = false, +}: ProjectFolderIconProps) { const gitProject = isGitWorkspaceProject(project) const Icon = gitProject ? FolderGit2 : Folder + const resolvedTestId = + testId ?? + (remote + ? `project-remote-folder-icon-${project.id}` + : gitProject + ? `project-git-folder-icon-${project.id}` + : `project-folder-icon-${project.id}`) - return ( - - ) + if (remote) { + return ( + + + + + ) + } + + return } diff --git a/wework/src/components/projects/StandaloneProjectDialogs.tsx b/wework/src/components/projects/StandaloneProjectDialogs.tsx index 021a11fa92..04665703a0 100644 --- a/wework/src/components/projects/StandaloneProjectDialogs.tsx +++ b/wework/src/components/projects/StandaloneProjectDialogs.tsx @@ -115,7 +115,11 @@ export function StandaloneBlankProjectDialog({ onGetDeviceHomeDirectory: (deviceId: string) => Promise onListDeviceDirectories: (deviceId: string, path: string) => Promise onCreateDeviceDirectory: (deviceId: string, path: string) => Promise - onOpenStandaloneWorkspace?: (deviceId: string, workspacePath: string) => void + onOpenStandaloneWorkspace?: ( + deviceId: string, + workspacePath: string, + label?: string + ) => Promise | void }) { const { t } = useTranslation('common') const [projectName, setProjectName] = useState('New project') @@ -154,7 +158,7 @@ export function StandaloneBlankProjectDialog({ ) const workspacePath = joinPath(documentsPath, projectDirectoryName) await onCreateDeviceDirectory(targetDevice.device_id, workspacePath) - onOpenStandaloneWorkspace?.(targetDevice.device_id, workspacePath) + await onOpenStandaloneWorkspace?.(targetDevice.device_id, workspacePath, trimmedName) onClose() } catch (submitError) { setError( @@ -260,7 +264,11 @@ export function StandaloneFolderProjectDialog({ onGetDeviceHomeDirectory: (deviceId: string) => Promise onListDeviceDirectories: (deviceId: string, path: string) => Promise onCreateDeviceDirectory: (deviceId: string, path: string) => Promise - onOpenStandaloneWorkspace?: (deviceId: string, workspacePath: string) => void + onOpenStandaloneWorkspace?: ( + deviceId: string, + workspacePath: string, + label?: string + ) => Promise | void }) { const { t } = useTranslation('common') const selectableDevices = useMemo( @@ -356,8 +364,8 @@ export function StandaloneFolderProjectDialog({ onListDeviceDirectories={onListDeviceDirectories} onCreateDeviceDirectory={onCreateDeviceDirectory} onCancel={onClose} - onConfirm={result => { - onOpenStandaloneWorkspace?.(result.deviceId, result.path) + onConfirm={async result => { + await onOpenStandaloneWorkspace?.(result.deviceId, result.path) onClose() }} /> diff --git a/wework/src/components/settings/ArchivedConversationsSettingsPage.tsx b/wework/src/components/settings/ArchivedConversationsSettingsPage.tsx new file mode 100644 index 0000000000..e74ea2211c --- /dev/null +++ b/wework/src/components/settings/ArchivedConversationsSettingsPage.tsx @@ -0,0 +1,432 @@ +import { Loader2, RotateCw, Search, Trash2, Undo2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { createHttpClient } from '@/api/http' +import { createRuntimeWorkApi } from '@/api/runtimeWork' +import { getRuntimeConfig } from '@/config/runtime' +import { useTranslation } from '@/hooks/useTranslation' +import type { ArchivedConversationItem, ArchivedConversationsListRequest } from '@/types/api' + +type SourceFilter = NonNullable +type SortFilter = NonNullable + +function createSettingsRuntimeWorkApi() { + const { apiBaseUrl } = getRuntimeConfig() + return createRuntimeWorkApi(createHttpClient({ baseUrl: apiBaseUrl })) +} + +function itemTestId(item: ArchivedConversationItem) { + return `${item.deviceId}-${item.localTaskId}`.replace(/[^a-zA-Z0-9_-]/g, '-') +} + +function projectOptionKey(group: { + projectKey?: string | null + projectId?: number | null + name: string +}) { + return group.projectKey || (group.projectId ? `project:${group.projectId}` : group.name) +} + +function formatArchivedTime(value?: string | null) { + if (!value) return '' + try { + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)) + } catch { + return value + } +} + +function groupItems(items: ArchivedConversationItem[]) { + const groups = new Map() + items.forEach(item => { + const key = item.projectKey || item.projectName || item.workspacePath + const existing = groups.get(key) + if (existing) { + existing.items.push(item) + return + } + groups.set(key, { + name: item.projectName || item.workspacePath, + items: [item], + }) + }) + return [...groups.entries()].map(([key, group]) => ({ key, ...group })) +} + +function matchesSearch(item: ArchivedConversationItem, query: string) { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) return true + return [ + item.title, + item.projectName, + item.workspacePath, + item.localTaskId, + item.deviceAddress, + item.deviceId, + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(normalizedQuery) +} + +function archivedTimestamp(value?: string | null) { + if (!value) return 0 + const timestamp = new Date(value).getTime() + return Number.isNaN(timestamp) ? 0 : timestamp +} + +function sortItems(items: ArchivedConversationItem[], sort: SortFilter) { + const sorted = [...items] + if (sort === 'alphabetical') { + return sorted.sort((left, right) => left.title.localeCompare(right.title)) + } + if (sort === 'created') { + return sorted.sort( + (left, right) => archivedTimestamp(right.createdAt) - archivedTimestamp(left.createdAt) + ) + } + return sorted.sort( + (left, right) => + archivedTimestamp(right.updatedAt || right.createdAt) - + archivedTimestamp(left.updatedAt || left.createdAt) + ) +} + +export function ArchivedConversationsSettingsPage() { + const { t } = useTranslation('common') + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [search, setSearch] = useState('') + const [source, setSource] = useState('all') + const [sort, setSort] = useState('updated') + const [projectKey, setProjectKey] = useState('all') + const [busyKey, setBusyKey] = useState(null) + + const api = useMemo(() => createSettingsRuntimeWorkApi(), []) + + const loadArchivedConversations = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await api.listArchivedConversations() + setItems(response.items) + } catch { + setError(t('workbench.archived_conversations_load_failed', '加载已归档对话失败')) + } finally { + setLoading(false) + } + }, [api, t]) + + useEffect(() => { + const loadTimer = window.setTimeout(() => { + void loadArchivedConversations() + }, 0) + return () => window.clearTimeout(loadTimer) + }, [loadArchivedConversations]) + + const filteredItemsBeforeProject = useMemo(() => { + return items.filter(item => { + if (source !== 'all' && item.source !== source) return false + return matchesSearch(item, search) + }) + }, [items, search, source]) + + const projectGroups = useMemo(() => { + const groups = new Map< + string, + { name: string; projectKey?: string | null; projectId?: number | null; count: number } + >() + filteredItemsBeforeProject.forEach(item => { + const key = item.projectKey || item.projectName || item.workspacePath + const existing = groups.get(key) + if (existing) { + existing.count += 1 + return + } + groups.set(key, { + name: item.projectName || item.workspacePath, + projectKey: item.projectKey, + projectId: item.projectId, + count: 1, + }) + }) + return [...groups.values()].sort((left, right) => left.name.localeCompare(right.name)) + }, [filteredItemsBeforeProject]) + + const visibleItems = useMemo(() => { + const projectItems = + projectKey === 'all' + ? filteredItemsBeforeProject + : filteredItemsBeforeProject.filter( + item => (item.projectKey || item.projectName) === projectKey + ) + return sortItems(projectItems, sort) + }, [filteredItemsBeforeProject, projectKey, sort]) + + const groupedItems = useMemo(() => groupItems(visibleItems), [visibleItems]) + const hasArchivedItems = items.length > 0 + + const handleUnarchive = async (item: ArchivedConversationItem) => { + const key = `unarchive:${item.id}` + setBusyKey(key) + try { + await api.unarchiveConversation({ + deviceId: item.deviceId, + workspacePath: item.workspacePath, + localTaskId: item.localTaskId, + }) + await loadArchivedConversations() + } finally { + setBusyKey(null) + } + } + + const handleDelete = async (item: ArchivedConversationItem) => { + if (!window.confirm(t('workbench.archived_delete_confirm', '确定删除该归档对话吗?'))) { + return + } + const key = `delete:${item.id}` + setBusyKey(key) + try { + await api.deleteArchivedConversation({ + deviceId: item.deviceId, + workspacePath: item.workspacePath, + localTaskId: item.localTaskId, + }) + await loadArchivedConversations() + } finally { + setBusyKey(null) + } + } + + const handleBulkDelete = async () => { + if (items.length === 0) return + if ( + !window.confirm(t('workbench.archived_bulk_delete_confirm', '确定删除全部已归档聊天吗?')) + ) { + return + } + setBusyKey('bulk-delete') + try { + await api.deleteArchivedConversationsBulk({ + items: items.map(item => ({ + deviceId: item.deviceId, + workspacePath: item.workspacePath, + localTaskId: item.localTaskId, + })), + }) + await loadArchivedConversations() + } finally { + setBusyKey(null) + } + } + + return ( +
+
+
+

+ {t('workbench.archived_conversations_title', '已归档对话')} +

+ {hasArchivedItems && ( +

+ {t('workbench.archived_conversations_desc', '管理此设备上的归档 Codex 对话。')} +

+ )} +
+ {hasArchivedItems && ( +
+ + +
+ )} +
+ + {hasArchivedItems && ( +
+ + + + +
+ )} + +
+ {loading ? ( +
+ + {t('workbench.loading', '加载中...')} +
+ ) : error ? ( +
+ {error} +
+ ) : groupedItems.length === 0 ? ( +
+ {t('workbench.archived_empty', '暂无已归档的聊天。')} +
+ ) : ( +
+ {groupedItems.map(group => ( +
+
+

{group.name}

+ {group.items.length} +
+
+ {group.items.map(item => { + const suffix = itemTestId(item) + const deleteLabel = t('workbench.archived_delete_tooltip', '删除已归档聊天') + const rowBusy = + busyKey === `delete:${item.id}` || busyKey === `unarchive:${item.id}` + return ( +
+
+
+ {item.title} +
+
+ {item.source !== 'local' && ( + {item.deviceAddress || item.deviceId} + )} + {item.workspacePath} + {formatArchivedTime(item.updatedAt || item.createdAt)} +
+
+
+
+ + + {deleteLabel} + +
+ +
+
+ ) + })} +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/wework/src/components/settings/ConnectionsSettingsPage.tsx b/wework/src/components/settings/ConnectionsSettingsPage.tsx index 522d38bcfa..cb4a5f6aac 100644 --- a/wework/src/components/settings/ConnectionsSettingsPage.tsx +++ b/wework/src/components/settings/ConnectionsSettingsPage.tsx @@ -1,4 +1,5 @@ import { + Archive, ArrowLeft, Palette, BookOpen, @@ -55,13 +56,14 @@ import { ProxySettingsPage } from './ProxySettingsPage' import { RuntimeConfigSettingsPage } from './RuntimeConfigSettingsPage' import { SkillSettingsPage } from './SkillSettingsPage' import { WorktreesSettingsPage } from './WorktreesSettingsPage' +import { ArchivedConversationsSettingsPage } from './ArchivedConversationsSettingsPage' interface ConnectionsSettingsPageProps { onBack: () => void autoOpenAddCloudDeviceDialog?: boolean } -type SettingsCategory = 'personal' | 'coding' +type SettingsCategory = 'personal' | 'coding' | 'archived' interface SettingsNavItem { key: string @@ -112,6 +114,13 @@ const settingsNavItems: SettingsNavItem[] = [ fallback: '工作树', category: 'coding', }, + { + key: 'archived-conversations', + icon: Archive, + label: 'settings_nav_archived_conversations', + fallback: '已归档对话', + category: 'archived', + }, ] const settingsCategoryLabels: Record = { @@ -123,6 +132,10 @@ const settingsCategoryLabels: Record ) : activeNav === 'worktrees' ? ( + ) : activeNav === 'archived-conversations' ? ( + ) : ( { expect(screen.getByTestId('mobile-settings-plugins-button')).toHaveTextContent('插件') expect(screen.getByTestId('mobile-settings-personal-button')).toHaveTextContent('个人') expect(screen.getByTestId('mobile-settings-worktrees-button')).toHaveTextContent('工作树') + expect(screen.getByTestId('mobile-settings-archived-conversations-button')).toHaveTextContent( + '已归档对话' + ) expect(screen.getByTestId('mobile-settings-skills-button')).toHaveTextContent('技能') await userEvent.click(screen.getByTestId('mobile-settings-personal-button')) diff --git a/wework/src/components/settings/MobileSettingsPage.tsx b/wework/src/components/settings/MobileSettingsPage.tsx index 35b87aba7a..ccae750cbe 100644 --- a/wework/src/components/settings/MobileSettingsPage.tsx +++ b/wework/src/components/settings/MobileSettingsPage.tsx @@ -1,4 +1,5 @@ import { + Archive, ArrowLeft, ChevronRight, GitBranch, @@ -13,6 +14,7 @@ import { AppearanceSettingsPage } from '@/features/appearance/AppearanceSettings import { RuntimeConfigSettingsPage } from './RuntimeConfigSettingsPage' import { SkillSettingsPage } from './SkillSettingsPage' import { WorktreesSettingsPage } from './WorktreesSettingsPage' +import { ArchivedConversationsSettingsPage } from './ArchivedConversationsSettingsPage' interface MobileSettingsPageProps { onBack: () => void @@ -22,7 +24,13 @@ interface MobileSettingsPageProps { export function MobileSettingsPage({ onBack, onOpenPlugins }: MobileSettingsPageProps) { const { t } = useTranslation('common') const [activePage, setActivePage] = useState< - 'menu' | 'appearance' | 'personal' | 'codex-auth' | 'skills' | 'worktrees' + | 'menu' + | 'appearance' + | 'personal' + | 'codex-auth' + | 'skills' + | 'worktrees' + | 'archived-conversations' >('menu') if (activePage === 'appearance') { @@ -77,6 +85,34 @@ export function MobileSettingsPage({ onBack, onOpenPlugins }: MobileSettingsPage ) } + if (activePage === 'archived-conversations') { + return ( +
+
+ +

+ {t('workbench.settings_nav_archived_conversations', '已归档对话')} +

+
+
+
+ +
+
+ ) + } + if (activePage === 'skills') { return (
+ + @@ -253,6 +278,20 @@ function ProjectSendProbe() { ) } +function RuntimeProjectMutationProbe() { + const workbench = useWorkbench() + return ( +
+ + +
+ ) +} + function RuntimeOpenProbe() { const workbench = useWorkbench() return ( @@ -489,6 +528,58 @@ describe('WorkbenchProvider runtime tasks', () => { ) }) + test('registers a standalone Codex workspace with an optional label', async () => { + const runtimeWorkApi = createRuntimeWorkApiMock({ + listRuntimeWork: vi.fn().mockResolvedValue(createRuntimeWork({ projects: [] })), + }) + const services = createWorkbenchServices({ + runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], + }) + + renderWorkbench(, services) + + await waitFor(() => + expect(screen.getByText('open labeled standalone workspace')).toBeInTheDocument() + ) + await userEvent.click(screen.getByText('open labeled standalone workspace')) + await waitFor(() => expect(runtimeWorkApi.openRuntimeWorkspace).toHaveBeenCalledTimes(1)) + expect(runtimeWorkApi.openRuntimeWorkspace).toHaveBeenCalledWith({ + deviceId: 'device-1', + workspacePath: '/workspace/direct-codex', + runtime: 'codex', + label: 'Direct Codex', + }) + }) + + test('renames and removes runtime projects through runtime-work metadata APIs', async () => { + const runtimeWorkApi = createRuntimeWorkApiMock() + const services = createWorkbenchServices({ + runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], + }) + + renderWorkbench(, services) + + await waitFor(() => expect(screen.getByText('rename runtime project')).toBeInTheDocument()) + await userEvent.click(screen.getByText('rename runtime project')) + await waitFor(() => expect(runtimeWorkApi.renameRuntimeWorkspace).toHaveBeenCalledTimes(1)) + expect(runtimeWorkApi.renameRuntimeWorkspace).toHaveBeenCalledWith({ + deviceId: 'device-1', + workspacePath: '/workspace/project-alpha', + runtime: 'codex', + name: 'Hello project', + }) + expect(services.projectApi.updateProject).not.toHaveBeenCalled() + + await userEvent.click(screen.getByText('remove runtime project')) + await waitFor(() => expect(runtimeWorkApi.removeRuntimeWorkspace).toHaveBeenCalledTimes(1)) + expect(runtimeWorkApi.removeRuntimeWorkspace).toHaveBeenCalledWith({ + deviceId: 'device-1', + workspacePath: '/workspace/project-alpha', + runtime: 'codex', + }) + expect(services.projectApi.deleteProject).not.toHaveBeenCalled() + }) + test('renders streaming local task chunks when the socket connects after chat start', async () => { let streamHandlers: ChatStreamHandlers = {} const subscribe = vi.fn((handlers: ChatStreamHandlers) => { @@ -731,6 +822,33 @@ describe('WorkbenchProvider runtime tasks', () => { expect(screen.getByTestId('runtime-open-messages')).not.toHaveTextContent('message a') }) + test('does not reload the currently selected runtime task when clicked again', async () => { + const getRuntimeTranscript = vi.fn().mockResolvedValue({ + localTaskId: 'runtime-a', + workspacePath: '/workspace/project-alpha', + runtime: 'claude_code', + messages: [{ id: 'runtime-a:user:1', role: 'user', content: 'message a' }], + } satisfies RuntimeTranscriptResponse) + const runtimeWorkApi = createRuntimeWorkApiMock({ getRuntimeTranscript }) + const services = createWorkbenchServices({ + runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], + }) + + renderWorkbench(, services) + + await userEvent.click(await screen.findByText('open runtime a')) + await waitFor(() => + expect(screen.getByTestId('runtime-open-messages')).toHaveTextContent('message a') + ) + expect(getRuntimeTranscript).toHaveBeenCalledTimes(1) + + await userEvent.click(screen.getByText('open runtime a')) + + expect(getRuntimeTranscript).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('runtime-open-messages')).toHaveTextContent('message a') + expect(screen.getByTestId('runtime-transcript-loading')).toHaveTextContent('idle') + }) + test('reuses the current runtime task address for follow-up messages', async () => { const sendRuntimeMessage = vi.fn().mockResolvedValue({ accepted: true, diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index 4e5c916e9f..652d74a0c0 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -132,7 +132,7 @@ const DEVICE_LIST_CACHE_KEY = 'wework.workbench.lastNonEmptyDevices' const DEVICE_LIST_CACHE_TTL_MS = 5 * 60 * 1000 const EMPTY_RUNTIME_WORK: RuntimeWorkListResponse = { projects: [], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, } @@ -296,13 +296,21 @@ export interface WorkbenchContextValue { selectProject: (projectId: number | null) => void selectProjectWorkspace: (projectId: number, deviceWorkspaceId: number | null) => void selectStandaloneDevice: (deviceId: string | null) => void - openStandaloneWorkspace: (deviceId: string, workspacePath: string) => void + openStandaloneWorkspace: ( + deviceId: string, + workspacePath: string, + label?: string + ) => Promise startNewChat: () => void startStandaloneChat: () => void startNewProjectChat: (projectId: number) => void openRuntimeLocalTask: (address: RuntimeTaskAddress) => Promise loadOlderRuntimeTranscript: () => Promise + renameRuntimeLocalTask: (address: RuntimeTaskAddress, title: string) => Promise archiveRuntimeLocalTask: (address: RuntimeTaskAddress) => Promise + archiveProjectConversations: (runtimeProjectKey: string) => Promise + archiveProjectsConversations: (runtimeProjectKeys: string[]) => Promise + archiveChatConversations: (addresses: RuntimeTaskAddress[]) => Promise forkCurrentRuntimeTask: (target: RuntimeTaskForkTarget) => Promise listImPrivateSessions: () => Promise bindRuntimeTaskToImSessions: ( @@ -434,6 +442,34 @@ function isSameRuntimeTaskIdentity( ) } +function isSameRuntimeTaskAddress( + left: RuntimeTaskAddress | null | undefined, + right: RuntimeTaskAddress +): boolean { + return Boolean(left && left.deviceId === right.deviceId && left.localTaskId === right.localTaskId) +} + +function workspaceTaskAddresses(workspaces: RuntimeDeviceWorkspace[]): RuntimeTaskAddress[] { + return workspaces.flatMap(workspace => + workspace.localTasks.map(task => ({ + deviceId: workspace.deviceId, + localTaskId: task.localTaskId, + })) + ) +} + +function projectTaskAddresses( + runtimeWork: RuntimeWorkListResponse | null, + runtimeProjectKeys: string[] +): RuntimeTaskAddress[] { + if (!runtimeWork || runtimeProjectKeys.length === 0) return [] + + const keySet = new Set(runtimeProjectKeys) + return runtimeWork.projects.flatMap(projectWork => + keySet.has(projectWork.project.key) ? workspaceTaskAddresses(projectWork.deviceWorkspaces) : [] + ) +} + function resolveRuntimeTaskRouteAddress( runtimeWork: RuntimeWorkListResponse | null, route: RuntimeTaskRoute @@ -442,7 +478,7 @@ function resolveRuntimeTaskRouteAddress( const workspaces = [ ...runtimeWork.projects.flatMap(projectWork => projectWork.deviceWorkspaces), - ...runtimeWork.unmappedDeviceWorkspaces, + ...runtimeWork.chats, ] for (const workspace of workspaces) { @@ -728,7 +764,7 @@ function findRuntimeWorkspaceForDevice( ): string | null { if (!deviceId) return null const workspaces = [ - ...(runtimeWork?.unmappedDeviceWorkspaces ?? []), + ...(runtimeWork?.chats ?? []), ...(runtimeWork?.projects ?? []).flatMap(project => project.deviceWorkspaces), ] const matches = workspaces.filter( @@ -1443,10 +1479,25 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide ) const openStandaloneWorkspace = useCallback( - (deviceId: string, workspacePath: string) => { + async (deviceId: string, workspacePath: string, label?: string) => { const normalizedDeviceId = deviceId.trim() const normalizedWorkspacePath = workspacePath.trim() if (!normalizedDeviceId || !normalizedWorkspacePath) return + const normalizedLabel = label?.trim() + if (!resolvedServices.runtimeWorkApi) { + throw new Error('Local runtime work is unavailable') + } + + const response = await resolvedServices.runtimeWorkApi.openRuntimeWorkspace({ + deviceId: normalizedDeviceId, + workspacePath: normalizedWorkspacePath, + runtime: 'codex', + ...(normalizedLabel ? { label: normalizedLabel } : {}), + }) + if (!response.accepted) { + throw new Error(response.error || 'Failed to register runtime workspace') + } + await refreshWorkLists() rememberExecutionDevice(normalizedDeviceId) dispatch({ @@ -1461,14 +1512,6 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide cancelRuntimeTranscriptLoad() handledRuntimeTaskRouteRef.current = null navigateTo('/') - void resolvedServices.runtimeWorkApi - ?.openRuntimeWorkspace({ - deviceId: normalizedDeviceId, - workspacePath: normalizedWorkspacePath, - runtime: 'codex', - }) - .then(() => refreshWorkLists()) - .catch(() => undefined) }, [ cancelRuntimeTranscriptLoad, @@ -1527,6 +1570,10 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide const openRuntimeLocalTask = useCallback( async (address: RuntimeTaskAddress) => { + if (isSameRuntimeTaskIdentity(currentRuntimeTaskRef.current, address)) { + return + } + if (!resolvedServices.runtimeWorkApi) { dispatch({ type: 'error_set', error: 'Local runtime work is unavailable' }) return @@ -1601,6 +1648,29 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide [resolvedServices.runtimeWorkApi, state.projects, state.runtimeWork, user.id] ) + const clearCurrentRuntimeTaskView = useCallback(() => { + dispatch({ type: 'current_task_cleared' }) + currentRuntimeTaskRef.current = null + dispatchMessages({ type: 'reset', messages: [] }) + setQueuedSends([]) + setGuidanceMessages([]) + setCodeCommentContexts([]) + handledRuntimeTaskRouteRef.current = null + navigateTo('/') + }, []) + + const clearCurrentRuntimeTaskIfArchived = useCallback( + (addresses: RuntimeTaskAddress[]) => { + if ( + !addresses.some(address => isSameRuntimeTaskAddress(currentRuntimeTaskRef.current, address)) + ) { + return + } + clearCurrentRuntimeTaskView() + }, + [clearCurrentRuntimeTaskView] + ) + const archiveRuntimeLocalTask = useCallback( async (address: RuntimeTaskAddress) => { if (!resolvedServices.runtimeWorkApi) { @@ -1608,7 +1678,7 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide return } - const response = await resolvedServices.runtimeWorkApi.archiveRuntimeTask(address) + const response = await resolvedServices.runtimeWorkApi.archiveConversation(address) if (!response.accepted) { dispatch({ type: 'error_set', error: response.error || 'Failed to archive runtime task' }) return @@ -1618,19 +1688,127 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide state.currentRuntimeTask?.deviceId === address.deviceId && state.currentRuntimeTask.localTaskId === address.localTaskId ) { - dispatch({ type: 'current_task_cleared' }) - currentRuntimeTaskRef.current = null - dispatchMessages({ type: 'reset', messages: [] }) - setQueuedSends([]) - setGuidanceMessages([]) - setCodeCommentContexts([]) - handledRuntimeTaskRouteRef.current = null - navigateTo('/') + clearCurrentRuntimeTaskView() + } + + await refreshWorkLists() + }, + [ + clearCurrentRuntimeTaskView, + refreshWorkLists, + resolvedServices.runtimeWorkApi, + state.currentRuntimeTask, + ] + ) + + const renameRuntimeLocalTask = useCallback( + async (address: RuntimeTaskAddress, title: string) => { + if (!resolvedServices.runtimeWorkApi) { + dispatch({ type: 'error_set', error: 'Local runtime work is unavailable' }) + return + } + + const response = await resolvedServices.runtimeWorkApi.renameRuntimeTask({ + address, + title, + }) + if (!response.accepted) { + dispatch({ type: 'error_set', error: response.error || 'Failed to rename runtime task' }) + return + } + + await refreshWorkLists() + }, + [refreshWorkLists, resolvedServices.runtimeWorkApi] + ) + + const archiveProjectConversations = useCallback( + async (runtimeProjectKey: string) => { + if (!resolvedServices.runtimeWorkApi) { + dispatch({ type: 'error_set', error: 'Local runtime work is unavailable' }) + return + } + const response = await resolvedServices.runtimeWorkApi.archiveProjectConversations({ + runtimeProjectKey, + }) + if (!response.accepted) { + dispatch({ type: 'error_set', error: response.error || 'Failed to archive project' }) + return + } + clearCurrentRuntimeTaskIfArchived( + projectTaskAddresses(state.runtimeWork, [runtimeProjectKey]) + ) + await refreshWorkLists() + }, + [ + clearCurrentRuntimeTaskIfArchived, + refreshWorkLists, + resolvedServices.runtimeWorkApi, + state.runtimeWork, + ] + ) + + const archiveProjectsConversations = useCallback( + async (runtimeProjectKeys: string[]) => { + if (!resolvedServices.runtimeWorkApi) { + dispatch({ type: 'error_set', error: 'Local runtime work is unavailable' }) + return + } + + const uniqueProjectKeys = [...new Set(runtimeProjectKeys.filter(Boolean))] + if (uniqueProjectKeys.length === 0) return + + const archivedAddresses = projectTaskAddresses(state.runtimeWork, uniqueProjectKeys) + const responses = await Promise.all( + uniqueProjectKeys.map(runtimeProjectKey => + resolvedServices.runtimeWorkApi.archiveProjectConversations({ runtimeProjectKey }) + ) + ) + const failedResponse = responses.find(response => !response.accepted) + if (failedResponse) { + dispatch({ + type: 'error_set', + error: failedResponse.error || 'Failed to archive project conversations', + }) + return + } + + clearCurrentRuntimeTaskIfArchived(archivedAddresses) + await refreshWorkLists() + }, + [ + clearCurrentRuntimeTaskIfArchived, + refreshWorkLists, + resolvedServices.runtimeWorkApi, + state.runtimeWork, + ] + ) + + const archiveChatConversations = useCallback( + async (addresses: RuntimeTaskAddress[]) => { + if (!resolvedServices.runtimeWorkApi) { + dispatch({ type: 'error_set', error: 'Local runtime work is unavailable' }) + return } + if (addresses.length === 0) return + + const responses = await Promise.all( + addresses.map(address => resolvedServices.runtimeWorkApi.archiveConversation(address)) + ) + const failedResponse = responses.find(response => !response.accepted) + if (failedResponse) { + dispatch({ + type: 'error_set', + error: failedResponse.error || 'Failed to archive chat conversations', + }) + return + } + + clearCurrentRuntimeTaskIfArchived(addresses) await refreshWorkLists() }, - [refreshWorkLists, resolvedServices.runtimeWorkApi, state.currentRuntimeTask] + [clearCurrentRuntimeTaskIfArchived, refreshWorkLists, resolvedServices.runtimeWorkApi] ) const forkCurrentRuntimeTask = useCallback( @@ -1909,18 +2087,49 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide const updateProjectName = useCallback( async (projectId: number, name: string) => { + const runtimeWorkspace = findProjectDeviceWorkspace(state.runtimeWork, projectId, null) + if (runtimeWorkspace && resolvedServices.runtimeWorkApi) { + const response = await resolvedServices.runtimeWorkApi.renameRuntimeWorkspace({ + deviceId: runtimeWorkspace.deviceId, + workspacePath: runtimeWorkspace.workspacePath, + runtime: 'codex', + name, + }) + if (!response.accepted) { + const message = response.error || 'Failed to rename runtime workspace' + dispatch({ type: 'error_set', error: message }) + throw new Error(message) + } + await refreshWorkLists() + return + } await resolvedServices.projectApi.updateProject(projectId, { name }) await refreshWorkLists() }, - [refreshWorkLists, resolvedServices] + [refreshWorkLists, resolvedServices, state.runtimeWork] ) const removeProject = useCallback( async (projectId: number) => { + const runtimeWorkspace = findProjectDeviceWorkspace(state.runtimeWork, projectId, null) + if (runtimeWorkspace && resolvedServices.runtimeWorkApi) { + const response = await resolvedServices.runtimeWorkApi.removeRuntimeWorkspace({ + deviceId: runtimeWorkspace.deviceId, + workspacePath: runtimeWorkspace.workspacePath, + runtime: 'codex', + }) + if (!response.accepted) { + const message = response.error || 'Failed to remove runtime workspace' + dispatch({ type: 'error_set', error: message }) + throw new Error(message) + } + await refreshWorkLists() + return + } await resolvedServices.projectApi.deleteProject(projectId) await refreshWorkLists() }, - [refreshWorkLists, resolvedServices] + [refreshWorkLists, resolvedServices, state.runtimeWork] ) const getDeviceHomeDirectory = useCallback( @@ -2560,7 +2769,11 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide startNewProjectChat, openRuntimeLocalTask, loadOlderRuntimeTranscript, + renameRuntimeLocalTask, archiveRuntimeLocalTask, + archiveProjectConversations, + archiveProjectsConversations, + archiveChatConversations, forkCurrentRuntimeTask, listImPrivateSessions, bindRuntimeTaskToImSessions, diff --git a/wework/src/features/workbench/workbenchReducer.test.ts b/wework/src/features/workbench/workbenchReducer.test.ts index 56e0f4f281..9d03a14440 100644 --- a/wework/src/features/workbench/workbenchReducer.test.ts +++ b/wework/src/features/workbench/workbenchReducer.test.ts @@ -39,7 +39,7 @@ describe('workbenchReducer', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 1, } @@ -115,7 +115,7 @@ describe('workbenchReducer', () => { ], runtimeWork: { projects: [{ project: { id: 7, name: 'Repo' }, deviceWorkspaces: [] }], - unmappedDeviceWorkspaces: [ + chats: [ { deviceId: 'device-1', workspacePath: '/workspace/repo', @@ -156,7 +156,7 @@ describe('workbenchReducer', () => { mapped: true, }), ]) - expect(updated.runtimeWork?.unmappedDeviceWorkspaces).toEqual([]) + expect(updated.runtimeWork?.chats).toEqual([]) }) test('keeps existing devices when a device refresh returns a transient empty list', () => { diff --git a/wework/src/features/workbench/workbenchReducer.ts b/wework/src/features/workbench/workbenchReducer.ts index 575319ec67..16210519d1 100644 --- a/wework/src/features/workbench/workbenchReducer.ts +++ b/wework/src/features/workbench/workbenchReducer.ts @@ -132,7 +132,7 @@ function upsertPreparedRuntimeWorkspace( } const currentRuntimeWork = runtimeWork ?? { projects: [], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, } const nextWorkspace = runtimeWorkspaceFromMapping(mapping, devices) @@ -169,7 +169,7 @@ function upsertPreparedRuntimeWorkspace( return { ...currentRuntimeWork, projects: projectsWithTarget, - unmappedDeviceWorkspaces: currentRuntimeWork.unmappedDeviceWorkspaces.filter( + chats: currentRuntimeWork.chats.filter( workspace => !( workspace.deviceId === mapping.deviceId && diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index 07cdcc946b..8afd16b697 100644 --- a/wework/src/i18n/locales/en/common.json +++ b/wework/src/i18n/locales/en/common.json @@ -59,11 +59,16 @@ "archive_chats": "Archive chats", "archive_chat": "Archive chat", "archive_runtime_task": "Archive task", + "archive_runtime_task_pending": ", archiving soon", + "archive_runtime_task_undo": "Undo", + "archive_runtime_task_notice_close": "Dismiss archive notice", "runtime_task_running": "Running", "mark_runtime_task": "Mark task", "unmark_runtime_task": "Unmark task", "rename_project": "Rename project", "rename_chat": "Rename chat", + "rename_chat_description": "Keep it short and easy to recognize", + "chat_name": "Chat name", "remove_project": "Remove", "no_chats": "No chats", "no_device_workspaces": "No workspaces", @@ -72,6 +77,7 @@ "expand_display": "Show more", "collapse_display": "Collapse", "save": "Save", + "save_failed": "Failed to save", "cancel": "Cancel", "comment": "Comment", "workspace_file_search": "Filter files...", @@ -188,6 +194,7 @@ "project_workspace_kind_workspace": "Regular path", "project_directory_loading": "Loading directories...", "project_directory_load_failed": "Failed to load directories", + "project_directory_select_failed": "Failed to open project", "project_directory_empty": "No subdirectories in the current directory", "project_show_hidden_directories": "Show hidden directories", "project_create_folder": "New folder", @@ -510,8 +517,10 @@ "settings_nav_plugins": "Plugins", "settings_nav_skills": "Skills", "settings_nav_worktrees": "Worktrees", + "settings_nav_archived_conversations": "Archived conversations", "settings_nav_archived_chats": "Archived chats", "settings_category_personal": "Personal", + "settings_category_archived": "Archived", "archived_chats_title": "Archived chats", "archived_chats_subtitle": "Review, restore, or delete archived conversations", "archived_chats_delete_all": "Delete all", @@ -533,6 +542,40 @@ "worktrees_delete_title": "Delete worktree", "worktrees_delete_desc": "This deletes the worktree directory and the task that uses it.", "worktrees_delete_confirm": "Delete", + "change_connection_color": "Change connection color", + "archive_project_conversations": "Archive conversations", + "archiving_conversations": "Archiving...", + "archive_project_confirm": "Archive all conversations in this project?", + "archive_project_dialog_title": "Archive {{count}} conversations?", + "archive_project_dialog_desc": "This will archive conversations in {{projectName}}. You can find them later in Archived conversations.", + "archive_project_dialog_confirm": "Archive all", + "archive_all_conversations": "Archive all conversations", + "archive_all_confirm": "Archive all conversations?", + "archive_all_dialog_title": "Archive {{count}} conversations?", + "archive_all_dialog_desc": "This will archive conversations in all projects. You can find them later in Archived conversations.", + "archive_projects_dialog_title": "Archive {{count}} conversations?", + "archive_projects_dialog_desc": "This will archive conversations in Projects. You can find them later in Archived conversations.", + "archive_chats_dialog_title": "Archive {{count}} conversations?", + "archive_chats_dialog_desc": "This will archive conversations in Chats. You can find them later in Archived conversations.", + "remove_project_confirm": "Remove this project?", + "archived_conversations_title": "Archived conversations", + "archived_conversations_desc": "Manage archived Codex conversations stored on your devices.", + "archived_conversations_load_failed": "Failed to load archived conversations", + "archived_search_placeholder": "Search archived conversations", + "archived_filter_all": "All", + "archived_filter_local": "Local", + "archived_filter_cloud": "Cloud", + "archived_sort_updated": "Updated", + "archived_sort_created": "Created", + "archived_sort_alpha": "Alphabetical", + "archived_project_all": "All projects", + "archived_empty": "No archived chats.", + "archived_unarchive": "Unarchive", + "archived_delete": "Delete", + "archived_delete_tooltip": "Delete archived chat", + "archived_bulk_delete": "Delete all", + "archived_delete_confirm": "Delete this archived conversation?", + "archived_bulk_delete_confirm": "Delete all archived chats?", "skill_management_title": "Skills", "skill_management_subtitle": "Manage local Claude and Codex skill directories together", "skill_management_refresh": "Refresh", diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json index 5904f8ae01..3cde8c20f0 100644 --- a/wework/src/i18n/locales/zh-CN/common.json +++ b/wework/src/i18n/locales/zh-CN/common.json @@ -55,15 +55,20 @@ "execution_mode_current_workspace_trigger": "本地模式", "execution_mode_current_workspace": "在本地处理", "execution_mode_git_worktree": "新工作树", - "archive_all_chats": "归档所有会话", + "archive_all_chats": "归档所有聊天", "archive_chats": "归档会话", "archive_chat": "归档会话", "archive_runtime_task": "归档任务", + "archive_runtime_task_pending": ",稍后将归档", + "archive_runtime_task_undo": "撤销", + "archive_runtime_task_notice_close": "关闭归档提示", "runtime_task_running": "运行中", "mark_runtime_task": "标记任务", "unmark_runtime_task": "取消标记", "rename_project": "重命名项目", "rename_chat": "重命名会话", + "rename_chat_description": "保持简短且易于识别", + "chat_name": "会话名称", "remove_project": "移除", "no_chats": "暂无会话", "no_device_workspaces": "暂无工作区", @@ -72,6 +77,7 @@ "expand_display": "展开显示", "collapse_display": "折叠显示", "save": "保存", + "save_failed": "保存失败", "cancel": "取消", "comment": "评论", "workspace_file_search": "筛选文件...", @@ -188,6 +194,7 @@ "project_workspace_kind_workspace": "普通地址", "project_directory_loading": "正在加载目录...", "project_directory_load_failed": "目录加载失败", + "project_directory_select_failed": "项目打开失败", "project_directory_empty": "当前目录下没有子目录", "project_show_hidden_directories": "显示隐藏目录", "project_create_folder": "新建文件夹", @@ -510,8 +517,10 @@ "settings_nav_plugins": "插件", "settings_nav_skills": "技能", "settings_nav_worktrees": "工作树", + "settings_nav_archived_conversations": "已归档对话", "settings_nav_archived_chats": "已归档会话", "settings_category_personal": "个人", + "settings_category_archived": "已归档", "archived_chats_title": "已归档会话", "archived_chats_subtitle": "查看、恢复或删除已归档的会话", "archived_chats_delete_all": "全部删除", @@ -533,6 +542,40 @@ "worktrees_delete_title": "删除工作树", "worktrees_delete_desc": "将删除这个工作树目录,并一并删除使用该工作树的任务。", "worktrees_delete_confirm": "确认删除", + "change_connection_color": "更改连接颜色", + "archive_project_conversations": "归档对话", + "archiving_conversations": "归档中...", + "archive_project_confirm": "确定归档该项目下的所有对话吗?", + "archive_project_dialog_title": "归档 {{count}} 个对话?", + "archive_project_dialog_desc": "这会将 {{projectName}} 中的对话归档。之后你可以在已归档对话中找到它们", + "archive_project_dialog_confirm": "全部归档", + "archive_all_conversations": "归档全部对话", + "archive_all_confirm": "确定归档所有对话吗?", + "archive_all_dialog_title": "归档 {{count}} 个对话?", + "archive_all_dialog_desc": "这会将所有项目中的对话归档。之后你可以在已归档对话中找到它们", + "archive_projects_dialog_title": "归档 {{count}} 个对话?", + "archive_projects_dialog_desc": "这会将项目中的对话归档。之后你可以在已归档对话中找到它们", + "archive_chats_dialog_title": "归档 {{count}} 个对话?", + "archive_chats_dialog_desc": "这会将对话列表中的对话归档。之后你可以在已归档对话中找到它们", + "remove_project_confirm": "确定移除该项目吗?", + "archived_conversations_title": "已归档对话", + "archived_conversations_desc": "管理此设备上的归档 Codex 对话。", + "archived_conversations_load_failed": "加载已归档对话失败", + "archived_search_placeholder": "搜索归档对话", + "archived_filter_all": "全部", + "archived_filter_local": "本地", + "archived_filter_cloud": "云端", + "archived_sort_updated": "按更新", + "archived_sort_created": "按创建", + "archived_sort_alpha": "按名称", + "archived_project_all": "全部项目", + "archived_empty": "暂无已归档的聊天。", + "archived_unarchive": "取消归档", + "archived_delete": "删除", + "archived_delete_tooltip": "删除已归档聊天", + "archived_bulk_delete": "删除全部", + "archived_delete_confirm": "确定删除该归档对话吗?", + "archived_bulk_delete_confirm": "确定删除全部已归档聊天吗?", "skill_management_title": "技能", "skill_management_subtitle": "统一管理本地 Claude 和 Codex 技能目录", "skill_management_refresh": "刷新", diff --git a/wework/src/lib/project-workspace-selection.test.ts b/wework/src/lib/project-workspace-selection.test.ts index a3a45b24c1..9e2bc3df04 100644 --- a/wework/src/lib/project-workspace-selection.test.ts +++ b/wework/src/lib/project-workspace-selection.test.ts @@ -79,7 +79,7 @@ const runtimeWork: RuntimeWorkListResponse = { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, } @@ -142,7 +142,7 @@ describe('project workspace selection', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 0, }, }) diff --git a/wework/src/lib/workspace-target.test.ts b/wework/src/lib/workspace-target.test.ts index b90dc31dd8..fd8a375010 100644 --- a/wework/src/lib/workspace-target.test.ts +++ b/wework/src/lib/workspace-target.test.ts @@ -83,7 +83,7 @@ describe('resolveWorkspaceTarget', () => { ], }, ], - unmappedDeviceWorkspaces: [], + chats: [], totalLocalTasks: 1, }, }) diff --git a/wework/src/lib/workspace-target.ts b/wework/src/lib/workspace-target.ts index 68b1a65dac..b153e480e2 100644 --- a/wework/src/lib/workspace-target.ts +++ b/wework/src/lib/workspace-target.ts @@ -101,7 +101,7 @@ export function resolveRuntimeWorkspaceContext({ } } - for (const workspace of runtimeWork?.unmappedDeviceWorkspaces ?? []) { + for (const workspace of runtimeWork?.chats ?? []) { const task = workspace.localTasks.find(item => runtimeTaskMatches(currentRuntimeTask, workspace, item) ) diff --git a/wework/src/pages/PluginManagementPage.tsx b/wework/src/pages/PluginManagementPage.tsx index 3a3eb3bd09..3204a7f795 100644 --- a/wework/src/pages/PluginManagementPage.tsx +++ b/wework/src/pages/PluginManagementPage.tsx @@ -24,7 +24,11 @@ export function PluginManagementPage() { startStandaloneChat, startNewProjectChat, openRuntimeLocalTask, + renameRuntimeLocalTask, archiveRuntimeLocalTask, + archiveProjectConversations, + archiveProjectsConversations, + archiveChatConversations, openStandaloneWorkspace, refreshDevices, refreshWorkLists, @@ -100,10 +104,13 @@ export function PluginManagementPage() { activeItem="plugins" onCollapse={() => setSidebarCollapsed(true)} onNewChat={handleNewChat} - onSelectProject={handleSelectProject} onStartNewProjectChat={handleStartNewProjectChat} onOpenRuntimeLocalTask={openRuntimeLocalTask} + onRenameRuntimeLocalTask={renameRuntimeLocalTask} onArchiveRuntimeLocalTask={archiveRuntimeLocalTask} + onArchiveProjectConversations={archiveProjectConversations} + onArchiveProjectsConversations={archiveProjectsConversations} + onArchiveChatConversations={archiveChatConversations} onOpenStandaloneWorkspace={openStandaloneWorkspace} onOpenPlugins={handleOpenPlugins} onRefreshDevices={refreshDevices} diff --git a/wework/src/pages/PluginsPage.tsx b/wework/src/pages/PluginsPage.tsx index ea3c99cfba..ec71efa5c9 100644 --- a/wework/src/pages/PluginsPage.tsx +++ b/wework/src/pages/PluginsPage.tsx @@ -24,7 +24,11 @@ export function PluginsPage() { startStandaloneChat, startNewProjectChat, openRuntimeLocalTask, + renameRuntimeLocalTask, archiveRuntimeLocalTask, + archiveProjectConversations, + archiveProjectsConversations, + archiveChatConversations, openStandaloneWorkspace, refreshDevices, refreshWorkLists, @@ -100,10 +104,13 @@ export function PluginsPage() { activeItem="plugins" onCollapse={() => setSidebarCollapsed(true)} onNewChat={handleNewChat} - onSelectProject={handleSelectProject} onStartNewProjectChat={handleStartNewProjectChat} onOpenRuntimeLocalTask={openRuntimeLocalTask} + onRenameRuntimeLocalTask={renameRuntimeLocalTask} onArchiveRuntimeLocalTask={archiveRuntimeLocalTask} + onArchiveProjectConversations={archiveProjectConversations} + onArchiveProjectsConversations={archiveProjectsConversations} + onArchiveChatConversations={archiveChatConversations} onOpenStandaloneWorkspace={openStandaloneWorkspace} onOpenPlugins={handleOpenPlugins} onRefreshDevices={refreshDevices} diff --git a/wework/src/pages/WorkbenchPage.tsx b/wework/src/pages/WorkbenchPage.tsx index f43d55265d..085e9dc898 100644 --- a/wework/src/pages/WorkbenchPage.tsx +++ b/wework/src/pages/WorkbenchPage.tsx @@ -32,7 +32,11 @@ export function WorkbenchPage() { startNewProjectChat, openRuntimeLocalTask, loadOlderRuntimeTranscript, + renameRuntimeLocalTask, archiveRuntimeLocalTask, + archiveProjectConversations, + archiveProjectsConversations, + archiveChatConversations, forkCurrentRuntimeTask, rememberExecutionDevice, refreshDevices, @@ -119,7 +123,11 @@ export function WorkbenchPage() { onStartNewProjectChat={startNewProjectChat} onOpenRuntimeLocalTask={openRuntimeLocalTask} onLoadOlderRuntimeTranscript={loadOlderRuntimeTranscript} + onRenameRuntimeLocalTask={renameRuntimeLocalTask} onArchiveRuntimeLocalTask={archiveRuntimeLocalTask} + onArchiveProjectConversations={archiveProjectConversations} + onArchiveProjectsConversations={archiveProjectsConversations} + onArchiveChatConversations={archiveChatConversations} onForkCurrentRuntimeTask={forkCurrentRuntimeTask} onRememberExecutionDevice={rememberExecutionDevice} onOpenStandaloneWorkspace={openStandaloneWorkspace} diff --git a/wework/src/types/api.ts b/wework/src/types/api.ts index 5ed4e1b006..bf0a6e1795 100644 --- a/wework/src/types/api.ts +++ b/wework/src/types/api.ts @@ -336,6 +336,8 @@ export interface RuntimeDeviceWorkspace { workspaceKind?: 'workspace' | 'worktree' | 'chat' | string | null worktreeId?: string | null label?: string | null + workspaceSource?: 'local' | 'remote' | string | null + remoteHostId?: string | null repoUrl?: string | null repoRootFingerprint?: string | null mapped?: boolean @@ -351,7 +353,7 @@ export interface RuntimeProjectWork { export interface RuntimeWorkListResponse { projects: RuntimeProjectWork[] - unmappedDeviceWorkspaces: RuntimeDeviceWorkspace[] + chats: RuntimeDeviceWorkspace[] totalLocalTasks: number } @@ -387,6 +389,20 @@ export interface RuntimeWorkspaceOpenRequest { deviceId: string workspacePath: string runtime: RuntimeName + label?: string | null +} + +export interface RuntimeWorkspaceRenameRequest { + deviceId: string + workspacePath: string + runtime: RuntimeName + name: string +} + +export interface RuntimeWorkspaceRemoveRequest { + deviceId: string + workspacePath: string + runtime: RuntimeName } export interface RuntimeWorkspaceOpenResponse { @@ -459,6 +475,70 @@ export interface RuntimeTaskArchiveResponse { error?: string | null } +export interface ArchivedConversationsListRequest { + deviceId?: string | null + workspacePath?: string | null + projectId?: number | null + runtimeProjectKey?: string | null + search?: string | null + source?: 'all' | 'local' | 'cloud' + sort?: 'updated' | 'created' | 'alphabetical' +} + +export interface ArchivedConversationItem { + id: string + localTaskId: string + title: string + projectId?: number | null + projectKey?: string | null + projectName?: string | null + workspacePath: string + workspaceKind?: 'workspace' | 'worktree' | 'chat' | string | null + deviceId: string + deviceName?: string | null + deviceAddress?: string | null + source: 'local' | 'cloud' + runtime?: RuntimeName | null + createdAt?: string | null + updatedAt?: string | null +} + +export interface ArchivedConversationProjectGroup { + projectId?: number | null + projectKey?: string | null + projectName: string + count: number +} + +export interface ArchivedConversationsListResponse { + items: ArchivedConversationItem[] + projectGroups: ArchivedConversationProjectGroup[] + total: number +} + +export interface RuntimeArchiveProjectConversationsRequest { + projectId?: number | null + runtimeProjectKey?: string | null +} + +export interface RuntimeArchivedConversationBulkRequest { + items: RuntimeTaskAddress[] +} + +export interface RuntimeArchivedConversationBulkResponse { + accepted: boolean + requestedCount: number + acceptedCount: number + deletedCount?: number | null + results: Record[] + error?: string | null +} + +export interface RuntimeTaskRenameRequest { + address: RuntimeTaskAddress + title: string +} + export interface RuntimeTaskCreateRequest { projectId?: number deviceWorkspaceId?: number From 7d823d890cb55faf1d628138977821dd0bf4bd93 Mon Sep 17 00:00:00 2001 From: sifang Date: Thu, 25 Jun 2026 13:23:42 +0800 Subject: [PATCH 2/2] fix(executor): align codex project ordering --- executor/runtime_work/codex_global_state.py | 15 ++---- .../runtime_work/test_local_task_store.py | 50 +++++++++++++++++-- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/executor/runtime_work/codex_global_state.py b/executor/runtime_work/codex_global_state.py index ee2a97cbeb..b082c007d5 100644 --- a/executor/runtime_work/codex_global_state.py +++ b/executor/runtime_work/codex_global_state.py @@ -84,27 +84,18 @@ def has_project_roots(self) -> bool: return bool(self.saved_workspace_roots) def projects(self) -> list[CodexGlobalProject]: - order_index = {key: index for index, key in enumerate(self.project_order)} projects: list[CodexGlobalProject] = [] - for root in self.saved_workspace_roots: + for index, root in enumerate(self.saved_workspace_roots): projects.append( CodexGlobalProject( key=root, name=self.workspace_root_labels.get(root) or _path_basename(root), workspace_path=root, source="local", - order_index=order_index.get(root), + order_index=index, ) ) - return sorted( - projects, - key=lambda project: ( - project.order_index is None, - project.order_index if project.order_index is not None else 0, - project.name.lower(), - project.key, - ), - ) + return projects def project_index(self) -> CodexGlobalProjectIndex: projects = self.projects() diff --git a/executor/tests/runtime_work/test_local_task_store.py b/executor/tests/runtime_work/test_local_task_store.py index ae8309246c..d7a88d3be4 100644 --- a/executor/tests/runtime_work/test_local_task_store.py +++ b/executor/tests/runtime_work/test_local_task_store.py @@ -60,6 +60,10 @@ def thread_rename(self, thread_id, title=None, name=None): return SimpleNamespace(id=thread_id, title=title or name) +def _codex_thread_source_name(value): + return getattr(value, "value", value) + + def _codex_discovery_for_threads(codex_home, *threads): from executor.runtime_work.codex_discovery import CodexSessionDiscovery @@ -663,6 +667,41 @@ async def test_runtime_work_handler_places_new_codex_project_first(tmp_path): ] +@pytest.mark.asyncio +async def test_runtime_work_handler_orders_codex_projects_by_saved_roots(tmp_path): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + codex_home = tmp_path / "codex-home" + _write_codex_global_state( + codex_home, + saved_roots=[ + "/Users/crystal/Documents/hello-g", + "/Users/crystal/Documents/hello-d", + "/Users/crystal/Documents/hello-f", + "/Users/crystal/Documents/hello-c", + ], + project_order=[ + "/Users/crystal/Documents/hello-f", + "/Users/crystal/Documents/hello-c", + ], + ) + discovery, _fake_codex = _codex_discovery_for_threads(codex_home) + + result = await RuntimeWorkRpcHandler( + store=LocalTaskStore(tmp_path / "index.json"), + codex_discovery=discovery, + ).handle_runtime_rpc({"method": "runtime.tasks.list", "payload": {}}) + + assert result["success"] is True + assert [workspace["workspacePath"] for workspace in result["workspaces"]] == [ + "/Users/crystal/Documents/hello-g", + "/Users/crystal/Documents/hello-d", + "/Users/crystal/Documents/hello-f", + "/Users/crystal/Documents/hello-c", + ] + + @pytest.mark.asyncio async def test_runtime_work_handler_lists_codex_tasks_from_sdk_only(tmp_path): from executor.runtime_work.local_task_store import LocalTaskRecord, LocalTaskStore @@ -750,8 +789,8 @@ async def test_runtime_work_handler_uses_codex_global_projects(tmp_path): assert result["success"] is True assert [workspace["workspacePath"] for workspace in result["workspaces"]] == [ - "/repo/Wegent", "/repo/empty", + "/repo/Wegent", ] workspaces = {item["workspacePath"]: item for item in result["workspaces"]} assert workspaces["/repo/Wegent"]["label"] == "Wegent" @@ -2351,7 +2390,10 @@ async def thread_start(self, **kwargs): result = await discovery.open_workspace("/repo/Wegent") assert result["threadId"] == "codex-thread-1" - assert getattr(fake_codex.thread_start_kwargs["thread_source"], "value", None) == "user" + assert ( + _codex_thread_source_name(fake_codex.thread_start_kwargs["thread_source"]) + == "user" + ) def test_codex_discovery_thread_start_kwargs_keeps_provider_for_managed_config(): @@ -2370,7 +2412,7 @@ def test_codex_discovery_thread_start_kwargs_keeps_provider_for_managed_config() sandbox=object(), ) - assert getattr(kwargs["thread_source"], "value", None) == "user" + assert _codex_thread_source_name(kwargs["thread_source"]) == "user" assert kwargs["model_provider"] == "wecode-openai" @@ -2390,7 +2432,7 @@ def test_codex_discovery_thread_start_kwargs_omits_provider_for_user_config(): sandbox=object(), ) - assert getattr(kwargs["thread_source"], "value", None) == "user" + assert _codex_thread_source_name(kwargs["thread_source"]) == "user" assert "model_provider" not in kwargs