From 08ef24d9536c74dbf28436faab2dc60e427e1c03 Mon Sep 17 00:00:00 2001 From: sifang Date: Tue, 23 Jun 2026 21:04:09 +0800 Subject: [PATCH] feat(wework): source runtime work from executor threads --- backend/app/api/endpoints/runtime_work.py | 15 +- backend/app/services/runtime_work_service.py | 471 +++----------- .../api/endpoints/test_runtime_work_api.py | 7 +- .../services/test_runtime_work_service.py | 416 ++---------- docs/en/developer-guide/runtime-local-work.md | 38 +- docs/zh/developer-guide/runtime-local-work.md | 38 +- executor/modes/local/websocket_client.py | 33 +- executor/runtime_work/codex_discovery.py | 21 + executor/runtime_work/local_task_store.py | 11 + executor/runtime_work/rpc_handler.py | 2 + executor/tests/test_local_websocket_client.py | 8 + frontend/src/apis/devices.ts | 2 + wework/src/api/devices.test.ts | 26 +- wework/src/api/runtimeWork.test.ts | 17 + wework/src/api/runtimeWork.ts | 9 +- .../chat/composer/ProjectWorkBar.tsx | 6 +- .../components/layout/DesktopSidebar.test.tsx | 120 +--- .../src/components/layout/DesktopSidebar.tsx | 601 ++++++------------ .../layout/DesktopWorkbenchLayout.test.tsx | 115 +++- wework/src/components/layout/MobileDrawer.tsx | 108 +--- .../layout/MobileWorkbenchLayout.test.tsx | 8 +- .../layout/runtimeTaskSidebarHelpers.ts | 9 - .../workbench/WorkbenchProvider.test.tsx | 43 +- .../features/workbench/WorkbenchProvider.tsx | 195 +++--- .../lib/project-workspace-selection.test.ts | 37 ++ wework/src/lib/project-workspace-selection.ts | 6 +- wework/src/types/api.ts | 2 + wework/src/types/devices.ts | 2 + 28 files changed, 821 insertions(+), 1545 deletions(-) diff --git a/backend/app/api/endpoints/runtime_work.py b/backend/app/api/endpoints/runtime_work.py index 5ef6e3a587..1240646abe 100644 --- a/backend/app/api/endpoints/runtime_work.py +++ b/backend/app/api/endpoints/runtime_work.py @@ -4,13 +4,10 @@ """Runtime-native local work endpoints for Wework.""" -from typing import Annotated - from fastapi import APIRouter, Body, Depends, Query from sqlalchemy.orm import Session from app.api.dependencies import get_db -from app.core.constants import CLIENT_ORIGIN_WEWORK, SUPPORTED_CLIENT_ORIGINS from app.core.security import get_current_user from app.models.user import User from app.schemas.runtime_work import ( @@ -44,27 +41,17 @@ router = APIRouter() -ClientOriginQuery = Annotated[ - str, - Query( - pattern=f"^({'|'.join(SUPPORTED_CLIENT_ORIGINS)})$", - description="Client surface to scope projects", - ), -] - @router.get("", response_model=RuntimeWorkListResponse, response_model_by_alias=True) async def list_runtime_work_endpoint( - client_origin: ClientOriginQuery = CLIENT_ORIGIN_WEWORK, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """List Projects -> Device Workspaces -> executor-local LocalTasks.""" + """List executor-local work grouped as projects and conversations.""" return await runtime_work_service.list_runtime_work( db=db, user_id=current_user.id, - client_origin=client_origin, ) diff --git a/backend/app/services/runtime_work_service.py b/backend/app/services/runtime_work_service.py index 9a74afa15f..f8b9fb4121 100644 --- a/backend/app/services/runtime_work_service.py +++ b/backend/app/services/runtime_work_service.py @@ -9,6 +9,7 @@ import posixpath import re from dataclasses import dataclass, replace +from datetime import datetime from hashlib import sha256 from types import SimpleNamespace from typing import Any, Optional @@ -238,12 +239,9 @@ async def list_runtime_work( *, db: Session, user_id: int, - client_origin: Optional[str] = CLIENT_ORIGIN_WEWORK, ) -> RuntimeWorkListResponse: - """Return runtime-native work grouped by central Project and Device Workspace.""" + """Return runtime-native work grouped by executor workspace.""" - projects = _list_projects(db, user_id, client_origin) - mappings = _list_workspace_rows(db, user_id, [project.id for project in projects]) devices = await device_service.get_all_devices(db, user_id) devices_by_id = {str(device.get("device_id")): device for device in devices} runtime_workspaces = await _list_online_runtime_workspaces( @@ -251,118 +249,46 @@ async def list_runtime_work( devices=devices, ) - workspace_items_by_project_id: dict[int, list[RuntimeDeviceWorkspace]] = {} + projects: list[RuntimeProjectWork] = [] + conversations: list[RuntimeDeviceWorkspace] = [] total_local_tasks = 0 - mapped_keys: set[tuple[str, str]] = set() - - for project in projects: - project_mappings = [row for row in mappings if row.project_id == project.id] - workspace_items: list[RuntimeDeviceWorkspace] = [] - for mapping in project_mappings: - key = (mapping.device_id, mapping.workspace_path) - mapped_keys.add(key) - local_tasks = runtime_workspaces.get(key, []) - total_local_tasks += len(local_tasks) - workspace_items.append( - _build_device_workspace_item( - mapping=mapping, - device=devices_by_id.get(mapping.device_id), - local_tasks=local_tasks, - ) - ) - configured_target = _project_runtime_target(project) - if configured_target: - materialized_mapping = _materialize_project_runtime_target( - db=db, - user_id=user_id, - project=project, - target=configured_target, - ) - key = (materialized_mapping.device_id, materialized_mapping.workspace_path) - if key not in mapped_keys: - mapped_keys.add(key) - local_tasks = runtime_workspaces.get(key, []) - total_local_tasks += len(local_tasks) - workspace_items.append( - _build_device_workspace_item( - mapping=materialized_mapping, - device=devices_by_id.get(materialized_mapping.device_id), - local_tasks=local_tasks, - ) - ) - workspace_items_by_project_id[project.id] = workspace_items - for (device_id, workspace_path), local_tasks in runtime_workspaces.items(): - if (device_id, workspace_path) in mapped_keys: - continue - target = _find_project_target_for_runtime_git_info( - projects=projects, - mappings=mappings, - device_id=device_id, - local_tasks=local_tasks, - ) - if target is not None and target.project is not None: - mapped_keys.add((device_id, workspace_path)) - total_local_tasks += len(local_tasks) - _append_worktree_tasks_to_source_workspace( - db=db, - workspace_items_by_project_id=workspace_items_by_project_id, - target=target, - device=devices_by_id.get(device_id), - local_tasks=local_tasks, - ) - continue - - target = _find_project_target_for_runtime_worktree( - projects=projects, - mappings=mappings, - device_id=device_id, - workspace_path=workspace_path, - ) - if target is None or target.project is None: - continue - mapped_keys.add((device_id, workspace_path)) + for (device_id, workspace_path), local_tasks in sorted( + runtime_workspaces.items(), + key=lambda item: _runtime_workspace_sort_key(item[1]), + ): total_local_tasks += len(local_tasks) - _append_worktree_tasks_to_source_workspace( - db=db, - workspace_items_by_project_id=workspace_items_by_project_id, - target=target, - device=devices_by_id.get(device_id), - local_tasks=local_tasks, + device = devices_by_id.get(device_id) + workspace_kind_fields = _runtime_workspace_kind_fields_from_tasks( + workspace_path, + local_tasks, ) - - projects_response = [ - RuntimeProjectWork( - project=_project_ref(project), - deviceWorkspaces=workspace_items_by_project_id.get(project.id, []), + workspace = RuntimeDeviceWorkspace( + id=None, + projectId=None, + deviceId=device_id, + deviceName=_device_name(device, device_id), + deviceStatus=_device_status(device), + workspacePath=workspace_path, + **workspace_kind_fields, + mapped=True, + available=True, + localTasks=local_tasks, ) - for project in projects - ] - - unmapped: list[RuntimeDeviceWorkspace] = [] - for (device_id, workspace_path), local_tasks in runtime_workspaces.items(): - if (device_id, workspace_path) in mapped_keys: + if workspace.workspace_kind == "chat": + conversations.append(workspace) continue - total_local_tasks += len(local_tasks) - device = devices_by_id.get(device_id) - unmapped.append( - RuntimeDeviceWorkspace( - id=None, - projectId=None, - deviceId=device_id, - deviceName=_device_name(device, device_id), - deviceStatus=_device_status(device), - workspacePath=workspace_path, - **_runtime_workspace_kind_fields(workspace_path), - mapped=False, - available=True, - localTasks=local_tasks, + project_ref = _runtime_project_ref_from_workspace(workspace_path) + projects.append( + RuntimeProjectWork( + project=project_ref, + deviceWorkspaces=[workspace], ) ) return RuntimeWorkListResponse( - projects=projects_response, - unmappedDeviceWorkspaces=unmapped, + projects=projects, + unmappedDeviceWorkspaces=conversations, totalLocalTasks=total_local_tasks, ) @@ -1772,34 +1698,6 @@ def _project_id_for_runtime_workspace( return None -def _list_projects( - db: Session, - user_id: int, - client_origin: Optional[str], -) -> list[Project]: - query = db.query(Project).filter( - Project.user_id == user_id, - Project.is_active == True, - ) - if client_origin: - query = query.filter(Project.client_origin == client_origin) - return query.order_by(Project.sort_order.asc(), Project.id.asc()).all() - - -def _list_workspace_rows( - db: Session, - user_id: int, - project_ids: list[int], -) -> list[DeviceWorkspaceResponse]: - if not project_ids: - return [] - return list_device_workspace_kinds( - db=db, - user_id=user_id, - project_ids=project_ids, - ) - - async def _list_online_runtime_workspaces( *, user_id: int, @@ -1826,10 +1724,11 @@ async def _list_online_runtime_workspaces( LocalTaskSummary.model_validate( { **task, - **_runtime_workspace_kind_fields( + **_runtime_task_kind_fields( + task, normalize_workspace_path( str(task.get("workspacePath") or workspace_path) - ) + ), ), "workspacePath": normalize_workspace_path( str(task.get("workspacePath") or workspace_path) @@ -1844,6 +1743,25 @@ async def _list_online_runtime_workspaces( 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 _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: + continue + return 0.0 + + def _iter_runtime_workspaces(result: dict[str, Any]) -> list[dict[str, Any]]: raw_workspaces = result.get("workspaces", []) if not isinstance(raw_workspaces, list): @@ -1888,17 +1806,42 @@ def _runtime_workspace_kind_fields(workspace_path: str) -> dict[str, Optional[st return {"workspaceKind": "worktree", "worktreeId": worktree.worktree_id} -def _device_workspace_kind_fields( +def _runtime_task_kind_fields( + task: dict[str, Any], workspace_path: str, - label: Optional[str], ) -> dict[str, Optional[str]]: - fields = _runtime_workspace_kind_fields(workspace_path) - if label not in {"worktree", "workspace"}: - return fields - return { - "workspaceKind": label, - "worktreeId": fields["worktreeId"] if label == "worktree" else None, - } + workspace_kind = task.get("workspaceKind") or task.get("workspace_kind") + if workspace_kind in {"workspace", "worktree", "chat"}: + worktree_id = task.get("worktreeId") or task.get("worktree_id") + return { + "workspaceKind": workspace_kind, + "worktreeId": ( + str(worktree_id).strip() + if workspace_kind == "worktree" + and isinstance(worktree_id, str) + and worktree_id.strip() + else None + ), + } + return _runtime_workspace_kind_fields(workspace_path) + + +def _runtime_workspace_kind_fields_from_tasks( + workspace_path: str, + local_tasks: list[LocalTaskSummary], +) -> dict[str, Optional[str]]: + if any(task.workspace_kind == "chat" for task in local_tasks): + return {"workspaceKind": "chat", "worktreeId": None} + worktree_task = next( + (task for task in local_tasks if task.workspace_kind == "worktree"), + None, + ) + if worktree_task: + return { + "workspaceKind": "worktree", + "worktreeId": worktree_task.worktree_id, + } + return _runtime_workspace_kind_fields(workspace_path) def _is_runtime_chat_workspace_path(path: str) -> bool: @@ -1920,242 +1863,20 @@ def _is_runtime_chat_workspace_path(path: str) -> bool: ) -def _find_project_target_for_runtime_git_info( - *, - projects: list[Project], - mappings: list[DeviceWorkspaceResponse], - device_id: str, - local_tasks: list[LocalTaskSummary], -) -> Optional[RuntimeTaskTarget]: - task_origin_urls = { - _canonical_git_url(_task_git_origin_url(task)) - for task in local_tasks - if task.runtime == "codex" and task.workspace_kind != "chat" - } - task_origin_urls.discard(None) - if not task_origin_urls: - return None - - projects_by_id = {project.id: project for project in projects} - candidates: list[tuple[int, RuntimeTaskTarget]] = [] - for mapping in mappings: - if mapping.device_id != device_id: - continue - mapping_url = _canonical_git_url(mapping.repo_url) - project = projects_by_id.get(mapping.project_id) - if mapping_url and mapping_url in task_origin_urls and project is not None: - candidates.append( - ( - 0, - RuntimeTaskTarget( - device_id=mapping.device_id, - workspace_path=mapping.workspace_path, - project=project, - workspace_source="local_path", - ), - ) - ) - - for project in projects: - target = _project_runtime_target(project) - if not target or target.device_id != device_id: - continue - project_url = _canonical_git_url(_project_git_url(project)) - if project_url and project_url in task_origin_urls: - candidates.append((1, target)) - - if not candidates: - return None - return sorted( - candidates, - key=lambda item: (item[0], item[1].project.id if item[1].project else 0), - )[0][1] - - -def _task_git_origin_url(task: LocalTaskSummary) -> Optional[str]: - git_info = task.git_info - if not isinstance(git_info, dict): - return None - value = git_info.get("originUrl") or git_info.get("origin_url") - return value if isinstance(value, str) and value.strip() else None - - -def _project_git_url(project: Project) -> Optional[str]: - config = _parse_project_config(project, strict=False) - if not config or not config.git: - return None - return config.git.url - - -def _canonical_git_url(value: Optional[str]) -> Optional[str]: - if not value: - return None - text = value.strip() - if not text: - return None - - scp_like = re.match(r"^(?:ssh://)?git@([^:/]+)[:/](.+)$", text) - if scp_like: - host, path = scp_like.groups() - return _canonical_git_host_path(host, path) - - parsed = urlparse(text) - if parsed.netloc and parsed.path: - return _canonical_git_host_path(parsed.netloc, parsed.path) - - return text.removesuffix(".git").lower() - - -def _canonical_git_host_path(host: str, path: str) -> str: - normalized_host = host.lower().removeprefix("www.") - normalized_path = path.strip("/").removesuffix(".git").lower() - return f"{normalized_host}/{normalized_path}" - - -def _find_project_target_for_runtime_worktree( - *, - projects: list[Project], - mappings: list[DeviceWorkspaceResponse], - device_id: str, - workspace_path: str, -) -> Optional[RuntimeTaskTarget]: - worktree = _parse_runtime_worktree_path(workspace_path) - if not worktree: - return None - - projects_by_id = {project.id: project for project in projects} - candidates: list[tuple[int, RuntimeTaskTarget]] = [] - for mapping in mappings: - if mapping.device_id != device_id: - continue - project = projects_by_id.get(mapping.project_id) - if project is None: - continue - if _path_basename(mapping.workspace_path).lower() == ( - worktree.project_dir_name.lower() - ): - candidates.append( - ( - 0, - RuntimeTaskTarget( - device_id=mapping.device_id, - workspace_path=mapping.workspace_path, - project=project, - workspace_source="local_path", - ), - ) - ) - - for project in projects: - target = _project_runtime_target(project) - if not target or target.device_id != device_id: - continue - if _path_basename(target.workspace_path).lower() == ( - worktree.project_dir_name.lower() - ): - candidates.append((1, target)) - if not candidates: - return None - return sorted( - candidates, - key=lambda item: (item[0], item[1].project.id if item[1].project else 0), - )[0][1] - - -def _append_worktree_tasks_to_source_workspace( - *, - db: Session, - workspace_items_by_project_id: dict[int, list[RuntimeDeviceWorkspace]], - target: RuntimeTaskTarget, - device: Optional[dict[str, Any]], - local_tasks: list[LocalTaskSummary], -) -> None: - if target.project is None: - return - - workspace_items = workspace_items_by_project_id.setdefault(target.project.id, []) - for item in workspace_items: - if ( - item.device_id == target.device_id - and item.workspace_path == target.workspace_path - ): - item.local_tasks.extend(local_tasks) - return - - mapping = _materialize_project_runtime_target( - db=db, - user_id=target.project.user_id, - project=target.project, - target=target, - ) - workspace_items.append( - _build_device_workspace_item( - mapping=mapping, - device=device, - local_tasks=local_tasks, - ) - ) - - -def _materialize_project_runtime_target( - *, - db: Session, - user_id: int, - project: Project, - target: RuntimeTaskTarget, -) -> DeviceWorkspaceResponse: - config = _parse_project_config(project, strict=False) - repo_url = config.git.url if config and config.git else None - return upsert_device_workspace( - db=db, - user_id=user_id, - payload=DeviceWorkspaceUpsert( - projectId=project.id, - deviceId=target.device_id, - workspacePath=target.workspace_path, - repoUrl=repo_url, - label="workspace", - ), - ) - - def _path_basename(path: str) -> str: parts = [part for part in normalize_workspace_path(path).split("/") if part] return parts[-1] if parts else "" -def _build_device_workspace_item( - *, - mapping: DeviceWorkspaceResponse, - device: Optional[dict[str, Any]], - local_tasks: list[LocalTaskSummary], -) -> RuntimeDeviceWorkspace: - status_value = _device_status(device) - available = status_value in {"online", "busy"} - return RuntimeDeviceWorkspace( - id=mapping.id, - projectId=mapping.project_id, - deviceId=mapping.device_id, - deviceName=_device_name(device, mapping.device_id), - deviceStatus=status_value, - workspacePath=mapping.workspace_path, - **_device_workspace_kind_fields(mapping.workspace_path, mapping.label), - repoUrl=mapping.repo_url, - repoRootFingerprint=mapping.repo_root_fingerprint, - label=mapping.label, - mapped=True, - available=available, - error=None if available else "Device is offline", - localTasks=local_tasks if available else [], - ) - - -def _project_ref(project: Project) -> RuntimeProjectRef: +def _runtime_project_ref_from_workspace(workspace_path: str) -> RuntimeProjectRef: + normalized_path = normalize_workspace_path(workspace_path) + project_id = int(sha256(normalized_path.encode("utf-8")).hexdigest()[:12], 16) + project_id = project_id % 1_000_000_000 + 1 return RuntimeProjectRef( - id=project.id, - name=project.name, - description=project.description or "", - color=project.color, + id=project_id, + name=_path_basename(normalized_path) or normalized_path, + description=normalized_path, + color=None, ) diff --git a/backend/tests/api/endpoints/test_runtime_work_api.py b/backend/tests/api/endpoints/test_runtime_work_api.py index 05fc940662..ca2b416a79 100644 --- a/backend/tests/api/endpoints/test_runtime_work_api.py +++ b/backend/tests/api/endpoints/test_runtime_work_api.py @@ -25,14 +25,11 @@ def test_list_runtime_work_endpoint_uses_current_user( runtime_work.runtime_work_service, "list_runtime_work", service_mock ) - response = test_client.get( - "/api/runtime-work?client_origin=wework", - headers=_auth_headers(test_token), - ) + response = test_client.get("/api/runtime-work", headers=_auth_headers(test_token)) assert response.status_code == 200 assert response.json()["totalLocalTasks"] == 0 - assert service_mock.await_args.kwargs["client_origin"] == "wework" + assert "client_origin" not in service_mock.await_args.kwargs def test_upsert_device_workspace_endpoint_returns_mapping( diff --git a/backend/tests/services/test_runtime_work_service.py b/backend/tests/services/test_runtime_work_service.py index 2a1f82698d..8c955e95ec 100644 --- a/backend/tests/services/test_runtime_work_service.py +++ b/backend/tests/services/test_runtime_work_service.py @@ -477,7 +477,7 @@ async def execute(**kwargs): @pytest.mark.asyncio -async def test_list_runtime_work_groups_local_tasks_under_device_workspaces( +async def test_list_runtime_work_groups_executor_workspaces_without_project_mapping( test_db, test_user, monkeypatch, @@ -492,8 +492,8 @@ async def test_list_runtime_work_groups_local_tasks_under_device_workspaces( payload=DeviceWorkspaceUpsert( projectId=project.id, deviceId="device-1", - workspacePath="/repo/Wegent", - label="MacBook", + workspacePath="/repo/Legacy", + label="legacy mapping", ), ) @@ -507,6 +507,7 @@ async def test_list_runtime_work_groups_local_tasks_under_device_workspaces( "name": "MacBook", "status": "online", "device_type": "local", + "client_ip": "192.0.2.10", } ] ), @@ -522,6 +523,7 @@ async def test_list_runtime_work_groups_local_tasks_under_device_workspaces( "workspacePath": "/repo/Wegent", "title": "Fix reconnect", "runtime": "codex", + "workspaceKind": "workspace", "createdAt": "2026-06-20T01:00:00Z", "updatedAt": "2026-06-20T02:00:00Z", "running": False, @@ -536,12 +538,28 @@ async def test_list_runtime_work_groups_local_tasks_under_device_workspaces( "workspacePath": "/tmp/spike", "title": "Spike", "runtime": "claude_code", + "workspaceKind": "workspace", "createdAt": "2026-06-20T03:00:00Z", "updatedAt": "2026-06-20T04:00:00Z", "running": True, } ], }, + { + "workspacePath": "/Users/alice/Documents/Codex/2026-06-23/chat-1", + "localTasks": [ + { + "localTaskId": "chat-1", + "workspacePath": ( + "/Users/alice/Documents/Codex/2026-06-23/chat-1" + ), + "title": "Hello", + "runtime": "codex", + "workspaceKind": "chat", + "updatedAt": "2026-06-20T05:00:00Z", + } + ], + }, ] } ) @@ -550,246 +568,41 @@ async def test_list_runtime_work_groups_local_tasks_under_device_workspaces( response = await runtime_work_service.list_runtime_work( db=test_db, user_id=test_user.id, - client_origin=CLIENT_ORIGIN_WEWORK, ) - assert response.total_local_tasks == 2 - assert response.projects[0].project.id == project.id - assert response.projects[0].device_workspaces[0].workspace_path == "/repo/Wegent" - assert ( - response.projects[0].device_workspaces[0].local_tasks[0].local_task_id - == "codex-1" + assert response.total_local_tasks == 3 + assert [project_work.project.name for project_work in response.projects] == [ + "spike", + "Wegent", + ] + assert {project_work.project.id for project_work in response.projects}.isdisjoint( + {project.id} ) - assert response.unmapped_device_workspaces[0].workspace_path == "/tmp/spike" + assert response.projects[0].device_workspaces[0].workspace_path == "/tmp/spike" + 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.unmapped_device_workspaces[0].local_tasks[0].runtime == "claude_code" - ) - rpc.assert_awaited_once() - assert test_db.query(TaskResource).count() == 0 - - -@pytest.mark.asyncio -async def test_list_runtime_work_materializes_legacy_project_config_workspace( - test_db, - test_user, - monkeypatch, -): - from app.services import runtime_work_service - - project = _local_path_project( - test_db, - test_user.id, - device_id="device-1", - path="/repo/Wegent", - ) - monkeypatch.setattr( - runtime_work_service.device_service, - "get_all_devices", - AsyncMock( - return_value=[ - { - "device_id": "device-1", - "name": "Local Device", - "status": "online", - "executor_version": "1.8.5", - } - ] - ), - ) - monkeypatch.setattr( - runtime_work_service.runtime_rpc_service, - "call", - AsyncMock( - return_value={ - "workspaces": [ - { - "workspacePath": "/repo/Wegent", - "localTasks": [], - } - ] - } - ), - ) - - response = await runtime_work_service.list_runtime_work( - db=test_db, - user_id=test_user.id, - client_origin=CLIENT_ORIGIN_WEWORK, - ) - - workspace = response.projects[0].device_workspaces[0] - assert workspace.id is not None - assert workspace.project_id == project.id - assert workspace.device_id == "device-1" - assert workspace.workspace_path == "/repo/Wegent" - - rows = runtime_work_service.list_device_workspaces( - db=test_db, - user_id=test_user.id, - project_id=project.id, - ) - assert len(rows) == 1 - assert rows[0].id == workspace.id - assert rows[0].workspace_path == "/repo/Wegent" - - -@pytest.mark.asyncio -async def test_list_runtime_work_uses_mapping_label_as_workspace_kind( - test_db, - test_user, - monkeypatch, -): - from app.schemas.runtime_work import 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", - label="worktree", - ), - ) - - monkeypatch.setattr( - runtime_work_service.device_service, - "get_all_devices", - AsyncMock( - return_value=[ - { - "device_id": "device-1", - "name": "MacBook", - "status": "online", - "device_type": "local", - } - ] - ), - ) - monkeypatch.setattr( - runtime_work_service.runtime_rpc_service, - "call", - AsyncMock(return_value={"workspaces": []}), - ) - - response = await runtime_work_service.list_runtime_work( - db=test_db, - user_id=test_user.id, - client_origin=CLIENT_ORIGIN_WEWORK, - ) - - workspace = response.projects[0].device_workspaces[0] - assert workspace.workspace_path == "/repo/Wegent" - assert workspace.label == "worktree" - assert workspace.workspace_kind == "worktree" - - -@pytest.mark.asyncio -async def test_list_runtime_work_matches_project_configured_local_directory_without_mapping_row( - test_db, - test_user, - monkeypatch, -): - from app.services import runtime_work_service - - project = _local_path_project(test_db, test_user.id) - - monkeypatch.setattr( - runtime_work_service.device_service, - "get_all_devices", - AsyncMock( - return_value=[ - { - "device_id": "device-1", - "name": "MacBook", - "status": "online", - "device_type": "local", - } - ] - ), - ) - monkeypatch.setattr( - runtime_work_service.runtime_rpc_service, - "call", - AsyncMock( - return_value={ - "workspaces": [ - { - "workspacePath": "/repo/Wegent", - "localTasks": [ - { - "localTaskId": "018f2d6b-8c7a-7abc-9def-0123456789ab", - "workspacePath": "/repo/Wegent", - "title": "Implement runtime sidebar", - "runtime": "codex", - "updatedAt": "2026-06-20T02:00:00Z", - } - ], - }, - { - "workspacePath": ( - "/Users/axb-mac/.wecode/wegent-executor/workspace/" - "chats/2026-06-20/hi-1" - ), - "localTasks": [ - { - "localTaskId": "019ee579-f6f4-73d3-9b3e-2d4652e0c9e9", - "workspacePath": ( - "/Users/axb-mac/.wecode/wegent-executor/" - "workspace/chats/2026-06-20/hi-1" - ), - "title": "hi", - "runtime": "codex", - "updatedAt": "2026-06-20T14:40:46+00:00", - } - ], - }, - ] - } - ), - ) - - response = await runtime_work_service.list_runtime_work( - db=test_db, - user_id=test_user.id, - client_origin=CLIENT_ORIGIN_WEWORK, + response.projects[1].device_workspaces[0].local_tasks[0].local_task_id + == "codex-1" ) - - workspace = response.projects[0].device_workspaces[0] - assert response.projects[0].project.id == project.id - assert workspace.device_id == "device-1" - assert workspace.workspace_path == "/repo/Wegent" - assert workspace.local_tasks[0].title == "Implement runtime sidebar" assert len(response.unmapped_device_workspaces) == 1 - assert ( - response.unmapped_device_workspaces[0].workspace_path - == "/Users/axb-mac/.wecode/wegent-executor/workspace/chats/2026-06-20/hi-1" - ) assert response.unmapped_device_workspaces[0].workspace_kind == "chat" - assert response.unmapped_device_workspaces[0].local_tasks[0].title == "hi" assert ( - response.unmapped_device_workspaces[0].local_tasks[0].workspace_kind == "chat" + response.unmapped_device_workspaces[0].local_tasks[0].local_task_id == "chat-1" ) + rpc.assert_awaited_once() assert test_db.query(TaskResource).count() == 0 @pytest.mark.asyncio -async def test_list_runtime_work_groups_managed_worktree_under_source_project( +async def test_list_runtime_work_preserves_executor_workspace_kind( test_db, test_user, monkeypatch, ): from app.services import runtime_work_service - project = _local_path_project( - test_db, - test_user.id, - path="/workspace/Wegent", - name="Wegent", - ) - monkeypatch.setattr( runtime_work_service.device_service, "get_all_devices", @@ -818,6 +631,8 @@ async def test_list_runtime_work_groups_managed_worktree_under_source_project( "workspacePath": "/workspace/worktrees/42/Wegent", "title": "Fix worktree sidebar", "runtime": "codex", + "workspaceKind": "worktree", + "worktreeId": "42", "updatedAt": "2026-06-20T02:00:00Z", } ], @@ -830,129 +645,26 @@ async def test_list_runtime_work_groups_managed_worktree_under_source_project( response = await runtime_work_service.list_runtime_work( db=test_db, user_id=test_user.id, - client_origin=CLIENT_ORIGIN_WEWORK, - ) - - worktree_workspace = response.projects[0].device_workspaces[0] - task = worktree_workspace.local_tasks[0] - assert response.projects[0].project.id == project.id - assert worktree_workspace.workspace_path == "/workspace/Wegent" - assert worktree_workspace.workspace_kind == "workspace" - assert worktree_workspace.worktree_id is None - assert task.local_task_id == "codex-worktree" - assert task.workspace_path == "/workspace/worktrees/42/Wegent" - assert task.workspace_kind == "worktree" - assert task.worktree_id == "42" - assert response.unmapped_device_workspaces == [] - assert test_db.query(TaskResource).count() == 0 - - -@pytest.mark.asyncio -async def test_list_runtime_work_groups_mapped_device_worktree_under_project( - test_db, - test_user, - monkeypatch, -): - from app.schemas.runtime_work import DeviceWorkspaceUpsert - from app.services import runtime_work_service - - project = _git_project(test_db, test_user.id, name="Wegent_github") - runtime_work_service.upsert_device_workspace( - db=test_db, - user_id=test_user.id, - payload=DeviceWorkspaceUpsert( - projectId=project.id, - deviceId="target-device", - workspacePath="/target/Wegent_github", - repoUrl="https://github.com/wecode-ai/Wegent.git", - label="workspace", - ), - ) - - monkeypatch.setattr( - runtime_work_service.device_service, - "get_all_devices", - AsyncMock( - return_value=[ - { - "device_id": "target-device", - "name": "Linux", - "status": "online", - "device_type": "local", - } - ] - ), - ) - monkeypatch.setattr( - runtime_work_service.runtime_rpc_service, - "call", - AsyncMock( - return_value={ - "workspaces": [ - { - "workspacePath": "/target/worktrees/019eeb2c/Wegent_github", - "localTasks": [ - { - "localTaskId": "forked-codex", - "workspacePath": ( - "/target/worktrees/019eeb2c/Wegent_github" - ), - "title": "Forked Codex task", - "runtime": "codex", - "updatedAt": "2026-06-22T02:00:00Z", - } - ], - } - ] - } - ), - ) - - response = await runtime_work_service.list_runtime_work( - db=test_db, - user_id=test_user.id, - client_origin=CLIENT_ORIGIN_WEWORK, ) workspace = response.projects[0].device_workspaces[0] task = workspace.local_tasks[0] - assert response.projects[0].project.id == project.id - assert workspace.device_id == "target-device" - assert workspace.workspace_path == "/target/Wegent_github" - assert task.local_task_id == "forked-codex" - assert task.workspace_path == "/target/worktrees/019eeb2c/Wegent_github" + assert workspace.workspace_path == "/workspace/worktrees/42/Wegent" + assert workspace.workspace_kind == "worktree" + assert workspace.worktree_id == "42" assert task.workspace_kind == "worktree" - assert task.worktree_id == "019eeb2c" + assert task.worktree_id == "42" assert response.unmapped_device_workspaces == [] - assert test_db.query(TaskResource).count() == 0 @pytest.mark.asyncio -async def test_list_runtime_work_groups_codex_git_origin_under_matching_project( +async def test_list_runtime_work_skips_offline_devices( test_db, test_user, monkeypatch, ): - from app.schemas.runtime_work import DeviceWorkspaceUpsert from app.services import runtime_work_service - project = _local_path_project( - test_db, - test_user.id, - path="/workspace/Wegent", - name="Wegent", - ) - runtime_work_service.upsert_device_workspace( - db=test_db, - user_id=test_user.id, - payload=DeviceWorkspaceUpsert( - projectId=project.id, - deviceId="device-1", - workspacePath="/workspace/Wegent", - repoUrl="https://github.com/wecode-ai/Wegent.git", - ), - ) - monkeypatch.setattr( runtime_work_service.device_service, "get_all_devices", @@ -961,52 +673,24 @@ async def test_list_runtime_work_groups_codex_git_origin_under_matching_project( { "device_id": "device-1", "name": "MacBook", - "status": "online", + "status": "offline", "device_type": "local", } ] ), ) - monkeypatch.setattr( - runtime_work_service.runtime_rpc_service, - "call", - AsyncMock( - return_value={ - "workspaces": [ - { - "workspacePath": "/Users/alice/dev/other/Wegent", - "localTasks": [ - { - "localTaskId": "codex-git-origin", - "workspacePath": "/Users/alice/dev/other/Wegent", - "title": "Fix Git grouped task", - "runtime": "codex", - "gitInfo": { - "originUrl": "git@github.com:wecode-ai/Wegent.git" - }, - "updatedAt": "2026-06-20T02:00:00Z", - } - ], - } - ] - } - ), - ) + rpc = AsyncMock(return_value={"workspaces": []}) + monkeypatch.setattr(runtime_work_service.runtime_rpc_service, "call", rpc) response = await runtime_work_service.list_runtime_work( db=test_db, user_id=test_user.id, - client_origin=CLIENT_ORIGIN_WEWORK, ) - workspace = response.projects[0].device_workspaces[0] - task = workspace.local_tasks[0] - assert response.projects[0].project.id == project.id - assert workspace.workspace_path == "/workspace/Wegent" - assert task.local_task_id == "codex-git-origin" - assert task.workspace_path == "/Users/alice/dev/other/Wegent" + assert response.projects == [] assert response.unmapped_device_workspaces == [] - assert test_db.query(TaskResource).count() == 0 + assert response.total_local_tasks == 0 + rpc.assert_not_awaited() @pytest.mark.asyncio diff --git a/docs/en/developer-guide/runtime-local-work.md b/docs/en/developer-guide/runtime-local-work.md index 4b28049975..18293a2e86 100644 --- a/docs/en/developer-guide/runtime-local-work.md +++ b/docs/en/developer-guide/runtime-local-work.md @@ -4,20 +4,23 @@ sidebar_position: 16 # Runtime Local Work -Wework runtime local work surfaces Codex and Claude Code work that already exists on a user's device. It does not import that work into central `TaskResource` or `Subtask` rows. The visible model has three levels: +Wework runtime local work surfaces Codex and Claude Code work that already exists on a user's device. It does not import that work into central `TaskResource` or `Subtask` rows, and it no longer depends on the Backend `projects` table to build the sidebar list. The list comes from runtime threads returned by online device executors and is shown in two groups: ```text Project - Device Workspace - LocalTask + LocalTask + +Conversation + LocalTask ``` ## Ownership -- Project is central Backend state. -- Device Workspace is a central mapping from user, device, and local directory to a Project. +- Project is a display group inferred from the runtime workspace information returned by the executor. +- Conversation is a Codex chat thread that does not belong to a Project workspace. - LocalTask is executor-local state and remains on the device. - A LocalTask's stable identity is `deviceId + localTaskId`. `workspacePath` is only device-workspace context for list grouping, task creation, and right-side workspace tools; task URLs, IM notification subscriptions, and native Codex update deduplication do not use the path as an identity field. +- The executor returns `workspaceKind` to distinguish Project work from Conversations. Codex App-style directories such as `~/Documents/Codex/YYYY-MM-DD/` are marked as `chat` and shown under "Conversations"; other workspaces are shown under "Projects". The executor still keeps a JSON LocalTask index for non-Codex or imported local tasks: @@ -29,16 +32,19 @@ Native Codex tasks are not written to this index. List refresh discovers them fr ## List Refresh -The frontend drives task list refreshes through polling. +Wework requests the task list on startup, explicit refresh, or device-state changes. It no longer refreshes the list through a fixed polling interval. -1. Wework periodically requests `GET /api/runtime-work?client_origin=wework`. -2. Backend reads the user's Projects and Device Workspace mappings. -3. Backend calls `runtime.tasks.list` over the online device WebSocket RPC channel. -4. The executor refreshes local Codex discovery and merges the non-Codex/imported JSON LocalTask index. -5. Backend groups results by `deviceId + workspacePath` and returns Project -> Device Workspace -> LocalTask, while each LocalTask is still opened and notified by `deviceId + localTaskId`. +1. Wework requests `GET /api/runtime-work`. +2. Backend reads the current user's online devices and calls `runtime.tasks.list` over each device WebSocket RPC channel. +3. The executor refreshes local Codex discovery and merges the non-Codex/imported JSON LocalTask index. +4. The executor returns `workspaceKind`, workspace path, task title, update time, and device status. +5. Backend performs light aggregation and returns the result to Wework without reading or matching the Backend `projects` table. +6. Wework renders Projects and Conversations from the runtime work response, while each LocalTask is still opened and notified by `deviceId + localTaskId`. The executor does not poll or push task lists to Backend by itself. Offline devices do not contribute LocalTasks. Wework may show an offline mapped workspace, but it does not keep a central cache of local tasks. +When there is only one device, Wework does not show an IP next to Project names. When there are multiple devices, the local device still omits the IP, while online remote devices show a usable non-loopback runtime transfer host or client IP with a green online dot. + ## Open And Continue When a user opens a LocalTask, Wework calls Backend: @@ -110,9 +116,13 @@ When Wework forks a runtime task, it only offers target workspaces that belong t The forked task identity still uses `deviceId + localTaskId`. `workspacePath` is only target-directory and workspace-tool context. -## Non-Project Workspaces +## Projects And Conversations + +Wework no longer shows "Unmapped Device Workspaces". Every thread returned by the executor must be grouped into either "Projects" or "Conversations": -Directories discovered by an executor but not mapped to a central Project appear in Wework under "Unmapped Device Workspaces". They also come from online device `runtime.tasks.list` responses, not from central database tasks. +- Tasks with `workspaceKind: chat` are shown under "Conversations". +- Other tasks are grouped as "Projects" by workspace name. +- The "Conversations" section is always visible, even when empty, and supports the same collapse and expand interaction as "Projects". ## IM Notifications @@ -136,4 +146,4 @@ The URL does not contain `workspacePath`. On refresh or shared links, the fronte ## Compatibility -Wegent-native Task/Subtask flows remain available for existing chat, shared task, and historical task URL paths. Wework sidebar, mobile drawer, project task display, and new task creation use the runtime work API instead of the DB task list. +Wegent-native Task/Subtask flows remain available for existing chat, shared task, and historical task URL paths. Wework sidebar, mobile drawer, project task display, and new task creation use the runtime work API instead of the DB task list or Backend `projects` table. diff --git a/docs/zh/developer-guide/runtime-local-work.md b/docs/zh/developer-guide/runtime-local-work.md index a98e6c81c2..3de86e608c 100644 --- a/docs/zh/developer-guide/runtime-local-work.md +++ b/docs/zh/developer-guide/runtime-local-work.md @@ -4,20 +4,23 @@ sidebar_position: 16 # 本地运行时任务 -Wework 的本地运行时任务用于展示和继续用户已经在设备上创建的 Codex 或 Claude Code 工作。它不再把这些工作导入中心库的 `TaskResource` 或 `Subtask`,而是按三层结构展示: +Wework 的本地运行时任务用于展示和继续用户已经在设备上创建的 Codex 或 Claude Code 工作。它不再把这些工作导入中心库的 `TaskResource` 或 `Subtask`,也不再依赖 Backend `projects` 表生成侧栏列表。列表来自在线设备 executor 返回的运行时线程,并在前端展示为两类: ```text Project - Device Workspace - LocalTask + LocalTask + +Conversation + LocalTask ``` ## 数据归属 -- Project 是中心状态,仍由 Backend 管理。 -- Device Workspace 是中心映射,表示某个用户、设备和本地目录属于哪个 Project。 +- Project 是 executor 线程所属工作区的展示分组,由运行时列表里的工作区信息推导。 +- Conversation 是没有项目归属的 Codex 对话线程展示分组。 - LocalTask 是 executor 本机状态,只保存在设备上。 - LocalTask 的稳定身份是 `deviceId + localTaskId`。`workspacePath` 只作为设备工作区上下文使用,用于列表分组、创建任务和右侧工具定位目录;任务 URL、IM 通知订阅和原生 Codex 更新去重都不把路径作为身份字段。 +- executor 返回的 `workspaceKind` 用于区分 Project 与 Conversation。Codex App 风格的目录(例如 `~/Documents/Codex/YYYY-MM-DD/`)会被标记为 `chat` 并展示到“对话”,其他工作区展示到“项目”。 executor 仍为非 Codex 或导入类本地任务保留 JSON LocalTask 索引: @@ -29,16 +32,19 @@ $WEGENT_EXECUTOR_HOME/runtime-work/index.json ## 列表刷新 -任务列表由前端轮询触发。 +任务列表由 Wework 在启动、显式刷新或设备状态变化时请求,不再由固定 interval 轮询触发。 -1. Wework 定时请求 `GET /api/runtime-work?client_origin=wework`。 -2. Backend 读取当前用户的 Project 和 Device Workspace 映射。 -3. Backend 通过在线设备的 WebSocket RPC 调用 `runtime.tasks.list`。 -4. executor 刷新本机 Codex 发现结果,并合并非 Codex/导入类 JSON LocalTask 索引。 -5. Backend 按 `deviceId + workspacePath` 分组返回 Project -> Device Workspace -> LocalTask,但每个 LocalTask 的打开和通知身份仍是 `deviceId + localTaskId`。 +1. Wework 请求 `GET /api/runtime-work`。 +2. Backend 读取当前用户在线设备列表,并通过设备 WebSocket RPC 调用 `runtime.tasks.list`。 +3. executor 刷新本机 Codex 发现结果,并合并非 Codex/导入类 JSON LocalTask 索引。 +4. executor 在返回值中携带 `workspaceKind`、工作区路径、任务标题、更新时间和设备状态。 +5. Backend 做轻量聚合后返回给 Wework,不再读取或匹配 Backend `projects` 表。 +6. Wework 根据 runtime work 响应展示 Project 和 Conversation;每个 LocalTask 的打开和通知身份仍是 `deviceId + localTaskId`。 executor 不主动向 Backend 轮询或推送任务列表。离线设备不会贡献 LocalTask;Wework 可以显示映射目录离线,但不会从中心库缓存本地任务。 +如果只有一个设备,Wework 不在项目名后显示设备 IP;如果有多个设备,本地设备不显示 IP,远端在线设备显示可用的非 loopback runtime transfer host 或客户端 IP,并配绿色在线点。 + ## 打开和继续任务 打开 LocalTask 时,Wework 调用 Backend: @@ -110,9 +116,13 @@ Project 场景必须使用可信的 Device Workspace 映射: 复制任务的身份仍然使用 `deviceId + localTaskId`。`workspacePath` 只用于定位目标设备目录和工作区工具上下文。 -## 非 Project 工作区 +## Project 与 Conversation + +Wework 不再显示“未映射工作区”。executor 返回的线程必须能归入“项目”或“对话”: -executor 发现但没有映射到中心 Project 的目录会在 Wework 的“未映射工作区”中显示。它们同样来自在线设备的 `runtime.tasks.list` 返回值,而不是中心数据库任务。 +- `workspaceKind: chat` 的任务展示在“对话”区。 +- 其他任务按工作区名称展示为“项目”。 +- “对话”区即使为空也始终显示,并且支持像“项目”区一样折叠和展开。 ## IM 通知 @@ -136,4 +146,4 @@ URL 不包含 `workspacePath`。刷新页面或复制链接时,前端先用 UR ## 兼容性 -Wegent 原生 Task/Subtask 流程仍保留给现有聊天、共享任务和历史 task URL。Wework sidebar、移动端 drawer、项目下任务展示和新任务创建路径使用 runtime work API,不再依赖 DB task list。 +Wegent 原生 Task/Subtask 流程仍保留给现有聊天、共享任务和历史 task URL。Wework sidebar、移动端 drawer、项目下任务展示和新任务创建路径使用 runtime work API,不再依赖 DB task list 或 Backend `projects` 表。 diff --git a/executor/modes/local/websocket_client.py b/executor/modes/local/websocket_client.py index a6434335c3..5b93fb8153 100644 --- a/executor/modes/local/websocket_client.py +++ b/executor/modes/local/websocket_client.py @@ -31,6 +31,7 @@ import asyncio import hashlib +import ipaddress import os import platform import re @@ -72,6 +73,19 @@ def build_runtime_auth_file_report( } +def _is_usable_device_ip(value: str) -> bool: + try: + address = ipaddress.ip_address(value.strip()) + except ValueError: + return False + return not ( + address.is_loopback + or address.is_unspecified + or address.is_multicast + or address.is_link_local + ) + + class WebSocketClient: """WebSocket client using Socket.IO for Backend communication. @@ -345,7 +359,7 @@ def _get_client_ip(self) -> str: # Google's public DNS server (8.8.8.8) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] - if ip and ip != "127.0.0.1": + if ip and _is_usable_device_ip(ip): return ip except Exception: logger.debug("Failed to detect IP via UDP socket", exc_info=True) @@ -353,20 +367,31 @@ def _get_client_ip(self) -> str: try: # Method 2: Get hostname resolution ip = socket.gethostbyname(socket.gethostname()) - if ip and ip != "127.0.0.1": + if ip and _is_usable_device_ip(ip): return ip except Exception: logger.debug("Failed to detect IP via hostname resolution", exc_info=True) try: - # Method 3: Try to get IP from network interfaces (Linux only) + # Method 3: Enumerate hostname addresses, useful on macOS where + # gethostbyname may resolve to loopback only. + addresses = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET) + for address in addresses: + ip = address[4][0] + if ip and _is_usable_device_ip(ip): + return ip + except Exception: + logger.debug("Failed to detect IP via hostname addresses", exc_info=True) + + try: + # Method 4: Try to get IP from network interfaces (Linux only) result = subprocess.run( ["hostname", "-I"], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: ips = result.stdout.strip().split() for ip in ips: - if ip and not ip.startswith("127."): + if ip and _is_usable_device_ip(ip): return ip except Exception: logger.debug("Failed to detect IP via hostname -I", exc_info=True) diff --git a/executor/runtime_work/codex_discovery.py b/executor/runtime_work/codex_discovery.py index 89ab8fce88..124b856505 100644 --- a/executor/runtime_work/codex_discovery.py +++ b/executor/runtime_work/codex_discovery.py @@ -7,6 +7,7 @@ import contextlib import json import os +import re from collections import deque from datetime import datetime, timezone from pathlib import Path @@ -24,6 +25,7 @@ DEFAULT_CODEX_SESSION_LIMIT = 100 CODEX_SESSION_RUNNING_TAIL_LINES = 200 CODEX_TERMINAL_EVENT_TYPES = {"task_complete", "turn_aborted"} +CODEX_CONVERSATION_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") logger = setup_logger("codex_session_discovery") @@ -279,6 +281,7 @@ def _thread_to_local_task(thread: Any) -> Optional[LocalTaskRecord]: workspace_path=normalize_workspace_path(cwd), title=_thread_title(thread, thread_id), runtime="codex", + workspace_kind=_codex_thread_workspace_kind(cwd), runtime_handle=runtime_handle, created_at=created_at, updated_at=updated_at, @@ -288,6 +291,24 @@ def _thread_to_local_task(thread: Any) -> Optional[LocalTaskRecord]: ) +def _codex_thread_workspace_kind(cwd: str) -> str: + return "chat" if _is_codex_app_conversation_path(cwd) else "workspace" + + +def _is_codex_app_conversation_path(cwd: str) -> bool: + try: + path = Path(cwd).expanduser().resolve(strict=False) + root = (Path.home() / "Documents" / "Codex").resolve(strict=False) + relative = path.relative_to(root) + except ValueError: + return False + + parts = relative.parts + return len(parts) >= 2 and bool( + CODEX_CONVERSATION_DATE_PATTERN.fullmatch(parts[0]) + ) + + def _thread_title(thread: Any, thread_id: str) -> str: title = _object_text(thread, "name", "preview", "title") return title or thread_id diff --git a/executor/runtime_work/local_task_store.py b/executor/runtime_work/local_task_store.py index 78f7d21e9a..508c4329c7 100644 --- a/executor/runtime_work/local_task_store.py +++ b/executor/runtime_work/local_task_store.py @@ -33,6 +33,8 @@ class LocalTaskRecord: workspace_path: str title: str runtime: str + workspace_kind: str = "workspace" + worktree_id: Optional[str] = None runtime_handle: dict[str, Any] = field(default_factory=dict) parent: Optional[dict[str, Any]] = None children: list[dict[str, Any]] = field(default_factory=list) @@ -64,6 +66,8 @@ def upsert_task(self, task: LocalTaskRecord) -> None: workspace_path=workspace_path, title=task.title, runtime=task.runtime, + workspace_kind=task.workspace_kind or "workspace", + worktree_id=task.worktree_id, runtime_handle=task.runtime_handle, parent=task.parent, children=task.children, @@ -153,6 +157,8 @@ def update_task( workspace_path=normalize_workspace_path(updated.workspace_path), title=updated.title, runtime=updated.runtime, + workspace_kind=updated.workspace_kind or current.workspace_kind or "workspace", + worktree_id=updated.worktree_id, runtime_handle=updated.runtime_handle, parent=updated.parent, children=updated.children, @@ -200,6 +206,8 @@ def _payload_to_record(self, payload: Any) -> LocalTaskRecord: workspace_path=normalize_workspace_path(str(payload["workspace_path"])), title=str(payload["title"]), runtime=str(payload["runtime"]), + workspace_kind=str(payload.get("workspace_kind") or "workspace"), + worktree_id=self._optional_text_value(payload.get("worktree_id")), runtime_handle=self._dict_value(payload.get("runtime_handle")), parent=self._optional_dict_value(payload.get("parent")), children=self._list_value(payload.get("children")), @@ -215,6 +223,9 @@ def _dict_value(self, value: Any) -> dict[str, Any]: def _optional_dict_value(self, value: Any) -> Optional[dict[str, Any]]: return value if isinstance(value, dict) else None + def _optional_text_value(self, value: Any) -> Optional[str]: + return value if isinstance(value, str) and value.strip() else None + def _list_value(self, value: Any) -> list[dict[str, Any]]: return value if isinstance(value, list) else [] diff --git a/executor/runtime_work/rpc_handler.py b/executor/runtime_work/rpc_handler.py index 09d24c477b..5bfa9162f4 100644 --- a/executor/runtime_work/rpc_handler.py +++ b/executor/runtime_work/rpc_handler.py @@ -1327,6 +1327,8 @@ def _task_summary(self, task: LocalTaskRecord) -> dict[str, Any]: "workspacePath": task.workspace_path, "title": task.title, "runtime": task.runtime, + "workspaceKind": task.workspace_kind, + "worktreeId": task.worktree_id, "createdAt": task.created_at, "updatedAt": task.updated_at, "running": task.running, diff --git a/executor/tests/test_local_websocket_client.py b/executor/tests/test_local_websocket_client.py index 6c97d41a75..7880b0f391 100644 --- a/executor/tests/test_local_websocket_client.py +++ b/executor/tests/test_local_websocket_client.py @@ -7,6 +7,7 @@ from executor.modes.local.websocket_client import ( WebSocketClient, build_runtime_auth_file_report, + _is_usable_device_ip, ) @@ -27,6 +28,13 @@ def test_build_runtime_auth_file_report_reports_codex_auth_presence(tmp_path): assert build_runtime_auth_file_report(home=tmp_path)["codex"]["exists"] is True +def test_usable_device_ip_rejects_loopback_and_accepts_non_loopback_address(): + assert _is_usable_device_ip("192.0.2.10") is True + assert _is_usable_device_ip("192.168.1.8") is True + assert _is_usable_device_ip("127.0.0.1") is False + assert _is_usable_device_ip("localhost") is False + + def test_connected_accepts_namespace_connected_during_connect_callback(): client = WebSocketClient.__new__(WebSocketClient) diff --git a/frontend/src/apis/devices.ts b/frontend/src/apis/devices.ts index f0413e2443..3444852c9d 100644 --- a/frontend/src/apis/devices.ts +++ b/frontend/src/apis/devices.ts @@ -54,6 +54,8 @@ export interface DeviceInfo { executor_version: string | null latest_version: string | null update_available: boolean + client_ip?: string | null + runtime_transfer_host?: string | null // Cloud device specific config cloud_config?: CloudDeviceConfig // Shell binding type diff --git a/wework/src/api/devices.test.ts b/wework/src/api/devices.test.ts index 6709346438..a37c3b8b6f 100644 --- a/wework/src/api/devices.test.ts +++ b/wework/src/api/devices.test.ts @@ -15,6 +15,8 @@ describe('createDeviceApi', () => { is_default: true, device_type: 'cloud', bind_shell: 'claudecode', + client_ip: '192.0.2.10', + runtime_transfer_host: '192.0.2.10:9000', }, { id: 2, @@ -33,7 +35,11 @@ describe('createDeviceApi', () => { const api = createDeviceApi(client) await expect(api.listDevices()).resolves.toEqual([ - expect.objectContaining({ device_id: 'claude-device' }), + expect.objectContaining({ + device_id: 'claude-device', + client_ip: '192.0.2.10', + runtime_transfer_host: '192.0.2.10:9000', + }), ]) await expect(api.getAllDevices()).resolves.toEqual([ expect.objectContaining({ device_id: 'claude-device' }), @@ -146,7 +152,7 @@ describe('createDeviceApi', () => { const api = createDeviceApi({ post } as never) await expect(api.listWorkspaceEntries('device-a', '/workspace/project')).rejects.toThrow( - 'Invalid workspace tree response', + 'Invalid workspace tree response' ) }) @@ -162,7 +168,7 @@ describe('createDeviceApi', () => { const api = createDeviceApi({ post } as never) await expect(api.listWorkspaceEntries('device-a', '/workspace/project')).rejects.toThrow( - 'Invalid workspace tree response', + 'Invalid workspace tree response' ) }) @@ -186,7 +192,7 @@ describe('createDeviceApi', () => { const api = createDeviceApi({ post } as never) await expect(api.listWorkspaceEntries('device-a', '/workspace/project')).rejects.toThrow( - 'Invalid workspace tree response', + 'Invalid workspace tree response' ) }) @@ -195,7 +201,7 @@ describe('createDeviceApi', () => { const api = createDeviceApi({ post } as never) await expect(api.listWorkspaceEntries('device-a', 'workspace/project')).rejects.toThrow( - 'Workspace path must be absolute', + 'Workspace path must be absolute' ) expect(post).not.toHaveBeenCalled() }) @@ -216,7 +222,7 @@ describe('createDeviceApi', () => { const api = createDeviceApi({ post } as never) await expect( - api.readWorkspaceTextFile('device-a', '/workspace/project/src/main.ts'), + api.readWorkspaceTextFile('device-a', '/workspace/project/src/main.ts') ).resolves.toMatchObject({ path: '/workspace/project/src/main.ts', name: 'main.ts', @@ -238,7 +244,7 @@ describe('createDeviceApi', () => { const api = createDeviceApi({ post } as never) await expect(api.readWorkspaceTextFile('device-a', 'src/main.ts')).rejects.toThrow( - 'Workspace file path must be absolute', + 'Workspace file path must be absolute' ) expect(post).not.toHaveBeenCalled() }) @@ -259,7 +265,7 @@ describe('createDeviceApi', () => { const api = createDeviceApi({ post } as never) await expect( - api.readWorkspaceTextFile('device-a', '/workspace/project/src/../main.ts'), + api.readWorkspaceTextFile('device-a', '/workspace/project/src/../main.ts') ).resolves.toMatchObject({ path: '/workspace/project/main.ts', name: 'main.ts', @@ -289,7 +295,7 @@ describe('createDeviceApi', () => { const api = createDeviceApi({ post } as never) await expect( - api.readWorkspaceTextFile('device-a', '/workspace/project/src/main.ts'), + api.readWorkspaceTextFile('device-a', '/workspace/project/src/main.ts') ).rejects.toThrow('Invalid workspace text file response') }) @@ -309,7 +315,7 @@ describe('createDeviceApi', () => { const api = createDeviceApi({ post } as never) await expect( - api.readWorkspaceTextFile('device-a', '/workspace/project/src/main.ts'), + api.readWorkspaceTextFile('device-a', '/workspace/project/src/main.ts') ).rejects.toThrow('Invalid workspace text file response') }) }) diff --git a/wework/src/api/runtimeWork.test.ts b/wework/src/api/runtimeWork.test.ts index 981bb7dd7a..bc699202e3 100644 --- a/wework/src/api/runtimeWork.test.ts +++ b/wework/src/api/runtimeWork.test.ts @@ -3,6 +3,23 @@ import { createRuntimeWorkApi } from './runtimeWork' import type { HttpClient } from './http' describe('createRuntimeWorkApi', () => { + test('lists runtime work without client origin query', async () => { + const get = vi.fn().mockResolvedValue({ + projects: [], + unmappedDeviceWorkspaces: [], + totalLocalTasks: 0, + }) + const api = createRuntimeWorkApi({ get } as unknown as HttpClient) + + await expect(api.listRuntimeWork()).resolves.toEqual({ + projects: [], + unmappedDeviceWorkspaces: [], + totalLocalTasks: 0, + }) + + expect(get).toHaveBeenCalledWith('/runtime-work') + }) + test('deletes a device workspace mapping', async () => { const del = vi.fn().mockResolvedValue({ deleted: true }) const api = createRuntimeWorkApi({ delete: del } as unknown as HttpClient) diff --git a/wework/src/api/runtimeWork.ts b/wework/src/api/runtimeWork.ts index 600fca7703..d7e5cba3d3 100644 --- a/wework/src/api/runtimeWork.ts +++ b/wework/src/api/runtimeWork.ts @@ -24,17 +24,10 @@ import type { } from '@/types/api' import type { HttpClient } from './http' -const WEWORK_CLIENT_ORIGIN = 'wework' - -function withClientOrigin(path: string): string { - const separator = path.includes('?') ? '&' : '?' - return `${path}${separator}client_origin=${WEWORK_CLIENT_ORIGIN}` -} - export function createRuntimeWorkApi(client: HttpClient) { return { listRuntimeWork(): Promise { - return client.get(withClientOrigin('/runtime-work')) + return client.get('/runtime-work') }, upsertDeviceWorkspace(data: DeviceWorkspaceUpsert): Promise { return client.post('/runtime-work/device-workspaces', data) diff --git a/wework/src/components/chat/composer/ProjectWorkBar.tsx b/wework/src/components/chat/composer/ProjectWorkBar.tsx index b767c2655b..6ce155334c 100644 --- a/wework/src/components/chat/composer/ProjectWorkBar.tsx +++ b/wework/src/components/chat/composer/ProjectWorkBar.tsx @@ -367,8 +367,8 @@ export function ProjectWorkBar({ const handleSelectDeviceWorkspace = useCallback( (projectId: number, workspace: RuntimeDeviceWorkspace) => { - if (!isSelectableProjectWorkspace(workspace, devices) || !workspace.id) return - onSelectProjectWorkspace?.(projectId, workspace.id) + if (!isSelectableProjectWorkspace(workspace, devices)) return + onSelectProjectWorkspace?.(projectId, workspace.id ?? null) closeMenu() }, [closeMenu, devices, onSelectProjectWorkspace] @@ -723,7 +723,7 @@ export function ProjectWorkBar({ - {offlineDevices.length > 0 && ( - - )} - - - {expanded && ( -
- {visibleDevices.map(device => { - const deviceName = device.name || device.device_id - const color = getSidebarDeviceColor( - getSidebarDeviceColorKey(device.name, device.device_id) - ) - const selected = selectedDeviceId === device.device_id - const deviceState: SidebarDeviceState = { - deviceId: device.device_id, - device, - status: device.status, - } - - return ( - - ) - })} -
- )} - - ) -} - function getSidebarDeviceStatusLabel( t: ReturnType['t'], status: SidebarDeviceStatus @@ -601,7 +479,7 @@ function formatSidebarTemplate(template: string, values: Record) ) } -function SidebarDeviceStatusIndicator({ +function ProjectDeviceInlineStatus({ deviceState, testId, className, @@ -610,27 +488,29 @@ function SidebarDeviceStatusIndicator({ testId: string className?: string }) { - const { t } = useTranslation('common') - if (deviceState.status === 'online') return null - - const label = getSidebarDeviceStatusLabel(t, deviceState.status) - const deviceName = getSidebarDeviceName(deviceState) - const title = formatSidebarTemplate( - t('workbench.project_device_status_title', '{{device}} · {{status}}'), - { device: deviceName, status: label } - ) + const label = getDeviceNetworkLabel(deviceState.device) || deviceState.deviceId + const online = deviceState.status === 'online' return ( - {label} + {label} + ) } @@ -860,85 +740,6 @@ function RuntimeLocalTaskRow({ ) } -function RuntimeWorkspaceGroup({ - workspace, - currentRuntimeTask, - imNotificationSettings, - showDeviceMarker, - onOpenRuntimeLocalTask, - onArchiveRuntimeLocalTask, - onToggleRuntimeTaskNotification, -}: { - workspace: RuntimeDeviceWorkspace - currentRuntimeTask?: RuntimeTaskAddress | null - imNotificationSettings?: RuntimeIMNotificationSettingsResponse | null - showDeviceMarker: boolean - onOpenRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise | void - onArchiveRuntimeLocalTask?: (address: RuntimeTaskAddress) => Promise | void - onToggleRuntimeTaskNotification?: ( - address: RuntimeTaskAddress, - subscribed: boolean - ) => Promise | void -}) { - const { t } = useTranslation('common') - const sortedTasks = useMemo(() => sortRuntimeTasks(workspace.localTasks), [workspace.localTasks]) - const deviceState: SidebarDeviceState = { - deviceId: workspace.deviceId, - status: workspace.available - ? (workspace.deviceStatus as SidebarDeviceStatus) || 'online' - : 'unavailable', - } - const title = `${workspace.deviceName || workspace.deviceId} · ${workspace.workspacePath}` - - return ( -
-
- - {getRuntimeWorkspaceLabel(workspace)} - {!workspace.available && ( - - )} -
- {sortedTasks.length === 0 ? ( -
- {t('workbench.no_chats', '暂无会话')} -
- ) : ( - sortedTasks.map(task => ( - - )) - )} -
- ) -} - function ProjectItem({ project, expanded, @@ -946,6 +747,7 @@ function ProjectItem({ onSelectProject, devices, runtimeProjectWork, + runtimeOnlyProject, currentRuntimeTask, imNotificationSettings, showDeviceMarker, @@ -963,6 +765,7 @@ function ProjectItem({ onSelectProject: (projectId: number) => void devices: DeviceInfo[] runtimeProjectWork?: RuntimeProjectWork + runtimeOnlyProject: boolean currentRuntimeTask?: RuntimeTaskAddress | null imNotificationSettings?: RuntimeIMNotificationSettingsResponse | null showDeviceMarker: boolean @@ -989,7 +792,10 @@ function ProjectItem({ [runtimeTaskItems, runtimeTasksExpanded] ) const hasHiddenRuntimeTasks = hasHiddenRuntimeSidebarTaskItems(runtimeTaskItems) - const projectDeviceState = getSidebarDeviceState(getProjectDeviceId(project), devices) + const projectDeviceState = + getRuntimeProjectDeviceState(runtimeProjectWork, devices) ?? + getSidebarDeviceState(getProjectDeviceId(project), devices) + const showProjectDeviceStatus = shouldShowProjectDeviceStatus(projectDeviceState, devices) const canStartProjectChat = isSidebarDeviceOnline(projectDeviceState) const newProjectChatTitle = projectDeviceState && !canStartProjectChat @@ -1019,56 +825,58 @@ function ProjectItem({ {project.name} - {projectDeviceState && ( - )} -
- 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), - }, - ]} - /> - -
+ {!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), + }, + ]} + /> + +
+ )} {expanded && (
@@ -1163,10 +971,7 @@ export function DesktopSidebar({ const storageScope = getDesktopSidebarStorageScope(user) const projectsExpandedStorageKey = getDesktopSidebarStorageKey(storageScope, 'projectsExpanded') - const unmappedExpandedStorageKey = getDesktopSidebarStorageKey( - storageScope, - 'unmappedRuntimeExpanded' - ) + const chatsExpandedStorageKey = getDesktopSidebarStorageKey(storageScope, 'chatsExpanded') const expandedProjectIdsStorageKey = getDesktopSidebarStorageKey( storageScope, 'expandedProjectIds' @@ -1181,41 +986,32 @@ export function DesktopSidebar({ const [projectsExpanded, setProjectsExpanded] = useState(() => readStoredBoolean(projectsExpandedStorageKey, true) ) - const [unmappedExpanded, setUnmappedExpanded] = useState(() => - readStoredBoolean(unmappedExpandedStorageKey, true) + const [chatsExpanded, setChatsExpanded] = useState(() => + readStoredBoolean(chatsExpandedStorageKey, true) ) const [expandedProjectIds, setExpandedProjectIds] = useState>(() => readStoredNumberSet(expandedProjectIdsStorageKey) ) - const [devicesExpanded, setDevicesExpanded] = useState(false) - const [offlineDevicesExpanded, setOfflineDevicesExpanded] = useState(false) - const [selectedDeviceFilterId, setSelectedDeviceFilterId] = useState(null) - const activeDeviceFilterId = - selectedDeviceFilterId && devices.some(device => device.device_id === selectedDeviceFilterId) - ? selectedDeviceFilterId - : null - const visibleExpandedProjectIds = useMemo( - () => pruneProjectIdSet(expandedProjectIds, projects), - [expandedProjectIds, projects] - ) const filteredRuntimeProjects = useMemo(() => { const items = runtimeWork?.projects ?? [] - return items.map(item => filterRuntimeProjectWorkByDevice(item, activeDeviceFilterId)) - }, [runtimeWork?.projects, activeDeviceFilterId]) + return items + }, [runtimeWork?.projects]) + const sidebarProjects = useMemo(() => { + if (runtimeWork) { + return filteredRuntimeProjects.map(runtimeProjectToProject) + } + return projects + }, [filteredRuntimeProjects, projects, runtimeWork]) + const visibleExpandedProjectIds = useMemo( + () => pruneProjectIdSet(expandedProjectIds, sidebarProjects), + [expandedProjectIds, sidebarProjects] + ) const runtimeWorkByProjectId = useMemo(() => { return new Map(filteredRuntimeProjects.map(item => [item.project.id, item])) }, [filteredRuntimeProjects]) const unmappedWorkspaces = useMemo( - () => - filterRuntimeWorkspacesByDevice( - runtimeWork?.unmappedDeviceWorkspaces ?? [], - activeDeviceFilterId - ), - [runtimeWork?.unmappedDeviceWorkspaces, activeDeviceFilterId] - ) - const unmappedDirectoryWorkspaces = useMemo( - () => getRuntimeDirectoryWorkspaces(unmappedWorkspaces), - [unmappedWorkspaces] + () => runtimeWork?.unmappedDeviceWorkspaces ?? [], + [runtimeWork?.unmappedDeviceWorkspaces] ) const unmappedChatTaskItems = useMemo( () => getRuntimeChatSidebarTaskItems(unmappedWorkspaces), @@ -1232,14 +1028,14 @@ export function DesktopSidebar({ ) return projectWork?.project.id ?? null }, [currentRuntimeTask, runtimeWork?.projects]) - const selectedRuntimeInUnmapped = useMemo(() => { + const selectedRuntimeChatVisible = useMemo(() => { if (!currentRuntimeTask) return false - return unmappedDirectoryWorkspaces.some(workspace => - workspace.localTasks.some(task => isRuntimeTaskSelected(currentRuntimeTask, workspace, task)) + return unmappedChatTaskItems.some(({ workspace, task }) => + isRuntimeTaskSelected(currentRuntimeTask, workspace, task) ) - }, [currentRuntimeTask, unmappedDirectoryWorkspaces]) + }, [currentRuntimeTask, unmappedChatTaskItems]) const displayedProjectsExpanded = projectsExpanded || selectedRuntimeProjectId !== null - const displayedUnmappedExpanded = unmappedExpanded || selectedRuntimeInUnmapped + const displayedChatsExpanded = chatsExpanded || selectedRuntimeChatVisible const displayedExpandedProjectIds = useMemo(() => { if (selectedRuntimeProjectId === null) return visibleExpandedProjectIds if (visibleExpandedProjectIds.has(selectedRuntimeProjectId)) return visibleExpandedProjectIds @@ -1258,10 +1054,6 @@ export function DesktopSidebar({ }) } - const handleSelectDeviceFilter = (deviceId: string) => { - setSelectedDeviceFilterId(previous => (previous === deviceId ? null : deviceId)) - } - const openProjectCreateDialog = () => { setProjectCreateMode('scratch') setEditingProject(null) @@ -1281,8 +1073,8 @@ export function DesktopSidebar({ useEffect(() => { if (storageScopeRef.current !== storageScope) return - writeStoredBoolean(unmappedExpandedStorageKey, unmappedExpanded) - }, [unmappedExpanded, unmappedExpandedStorageKey, storageScope]) + writeStoredBoolean(chatsExpandedStorageKey, chatsExpanded) + }, [chatsExpanded, chatsExpandedStorageKey, storageScope]) useEffect(() => { if (storageScopeRef.current !== storageScope) return @@ -1294,13 +1086,13 @@ export function DesktopSidebar({ storageScopeRef.current = storageScope setProjectsExpanded(readStoredBoolean(projectsExpandedStorageKey, true)) - setUnmappedExpanded(readStoredBoolean(unmappedExpandedStorageKey, true)) + setChatsExpanded(readStoredBoolean(chatsExpandedStorageKey, true)) setExpandedProjectIds(readStoredNumberSet(expandedProjectIdsStorageKey)) }, [ + chatsExpandedStorageKey, expandedProjectIdsStorageKey, projectsExpandedStorageKey, storageScope, - unmappedExpandedStorageKey, ]) useEffect(() => { @@ -1333,9 +1125,9 @@ export function DesktopSidebar({ taskRow?.scrollIntoView({ block: 'nearest' }) }, [ currentRuntimeTask, + displayedChatsExpanded, displayedExpandedProjectIds, displayedProjectsExpanded, - displayedUnmappedExpanded, ]) return ( @@ -1369,22 +1161,11 @@ export function DesktopSidebar({ data-testid="sidebar-worklists-scroll" className="scrollbar-none mt-8 min-h-0 flex-1 overflow-y-auto" > - setDevicesExpanded(expanded => !expanded)} - onToggleOfflineExpanded={() => setOfflineDevicesExpanded(expanded => !expanded)} - onSelectDevice={handleSelectDeviceFilter} - onAddDevice={() => onOpenSettings({ autoOpenAddCloudDeviceDialog: true })} - /> -
0} + hasContent={sidebarProjects.length > 0} toggleTestId="projects-section-toggle" iconTestId="projects-section-chevron-right" onToggle={() => setProjectsExpanded(expanded => !expanded)} @@ -1404,16 +1185,17 @@ export function DesktopSidebar({ {displayedProjectsExpanded && (
- {projects.map(project => ( + {sidebarProjects.map(project => ( item.id === project.id)} currentRuntimeTask={currentRuntimeTask} imNotificationSettings={imNotificationSettings} - showDeviceMarker={devicesExpanded} + showDeviceMarker={false} onToggleProject={handleToggleProject} onSelectProject={onSelectProject} onStartNewProjectChat={onStartNewProjectChat} @@ -1429,57 +1211,36 @@ export function DesktopSidebar({ )}
- {unmappedChatTaskItems.length > 0 && ( -
-
- - {t('workbench.chats', '对话')} - -
-
- {unmappedChatTaskItems.map(({ workspace, task }) => ( - - ))} -
-
- )} - -
+
0} - toggleTestId="unmapped-runtime-section-toggle" - iconTestId="unmapped-runtime-section-chevron-right" - onToggle={() => setUnmappedExpanded(expanded => !expanded)} + title={t('workbench.chats', '对话')} + expanded={displayedChatsExpanded} + hasContent={unmappedChatTaskItems.length > 0} + toggleTestId="runtime-chat-section-toggle" + iconTestId="runtime-chat-section-chevron-right" + onToggle={() => setChatsExpanded(expanded => !expanded)} > - {displayedUnmappedExpanded && ( -
- {unmappedDirectoryWorkspaces.length === 0 ? ( -
- {t('workbench.no_unmapped_device_workspaces', '暂无未映射工作区')} + {displayedChatsExpanded && ( +
+ {unmappedChatTaskItems.length === 0 ? ( +
+ {t('workbench.no_chats', '暂无会话')}
) : ( - unmappedDirectoryWorkspaces.map(workspace => ( - ( + { ) }) - test('hides project device status when the project device is online', () => { + test('shows project device network status for non-local devices when multiple devices exist', () => { const onlineDevice = { id: 1, device_id: 'online-device', @@ -1537,6 +1537,8 @@ describe('DesktopWorkbenchLayout', () => { is_default: false, device_type: 'cloud' as const, bind_shell: 'claudecode', + client_ip: '127.0.0.1', + runtime_transfer_host: '192.0.2.10:9000', } render( @@ -1544,7 +1546,18 @@ describe('DesktopWorkbenchLayout', () => { {...baseProps} state={{ ...baseProps.state, - devices: [onlineDevice], + devices: [ + onlineDevice, + { + id: 2, + device_id: 'local-device', + name: 'Local Device', + status: 'online' as const, + is_default: true, + device_type: 'local' as const, + bind_shell: 'claudecode', + }, + ], projects: [ { id: 7, @@ -1563,10 +1576,99 @@ describe('DesktopWorkbenchLayout', () => { ) const projectRow = screen.getByTestId('project-row-7') - expect(within(projectRow).queryByTestId('project-device-status-7')).not.toBeInTheDocument() + expect(within(projectRow).getByTestId('project-device-status-7')).toHaveTextContent( + '192.0.2.10' + ) expect(within(projectRow).getByTestId('project-new-conversation-button')).not.toBeDisabled() }) + test('hides project device network status when only one device exists', () => { + render( + + ) + + const projectRow = screen.getByTestId('project-row-7') + expect(within(projectRow).queryByTestId('project-device-status-7')).not.toBeInTheDocument() + }) + + test('hides project device network status for local devices when multiple devices exist', () => { + render( + + ) + + const projectRow = screen.getByTestId('project-row-7') + expect(within(projectRow).queryByTestId('project-device-status-7')).not.toBeInTheDocument() + }) + test('keeps offline project conversations readable but locks the composer', async () => { const offlineDevice = { id: 1, @@ -1714,18 +1816,19 @@ describe('DesktopWorkbenchLayout', () => { test('selects a project while toggling an empty project task list', async () => { render() - expect(screen.queryByText('暂无会话')).not.toBeInTheDocument() + expect(screen.getByTestId('runtime-chat-empty')).toHaveTextContent('暂无会话') + expect(screen.queryByTestId('project-local-tasks-empty-1')).not.toBeInTheDocument() expect(screen.getByTestId('project-row-1')).not.toHaveClass('bg-white') await userEvent.click(screen.getByTestId('project-item-button')) expect(baseProps.onSelectProject).toHaveBeenNthCalledWith(1, 1) - expect(screen.getByText('暂无会话')).toBeInTheDocument() + 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.queryByText('暂无会话')).not.toBeInTheDocument() + expect(screen.queryByTestId('project-local-tasks-empty-1')).not.toBeInTheDocument() expect(baseProps.onSelectProject).toHaveBeenNthCalledWith(2, 1) expect(baseProps.onSelectProject).toHaveBeenCalledTimes(2) }) diff --git a/wework/src/components/layout/MobileDrawer.tsx b/wework/src/components/layout/MobileDrawer.tsx index c9c4506610..0e99c580b7 100644 --- a/wework/src/components/layout/MobileDrawer.tsx +++ b/wework/src/components/layout/MobileDrawer.tsx @@ -32,17 +32,14 @@ import type { } from '@/types/api' import { getRuntimeChatSidebarTaskItems, - getRuntimeDirectoryWorkspaces, getRuntimeTaskAddress, getRuntimeTaskTime, getRuntimeTaskWorkspaceTitle, getRuntimeSidebarTaskItems, - getRuntimeWorkspaceLabel, getVisibleRuntimeSidebarTaskItems, hasHiddenRuntimeSidebarTaskItems, isRuntimeTaskSelected, isRuntimeWorktreeTask, - sortRuntimeTasks, } from './runtimeTaskSidebarHelpers' const MOBILE_RUNNING_SPINNER_CLASS = 'h-3.5 w-3.5 shrink-0 animate-spin' @@ -160,10 +157,6 @@ export function MobileDrawer({ () => runtimeWork?.unmappedDeviceWorkspaces ?? [], [runtimeWork] ) - const unmappedDirectoryWorkspaces = useMemo( - () => getRuntimeDirectoryWorkspaces(unmappedWorkspaces), - [unmappedWorkspaces] - ) const unmappedChatTaskItems = useMemo( () => getRuntimeChatSidebarTaskItems(unmappedWorkspaces), [unmappedWorkspaces] @@ -350,13 +343,20 @@ export function MobileDrawer({ {t('workbench.new_project', '新建项目')}
- {unmappedChatTaskItems.length > 0 && ( -
-
- {t('workbench.chats', '对话')} -
-
- {unmappedChatTaskItems.map(({ workspace, task }) => { +
+
+ {t('workbench.chats', '对话')} +
+
+ {unmappedChatTaskItems.length === 0 ? ( +
+ {t('workbench.no_chats', '暂无会话')} +
+ ) : ( + unmappedChatTaskItems.map(({ workspace, task }) => { const selectedTask = isRuntimeTaskSelected( currentRuntimeTask, workspace, @@ -402,10 +402,10 @@ export function MobileDrawer({ )} ) - })} -
-
- )} + }) + )} +
+
{projects.map(project => { const runtimeProjectWork = runtimeWorkByProjectId.get(project.id) const workspaces = runtimeProjectWork?.deviceWorkspaces ?? [] @@ -594,78 +594,6 @@ export function MobileDrawer({ })}
- -
-
-

- {t('workbench.unmapped_device_workspaces', '未映射工作区')} -

-
-
- {unmappedDirectoryWorkspaces.length === 0 ? ( -
- {t('workbench.no_unmapped_device_workspaces', '暂无未映射工作区')} -
- ) : ( - unmappedDirectoryWorkspaces.map(workspace => ( -
-
- - {getRuntimeWorkspaceLabel(workspace)} - -
- {sortRuntimeTasks(workspace.localTasks).map(task => { - const selectedTask = isRuntimeTaskSelected( - currentRuntimeTask, - workspace, - task - ) - const disabled = !workspace.available || !onOpenRuntimeLocalTask - return ( - - ) - })} -
- )) - )} -
-
diff --git a/wework/src/components/layout/MobileWorkbenchLayout.test.tsx b/wework/src/components/layout/MobileWorkbenchLayout.test.tsx index 5e1c92ffb9..4e7673256a 100644 --- a/wework/src/components/layout/MobileWorkbenchLayout.test.tsx +++ b/wework/src/components/layout/MobileWorkbenchLayout.test.tsx @@ -835,6 +835,9 @@ describe('MobileWorkbenchLayout', () => { ) await userEvent.click(screen.getByTestId('open-mobile-drawer-button')) + expect(screen.getByTestId('mobile-runtime-chat-section')).toHaveTextContent('对话') + expect(screen.getByTestId('mobile-runtime-chat-empty')).toHaveTextContent('暂无会话') + expect(screen.queryByText('未映射工作区')).not.toBeInTheDocument() await userEvent.click(screen.getByText('github_wegent')) expect(screen.queryByText('Local Mac · Wegent local')).not.toBeInTheDocument() @@ -897,7 +900,7 @@ describe('MobileWorkbenchLayout', () => { expect(runningStatus.querySelector('svg')).not.toBeNull() }) - test('renders unmapped chat runtime tasks as conversations in the mobile drawer', async () => { + test('renders chat runtime tasks as conversations in the mobile drawer', async () => { const onOpenRuntimeLocalTask = vi.fn() const chatPath = '/Users/alice/.wecode/wegent-executor/workspace/chats/2026-06-20/hi-1' @@ -939,8 +942,9 @@ describe('MobileWorkbenchLayout', () => { await userEvent.click(screen.getByTestId('open-mobile-drawer-button')) - expect(screen.getByText('对话')).toBeInTheDocument() + expect(screen.getByTestId('mobile-runtime-chat-section')).toHaveTextContent('对话') expect(screen.queryByText(`Local Mac ${chatPath}`)).not.toBeInTheDocument() + expect(screen.queryByText('未映射工作区')).not.toBeInTheDocument() await userEvent.click(screen.getByTestId('mobile-chat-runtime-task-button')) expect(onOpenRuntimeLocalTask).toHaveBeenCalledWith({ diff --git a/wework/src/components/layout/runtimeTaskSidebarHelpers.ts b/wework/src/components/layout/runtimeTaskSidebarHelpers.ts index a70a2920b8..4170c0bbff 100644 --- a/wework/src/components/layout/runtimeTaskSidebarHelpers.ts +++ b/wework/src/components/layout/runtimeTaskSidebarHelpers.ts @@ -41,10 +41,6 @@ export function getRuntimeChatSidebarTaskItems( return getRuntimeSidebarTaskItems(workspaces.filter(isRuntimeChatWorkspace)) } -export function getRuntimeDirectoryWorkspaces(workspaces: RuntimeDeviceWorkspace[] = []) { - return workspaces.filter(workspace => !isRuntimeChatWorkspace(workspace)) -} - export function sortRuntimeTaskItems(items: RuntimeSidebarTaskItem[]) { return [...items].sort((left, right) => { const leftTime = new Date(getRuntimeTaskTime(left.task) || 0).getTime() @@ -67,11 +63,6 @@ export function hasHiddenRuntimeSidebarTaskItems(items: RuntimeSidebarTaskItem[] return items.length > RUNTIME_PROJECT_TASK_PREVIEW_LIMIT } -export function getRuntimeWorkspaceLabel(workspace: RuntimeDeviceWorkspace) { - const deviceLabel = workspace.deviceName || workspace.deviceId - return `${deviceLabel} ${workspace.workspacePath}` -} - export function getRuntimeTaskWorkspaceTitle(workspace: RuntimeDeviceWorkspace) { const deviceLabel = workspace.deviceName || workspace.deviceId return `${deviceLabel} ${workspace.workspacePath}` diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index fa947db624..2050c3bea4 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -57,7 +57,9 @@ function createProject(overrides: Partial = {}): ProjectWithTa } } -function createRuntimeWork(overrides: Partial = {}): RuntimeWorkListResponse { +function createRuntimeWork( + overrides: Partial = {} +): RuntimeWorkListResponse { return { projects: [ { @@ -225,7 +227,7 @@ function ProjectSendProbe() { {workbench.messages.map(message => `${message.role}:${message.content}`).join('|')}
{workbench.state.error ?? ''} -