From cdb283076a50f47771e430ff5ebd1570c3f97a93 Mon Sep 17 00:00:00 2001 From: yunpeng7 Date: Thu, 4 Jun 2026 20:06:51 +0800 Subject: [PATCH] feat(wework): support local devices in projects --- .../project_device_session_service.py | 24 ++ .../test_project_device_session_service.py | 44 +++ .../ai-devices/local-device-support.md | 29 +- .../ai-devices/local-device-support.md | 29 +- wework/src/App.plugins.test.tsx | 39 +-- wework/src/api/devices.test.ts | 2 + wework/src/api/devices.ts | 6 + wework/src/components/common/ActionMenu.tsx | 8 +- .../src/components/layout/DesktopSidebar.tsx | 36 +-- .../layout/DesktopWorkbenchLayout.test.tsx | 35 +- .../layout/DesktopWorkbenchLayout.tsx | 25 +- .../layout/DesktopWorkbenchMain.tsx | 6 +- .../layout/MobileWorkbenchLayout.tsx | 26 +- .../workspace-panels/BottomWorkspacePanel.tsx | 21 +- .../workspace-panels/RightWorkspacePanel.tsx | 21 +- .../WorkspacePanelCards.test.tsx | 58 +++- .../workspace-panels/WorkspacePanelCards.tsx | 158 +++++---- .../projects/ProjectCreateDialog.tsx | 23 +- .../settings/AddCloudDeviceDialog.tsx | 16 +- .../settings/ConnectionsSettingsPage.test.tsx | 110 ++++++- .../settings/ConnectionsSettingsPage.tsx | 305 +++++++++++------- .../workbench/WorkbenchProvider.test.tsx | 96 +++++- .../features/workbench/WorkbenchProvider.tsx | 61 +++- wework/src/i18n/locales/en/common.json | 6 + wework/src/i18n/locales/zh-CN/common.json | 6 + wework/src/lib/device-capabilities.ts | 31 ++ wework/src/lib/navigation.ts | 47 ++- 27 files changed, 968 insertions(+), 300 deletions(-) create mode 100644 wework/src/lib/device-capabilities.ts diff --git a/backend/app/services/project_device_session_service.py b/backend/app/services/project_device_session_service.py index 314a435830..33b5b8411b 100644 --- a/backend/app/services/project_device_session_service.py +++ b/backend/app/services/project_device_session_service.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from app.models.project import Project +from app.schemas.device import DeviceType from app.schemas.project import ( ProjectConfig, ProjectDeviceSessionResponse, @@ -21,6 +22,7 @@ DeviceSessionNotFoundError, local_device_session_service, ) +from app.services.device_service import device_service ProjectSessionType = Literal["terminal", "code_server"] @@ -50,6 +52,7 @@ async def start_project_device_session( project_config = _parse_project_config(project.config) device_id = _get_bound_device_id(project_config) + _ensure_device_supports_project_sessions(db, user_id, device_id) path, create_if_missing = _get_project_path( project_config, project.config, project_id ) @@ -126,6 +129,27 @@ def _get_bound_device_id(config: ProjectConfig) -> str: return config.execution.deviceId +def _ensure_device_supports_project_sessions( + db: Session, + user_id: int, + device_id: str, +) -> None: + """Reject project terminal and code-server sessions for local devices.""" + + device_kind = device_service.get_device_by_device_id(db, user_id, device_id) + if not device_kind: + return + + spec = getattr(device_kind, "json", None) + spec = spec.get("spec", {}) if isinstance(spec, dict) else {} + device_type = spec.get("deviceType") + if device_type == DeviceType.LOCAL.value: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Local devices do not support terminal or code-server sessions", + ) + + def _get_project_path( config: ProjectConfig, raw_config: object, diff --git a/backend/tests/services/test_project_device_session_service.py b/backend/tests/services/test_project_device_session_service.py index da29071a03..8d8f4cf53d 100644 --- a/backend/tests/services/test_project_device_session_service.py +++ b/backend/tests/services/test_project_device_session_service.py @@ -235,6 +235,50 @@ async def test_start_project_device_session_rejects_project_without_bound_device assert "local device" in exc_info.value.detail.lower() +@pytest.mark.asyncio +async def test_start_project_device_session_rejects_local_device_type(monkeypatch): + """Project terminal and code-server sessions are cloud-device-only tools.""" + from fastapi import HTTPException + + from app.services import project_device_session_service as service + + project = SimpleNamespace( + id=123, + user_id=7, + is_active=True, + config=_project_config(path="/workspace/project", device_id="local-device"), + ) + device_kind = SimpleNamespace(json={"spec": {"deviceType": "local"}}) + query = SimpleNamespace(filter=lambda *args: SimpleNamespace(first=lambda: project)) + db = SimpleNamespace(query=lambda model: query) + execute_mock = AsyncMock() + monkeypatch.setattr( + service.local_device_session_service, + "start_session", + execute_mock, + ) + monkeypatch.setattr( + service, + "device_service", + SimpleNamespace( + get_device_by_device_id=lambda db, user_id, device_id: device_kind + ), + raising=False, + ) + + with pytest.raises(HTTPException) as exc_info: + await service.start_project_device_session( + db=db, + user_id=7, + project_id=123, + session_type="terminal", + ) + + assert exc_info.value.status_code == 400 + assert "local devices do not support" in exc_info.value.detail.lower() + execute_mock.assert_not_awaited() + + @pytest.mark.asyncio async def test_local_device_session_service_calls_device_start_session(monkeypatch): """Device session service should send a start_session RPC to the online device.""" diff --git a/docs/en/user-guide/ai-devices/local-device-support.md b/docs/en/user-guide/ai-devices/local-device-support.md index 34fbc90ba5..1c7b8284f7 100644 --- a/docs/en/user-guide/ai-devices/local-device-support.md +++ b/docs/en/user-guide/ai-devices/local-device-support.md @@ -135,12 +135,12 @@ docker run -d --platform linux/amd64 \ wegent-device:linux-amd64 ``` -By default, the device image only starts `wegent-executor` and the interactive session gateway. Backend resolves the project-bound local device and project path from the project ID, then calls: +By default, the device image only starts `wegent-executor` and the interactive session gateway. Note that Wework currently exposes project connection tools only for cloud devices. Local devices can be bound to projects and execute AI tasks, but they do not support the project toolbar Terminal, IDE/code-server, or Desktop VNC/VPN entries. - `POST /api/projects/{project_id}/terminal`: starts a writable ttyd in the project path and returns a short-token URL. - `POST /api/projects/{project_id}/code-server`: returns a short-token code-server URL. The code-server process inside the device image runs with a fixed password, and the session gateway logs in server-side so the browser does not see the code-server login page or password. -Both entrypoints are exposed under the `/s/{session_id}/` path on `DEVICE_PUBLIC_BASE_URL`. Each terminal or code-server session has an isolated path, so a user can open multiple projects at the same time or open multiple terminal/code-server sessions for one project. Terminal sessions are created dynamically on the device and the matching ttyd process is cleaned up when the browser disconnects. Code-server is a persistent in-container process, and the gateway opens the requested project path through it. To keep the legacy fixed `8080` code-server and `7681` ttyd entrypoints, add `-e START_DEVICE_UI=1` and map those ports at container runtime. +These project session APIs are for cloud-device project connections. If the project is bound to a local device, Backend rejects terminal and code-server session startup. Cloud-device URLs include a short-lived session token and are exposed through the device-side session gateway. Each terminal or code-server session has an isolated path, so a user can open multiple projects at the same time or open multiple terminal/code-server sessions for one project. Terminal sessions are created dynamically on the device and the matching ttyd process is cleaned up when the browser disconnects. Code-server is a persistent in-container process, and the gateway opens the requested project path through it. To keep the legacy fixed `8080` code-server and `7681` ttyd entrypoints, add `-e START_DEVICE_UI=1` and map those ports at container runtime. When a project configures `workspace.localPath` or `workspace.checkoutPath`, the device creates that directory before starting terminal or code-server. @@ -241,6 +241,21 @@ You can dynamically choose execution location: Simply change the device selection before sending each message. +### Using Local Devices in Projects + +When creating a project, you can select an online or busy ClaudeCode local device. After the project is created, AI tasks execute on that local device and use the project's configured local path or checkout path. + +Local devices do not support cloud connection capabilities in the project toolbar: + +| Feature | Local Device Support | +|---------|----------------------| +| **Terminal** | Not supported | +| **IDE/code-server** | Not supported | +| **Desktop VNC/VPN** | Not supported | +| **CPU/MEM/Disk monitoring** | Not supported | + +When a project is bound to a local device, the workspace toolbar hides Terminal, IDE, and Desktop entries and shows a local-device capability notice. Choose a cloud device when you need those connection and monitoring capabilities. + ### Setting Default Device 1. Open device list in the selector @@ -259,11 +274,13 @@ Access your devices through: 2. **Settings Page**: Go to **Settings** → **Connections** to view connectable devices 3. **API**: `GET /devices` for programmatic access -### Managing Cloud Devices +### Managing Connection Devices + +The **Settings** → **Connections** page lists ClaudeCode devices that the current account can connect to, including cloud devices and local devices. It only shows devices with `bind_shell=claudecode`, grouped by cloud devices and local devices. -The **Settings** → **Connections** page lists Claude Code cloud devices that the current account can connect to. It only shows devices with `device_type=cloud` and `bind_shell=claudecode`, and displays online status, executor version, CPU, memory, and disk usage. +Cloud devices display online status, executor version, CPU, memory, and disk usage. When no cloud device exists, click **Add** to create one. After the create request returns, the page keeps a "cloud device creating" notice visible. Initialization usually takes 2-3 minutes, and the device appears in the list automatically when it comes online. -When no cloud device exists, click **Add** to create one. After the create request returns, the page keeps a "cloud device creating" notice visible. Initialization usually takes 2-3 minutes, and the device appears in the list automatically when it comes online. +Local devices display device name, online status, and executor version. They do not show CPU, MEM, or disk monitoring data or the resource monitoring note, and they do not show cloud-only actions such as Terminal, IDE, Desktop VNC/VPN, restart, or cloud-resource deletion. Offline local devices show a delete entry for removing the device registration. If the device reconnects, it automatically registers again. Online cloud devices can open interactive sessions directly: @@ -291,7 +308,7 @@ Each device shows: | **Name** | Device hostname (e.g., "Darwin - MacBook-Pro.local") | | **Status** | Online/Offline indicator | | **Version** | Executor version, when available | -| **Resource Usage** | CPU, memory, and disk usage, when reported by the device | +| **Resource Usage** | CPU, memory, and disk usage for cloud devices only | | **Slots** | Concurrent task capacity (X/5) | | **Default** | Star indicator if set as default | diff --git a/docs/zh/user-guide/ai-devices/local-device-support.md b/docs/zh/user-guide/ai-devices/local-device-support.md index 1e03348345..9853f184bd 100644 --- a/docs/zh/user-guide/ai-devices/local-device-support.md +++ b/docs/zh/user-guide/ai-devices/local-device-support.md @@ -137,12 +137,12 @@ docker run -d --platform linux/amd64 \ wegent-device:linux-amd64 ``` -设备镜像默认只启动 `wegent-executor` 和交互式 session gateway。Backend 根据项目 ID 找到项目绑定的本地设备与项目路径后,可以调用: +设备镜像默认只启动 `wegent-executor` 和交互式 session gateway。需要注意的是,Wework 当前只对云设备开放项目连接工具;本地设备可以绑定到项目并执行 AI 任务,但不支持项目工具栏中的终端、IDE/code-server、桌面 VNC/VPN 入口。 - `POST /api/projects/{project_id}/terminal`:在项目路径中启动可写 ttyd,返回带短期 token 的访问 URL。 - `POST /api/projects/{project_id}/code-server`:返回带短期 token 的 code-server 访问 URL。设备镜像内的 code-server 使用固定密码运行,session gateway 会在服务端自动登录,浏览器不会看到 code-server 登录页或固定密码。 -两个入口都通过 `DEVICE_PUBLIC_BASE_URL` 下的 `/s/{session_id}/` 路径对外暴露;每个 terminal 或 code-server session 都有独立路径,因此同一用户可以同时打开多个项目,或在同一项目中打开多个 terminal/code-server。terminal 会话由设备侧动态创建,浏览器连接关闭后会销毁对应 ttyd 进程;code-server 是容器内持久进程,通过 gateway 按项目路径打开目录。若需要保留旧的固定 `8080` code-server 和 `7681` ttyd 入口,可在运行容器时添加 `-e START_DEVICE_UI=1` 并额外映射对应端口。 +上述项目会话接口用于云设备项目连接;如果项目绑定的是本地设备,Backend 会拒绝启动 terminal 或 code-server 会话。云设备返回的访问地址带有短期 session token,并通过设备侧 session gateway 暴露。每个 terminal 或 code-server session 都有独立路径,因此同一用户可以同时打开多个项目,或在同一项目中打开多个 terminal/code-server。terminal 会话由设备侧动态创建,浏览器连接关闭后会销毁对应 ttyd 进程;code-server 是容器内持久进程,通过 gateway 按项目路径打开目录。若需要保留旧的固定 `8080` code-server 和 `7681` ttyd 入口,可在运行容器时添加 `-e START_DEVICE_UI=1` 并额外映射对应端口。 如果项目配置了 `workspace.localPath` 或 `workspace.checkoutPath`,设备会在启动 terminal 或 code-server 前自动创建该目录。 @@ -243,6 +243,21 @@ EXECUTOR_MODE=local wegent-executor 只需在发送每条消息之前更改设备选择即可。 +### 项目中使用本地设备 + +创建项目时可以选择在线或繁忙的 ClaudeCode 本地设备。项目创建后,AI 任务会在该本地设备上执行,并使用项目配置中的本地路径或检出路径。 + +本地设备不支持项目工具栏中的云端连接能力: + +| 功能 | 本地设备支持 | +|------|--------------| +| **终端** | 不支持 | +| **IDE/code-server** | 不支持 | +| **桌面 VNC/VPN** | 不支持 | +| **CPU/MEM/磁盘监控** | 不支持 | + +如果项目绑定本地设备,工作区工具栏会隐藏终端、IDE 和桌面入口,并显示本地设备能力限制提示。需要这些连接和监控能力时,请选择云设备创建项目。 + ### 设置默认设备 1. 在选择器中打开设备列表 @@ -261,11 +276,13 @@ EXECUTOR_MODE=local wegent-executor 2. **设置页**:进入 **设置** → **连接** 查看可连接设备 3. **API**:`GET /devices` 用于程序化访问 -### 管理云设备 +### 管理连接页设备 + +**设置** → **连接** 页面会列出当前账号可连接的 ClaudeCode 设备,包括云设备和本地设备。页面仅展示 `bind_shell=claudecode` 的设备,并按云设备、本地设备分组。 -**设置** → **连接** 页面会列出当前账号可连接的 Claude Code 云设备。页面仅展示 `device_type=cloud` 且 `bind_shell=claudecode` 的设备,并显示在线状态、executor 版本、CPU、内存和磁盘使用率。 +云设备会显示在线状态、executor 版本、CPU、内存和磁盘使用率。当没有云设备时,点击 **添加** 可以创建一台新的云设备。创建请求返回后,页面会保留“云设备创建中”的提示;初始化通常需要 2-3 分钟,设备上线后会自动出现在列表中。 -当没有云设备时,点击 **添加** 可以创建一台新的云设备。创建请求返回后,页面会保留“云设备创建中”的提示;初始化通常需要 2-3 分钟,设备上线后会自动出现在列表中。 +本地设备会显示设备名称、在线状态和 executor 版本,但不会展示 CPU、MEM、磁盘监控数据和资源监控说明,也不会展示终端、IDE、桌面 VNC/VPN、重启或删除云资源等云设备专属操作。离线本地设备会显示删除入口,用于移除该设备的注册记录;如果设备重新连接,它会自动重新注册。 在线云设备支持直接打开交互式会话: @@ -293,7 +310,7 @@ EXECUTOR_MODE=local wegent-executor | **名称** | 设备主机名(如 "Darwin - MacBook-Pro.local") | | **状态** | 在线/离线指示器 | | **版本** | executor 版本(如适用) | -| **资源使用率** | CPU、内存、磁盘使用率(如设备上报) | +| **资源使用率** | CPU、内存、磁盘使用率(仅云设备) | | **槽位** | 并发任务容量(X/5) | | **默认** | 如果设为默认则显示星号 | diff --git a/wework/src/App.plugins.test.tsx b/wework/src/App.plugins.test.tsx index 6d6c10ae01..1b7574fc1c 100644 --- a/wework/src/App.plugins.test.tsx +++ b/wework/src/App.plugins.test.tsx @@ -497,11 +497,11 @@ describe('App plugins route', () => { await waitFor(() => expect(window.location.pathname).toBe('/plugins')) expect(screen.getByRole('tab', { name: '插件' })).toHaveAttribute( 'aria-selected', - 'false', + 'true', ) expect(screen.getByRole('tab', { name: '技能' })).toHaveAttribute( 'aria-selected', - 'true', + 'false', ) expect(screen.getByRole('tab', { name: 'MCP' })).toBeInTheDocument() expect(screen.getByText('让 Wework 按你的方式工作')).toBeInTheDocument() @@ -512,7 +512,8 @@ describe('App plugins route', () => { render() - expect(screen.getByText('正在加载技能')).toBeInTheDocument() + expect(await screen.findByText('暂无已安装插件')).toBeInTheDocument() + await userEvent.click(screen.getByRole('tab', { name: '技能' })) expect(await screen.findByText('wehot')).toBeInTheDocument() expect(screen.queryByText('找不到技能')).not.toBeInTheDocument() expect(fetch).toHaveBeenCalledWith( @@ -526,7 +527,7 @@ describe('App plugins route', () => { render() - expect(await screen.findByText('wehot')).toBeInTheDocument() + expect(await screen.findByText('暂无已安装插件')).toBeInTheDocument() await userEvent.click(screen.getByTestId('collapse-sidebar-button')) expect(screen.queryByTestId('plugins-button')).not.toBeInTheDocument() @@ -541,7 +542,7 @@ describe('App plugins route', () => { render() - expect(await screen.findByText('Custom Docs MCP')).toBeInTheDocument() + expect(await screen.findByText('暂无已安装插件')).toBeInTheDocument() await userEvent.click(screen.getByTestId('collapse-sidebar-button')) expect(screen.queryByTestId('plugins-button')).not.toBeInTheDocument() @@ -563,7 +564,7 @@ describe('App plugins route', () => { 'h-11', 'w-11', ) - expect(await screen.findByText('wehot')).toBeInTheDocument() + expect(await screen.findByText('暂无已安装插件')).toBeInTheDocument() }) test('closes mobile settings when opening plugins from the settings menu', async () => { @@ -572,7 +573,7 @@ describe('App plugins route', () => { render() - expect(await screen.findByText('wehot')).toBeInTheDocument() + expect(await screen.findByText('暂无已安装插件')).toBeInTheDocument() await userEvent.click(screen.getByTestId('open-mobile-drawer-button')) await userEvent.click(screen.getByTestId('mobile-settings-button')) @@ -586,7 +587,7 @@ describe('App plugins route', () => { ) expect(window.location.pathname).toBe('/plugins') expect(screen.getByTestId('open-mobile-drawer-button')).toBeInTheDocument() - expect(await screen.findByText('wehot')).toBeInTheDocument() + expect(await screen.findByText('暂无已安装插件')).toBeInTheDocument() }) test('uses the mobile shell for plugin management route at the shared mobile breakpoint', async () => { @@ -597,7 +598,7 @@ describe('App plugins route', () => { expect(screen.getByTestId('open-mobile-drawer-button')).toBeInTheDocument() expect(screen.queryByTestId('collapse-sidebar-button')).not.toBeInTheDocument() - expect(await screen.findByText('Custom Docs MCP')).toBeInTheDocument() + expect(await screen.findByText('暂无已安装插件')).toBeInTheDocument() }) test('switches to the MCP catalog from the plugins page', async () => { @@ -638,16 +639,14 @@ describe('App plugins route', () => { ) expect(screen.getByTestId('plugins-button')).toBeInTheDocument() expect(screen.getByText('管理')).toBeInTheDocument() - expect(screen.getByRole('tab', { name: 'MCP 1' })).toHaveAttribute( + expect(screen.getByRole('tab', { name: '插件 0' })).toHaveAttribute( 'aria-selected', 'true', ) - expect(screen.getByRole('tab', { name: '集成 1' })).toBeInTheDocument() - expect(await screen.findByText('Custom Docs MCP')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'MCP 1' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: '市场 1' })).toBeInTheDocument() + expect(await screen.findByText('暂无已安装插件')).toBeInTheDocument() expect(screen.queryByPlaceholderText('供应商 Token')).not.toBeInTheDocument() - expect( - screen.getByTestId('installed-mcp-toggle-7'), - ).toHaveAttribute('aria-checked', 'true') }) test('renders plugin management on direct /plugins/manage visit', async () => { @@ -659,10 +658,10 @@ describe('App plugins route', () => { 'bg-[rgb(var(--color-sidebar-active))]', ) expect(screen.getByPlaceholderText('搜索插件')).toBeInTheDocument() - expect(await screen.findByText('Custom Docs MCP')).toBeInTheDocument() + expect(await screen.findByText('暂无已安装插件')).toBeInTheDocument() await waitFor(() => expect(fetch).toHaveBeenCalledWith( - '/api/mcps/installed', + '/api/plugins/installed', expect.objectContaining({ method: 'GET' }), ), ) @@ -673,6 +672,7 @@ describe('App plugins route', () => { render() + await userEvent.click(await screen.findByRole('tab', { name: 'MCP 1' })) const switchKnob = ( await screen.findByTestId('installed-mcp-toggle-7') ).querySelector('span') @@ -685,6 +685,7 @@ describe('App plugins route', () => { render() + await userEvent.click(await screen.findByRole('tab', { name: 'MCP 1' })) expect(await screen.findByText('Custom Docs MCP')).toBeInTheDocument() await userEvent.click(screen.getByTestId('installed-mcp-toggle-7')) @@ -715,6 +716,7 @@ describe('App plugins route', () => { ) await userEvent.click(screen.getByTestId('custom-mcp-submit-button')) + await userEvent.click(await screen.findByRole('tab', { name: 'MCP 2' })) expect(await screen.findByText('Local Docs')).toBeInTheDocument() expect(fetch).toHaveBeenCalledWith( '/api/mcps/custom', @@ -740,8 +742,7 @@ describe('App plugins route', () => { render() - expect(await screen.findByText('Custom Docs MCP')).toBeInTheDocument() - await userEvent.click(await screen.findByRole('tab', { name: '集成 1' })) + await userEvent.click(await screen.findByRole('tab', { name: '市场 1' })) expect(screen.getByText('MCP Router')).toBeInTheDocument() await userEvent.type( screen.getByTestId('mcp-provider-token-mcp_router'), diff --git a/wework/src/api/devices.test.ts b/wework/src/api/devices.test.ts index 87e718dec2..474efe8c5f 100644 --- a/wework/src/api/devices.test.ts +++ b/wework/src/api/devices.test.ts @@ -13,8 +13,10 @@ describe('createDeviceApi', () => { await api.restartCloudDevice('device/1') await api.deleteCloudDevice('device/1') + await api.deleteDevice('device/1') expect(client.post).toHaveBeenCalledWith('/cloud-devices/device%2F1/restart') expect(client.delete).toHaveBeenCalledWith('/cloud-devices/device%2F1') + expect(client.delete).toHaveBeenCalledWith('/devices/device%2F1') }) }) diff --git a/wework/src/api/devices.ts b/wework/src/api/devices.ts index 4a9bf9b6b6..335febd2d7 100644 --- a/wework/src/api/devices.ts +++ b/wework/src/api/devices.ts @@ -197,6 +197,12 @@ export function createDeviceApi(client: HttpClient) { ) }, + async deleteDevice(deviceId: string): Promise<{ message: string }> { + return client.delete<{ message: string }>( + `/devices/${encodeURIComponent(deviceId)}`, + ) + }, + getMetrics(deviceId: string): Promise { return client.post( `/cloud-devices/${encodeURIComponent(deviceId)}/metrics`, diff --git a/wework/src/components/common/ActionMenu.tsx b/wework/src/components/common/ActionMenu.tsx index cf7d40234c..424e5e1e18 100644 --- a/wework/src/components/common/ActionMenu.tsx +++ b/wework/src/components/common/ActionMenu.tsx @@ -125,7 +125,7 @@ export function ActionMenu({ onClick={handleTriggerClick} className={ triggerClassName ?? - 'flex h-7 w-7 items-center justify-center rounded-md text-[#606368] hover:bg-white/80 hover:text-[#2d2d2d]' + 'flex h-7 w-7 items-center justify-center rounded-md text-text-secondary hover:bg-background hover:text-text-primary' } aria-label={ariaLabel} aria-expanded={open} @@ -142,7 +142,7 @@ export function ActionMenu({ top: menuPosition?.top ?? 0, visibility: menuPosition ? 'visible' : 'hidden', }} - className="fixed z-popover min-w-[176px] rounded-lg border border-black/10 bg-[#2b2b2b] py-1.5 text-white shadow-xl" + className="fixed z-popover min-w-[176px] rounded-lg border border-border bg-background py-1.5 text-text-primary shadow-[0_12px_32px_rgba(15,23,42,0.14)]" > {items.map(item => ( -
+
{running ? (
{!expanded && projectRunning && ( @@ -373,7 +371,7 @@ function ProjectItem({ {expanded && (
{tasks.length === 0 ? ( -
+
{t('workbench.no_chats', '暂无会话')}
) : ( @@ -395,7 +393,7 @@ function ProjectItem({ type="button" data-testid={`project-task-limit-toggle-${project.id}`} onClick={() => onToggleTaskLimit(project.id)} - className="ml-5 h-8 rounded-md px-3 text-left text-xs font-medium text-[rgb(var(--color-sidebar-text-secondary))] hover:bg-[rgb(var(--color-sidebar-hover))] hover:text-[rgb(var(--color-sidebar-text-primary))]" + className="ml-10 h-8 rounded-md px-2 text-left text-xs font-medium text-[rgb(var(--color-sidebar-text-secondary))] hover:bg-[rgb(var(--color-sidebar-hover))] hover:text-[rgb(var(--color-sidebar-text-primary))]" > {showAllTasks ? t('workbench.show_less', '收起') @@ -428,13 +426,12 @@ function RecentTaskRow({
- -
+
{running ? ( - {}} - />
setSettingsMenuOpen(open => !open)} - className="flex h-9 shrink-0 items-center gap-2 rounded-md px-2 text-[13px] font-medium leading-[18px] text-[rgb(var(--color-sidebar-text-primary))] hover:bg-[rgb(var(--color-sidebar-hover))]" + className="flex h-9 w-full shrink-0 items-center gap-2 rounded-md px-2 text-left text-[13px] font-medium leading-[18px] text-[rgb(var(--color-sidebar-text-primary))] hover:bg-[rgb(var(--color-sidebar-hover))]" aria-expanded={settingsMenuOpen} > diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx index e43ecb63ec..e8cf598d5f 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx @@ -30,7 +30,8 @@ function createRect({ } vi.mock('@/config/runtime', () => ({ - getRuntimeConfig: () => ({ apiBaseUrl: '/api' }), + getRuntimeConfig: () => ({ appBasePath: '', apiBaseUrl: '/api' }), + stripAppBasePath: (path: string) => path, })) vi.mock('@/api/http', () => ({ @@ -53,6 +54,7 @@ describe('DesktopWorkbenchLayout', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() + window.history.pushState({}, '', '/') Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { @@ -215,6 +217,8 @@ describe('DesktopWorkbenchLayout', () => { expect(screen.getByText('github_wegent')).toBeInTheDocument() expect(screen.getByText('远程连接 Claude Code')).toBeInTheDocument() expect(screen.getByText('我们该做什么?')).toBeInTheDocument() + expect(screen.getByTestId('plugins-button')).toBeInTheDocument() + expect(screen.queryByTestId('automation-button')).not.toBeInTheDocument() }) test('collapses and expands project and chat sections from the sidebar headers', async () => { @@ -667,7 +671,7 @@ describe('DesktopWorkbenchLayout', () => { expect(selectedRow).toHaveTextContent('项目会话 7') expect(selectedRow).toHaveClass('bg-[rgb(var(--color-sidebar-active))]') - expect(Element.prototype.scrollIntoView).toHaveBeenCalled() + await waitFor(() => expect(Element.prototype.scrollIntoView).toHaveBeenCalled()) }) test('opens the settings menu from the sidebar', async () => { @@ -682,6 +686,15 @@ describe('DesktopWorkbenchLayout', () => { expect(screen.getByText('退出登录')).toBeInTheDocument() }) + test('opens settings page from the browser path on reload', () => { + window.history.pushState({}, '', '/settings') + + render() + + expect(screen.getByTestId('wework-settings-page')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: '我们该做什么?' })).not.toBeInTheDocument() + }) + test('closes the settings menu when clicking outside it', async () => { render() @@ -966,9 +979,22 @@ describe('DesktopWorkbenchLayout', () => { expect(baseProps.onStartNewProjectChat).toHaveBeenCalledWith(1) await userEvent.click(screen.getByTestId('project-menu-1')) + expect(screen.getByTestId('project-menu-1-menu')).toHaveClass( + 'bg-background', + 'text-text-primary', + 'border-border', + ) expect(screen.getByTestId('rename-project-1')).toHaveTextContent('重命名项目') + expect(screen.getByTestId('rename-project-1')).toHaveClass( + 'text-text-primary', + 'hover:bg-muted', + ) expect(screen.getByTestId('archive-project-chats-1')).toHaveTextContent('归档会话') expect(screen.getByTestId('remove-project-1')).toHaveTextContent('移除') + expect(screen.getByTestId('remove-project-1')).toHaveClass( + 'text-red-500', + 'hover:bg-red-500/10', + ) await userEvent.click(screen.getByTestId('rename-project-1')) await userEvent.clear(screen.getByTestId('rename-project-input')) @@ -1154,7 +1180,7 @@ describe('DesktopWorkbenchLayout', () => { 'overflow-y-auto', 'scrollbar-none', ) - expect(screen.getByTestId('settings-button')).toHaveClass('shrink-0') + expect(screen.getByTestId('settings-button')).toHaveClass('shrink-0', 'w-full') }) test('toggles an empty project chat list without selecting the project chat context', async () => { @@ -1274,8 +1300,7 @@ describe('DesktopWorkbenchLayout', () => { expect(screen.queryByText('控制其他设备')).not.toBeInTheDocument() expect(screen.queryByText('SSH')).not.toBeInTheDocument() expect(screen.getByTestId('settings-nav-connections')).toBeInTheDocument() - expect(screen.getByTestId('settings-nav-projects')).toBeInTheDocument() - expect(screen.getByText('项目')).toBeInTheDocument() + expect(screen.queryByTestId('settings-nav-projects')).not.toBeInTheDocument() expect(screen.queryByTestId('settings-nav-general')).not.toBeInTheDocument() expect(screen.queryByText('Personal Devices')).not.toBeInTheDocument() expect(screen.queryByText('Linux-Device-481b616e8e0b')).not.toBeInTheDocument() diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index 952b82fbb2..e407d844f1 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -14,6 +14,8 @@ import type { TaskListResponse, } from '@/types/api' import type { EnvironmentInfo } from '@/types/environment' +import { stripAppBasePath } from '@/config/runtime' +import { isSettingsRoute, navigateTo } from '@/lib/navigation' import { DesktopSidebar } from './DesktopSidebar' import { DesktopWorkbenchMain } from './DesktopWorkbenchMain' import { DesktopWindowControls } from './DesktopWindowControls' @@ -132,7 +134,9 @@ export function DesktopWorkbenchLayout({ }: DesktopWorkbenchLayoutProps) { const { sidebarCollapsed, setSidebarCollapsed } = useDesktopSidebarCollapsed() - const [settingsOpen, setSettingsOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(() => + isSettingsRoute(stripAppBasePath(window.location.pathname)) + ) const [environmentInfo, setEnvironmentInfo] = useState({ additions: '+0', deletions: '-0', @@ -170,6 +174,14 @@ export function DesktopWorkbenchLayout({ } }, [environmentProject, onLoadEnvironmentInfo]) + useEffect(() => { + const handlePopState = () => { + setSettingsOpen(isSettingsRoute(stripAppBasePath(window.location.pathname))) + } + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, []) + useEffect(() => { const nextCompletedIds = new Set( messages @@ -245,7 +257,10 @@ export function DesktopWorkbenchLayout({ onGetProjectWorkspaceRoot={onGetProjectWorkspaceRoot} onListDeviceDirectories={onListDeviceDirectories} onCreateDeviceDirectory={onCreateDeviceDirectory} - onOpenSettings={() => setSettingsOpen(true)} + onOpenSettings={() => { + setSettingsOpen(true) + navigateTo('/settings') + }} onLogout={onLogout} /> )} @@ -260,7 +275,10 @@ export function DesktopWorkbenchLayout({ {settingsOpen ? ( setSettingsOpen(false)} + onBack={() => { + setSettingsOpen(false) + navigateTo('/') + }} onListArchivedTasks={onListArchivedTasks} onUnarchiveTask={onUnarchiveTask} onDeleteTask={onDeleteTask} @@ -271,6 +289,7 @@ export function DesktopWorkbenchLayout({ isBootstrapping={state.isBootstrapping} currentTask={state.currentTask} currentProject={state.currentProject} + devices={state.devices} messages={messages} queuedMessages={queuedMessages} guidanceMessages={guidanceMessages} diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index 51e21d0b67..e01afc48bd 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -7,7 +7,7 @@ import type { } from '@/components/chat/ChatInput' import { ScrollableMessageArea } from '@/components/chat/ScrollableMessageArea' import { useTranslation } from '@/hooks/useTranslation' -import type { ProjectWithTasks, Task } from '@/types/api' +import type { DeviceInfo, ProjectWithTasks, Task } from '@/types/api' import type { EnvironmentInfo } from '@/types/environment' import type { GuidanceWorkbenchMessage, @@ -33,6 +33,7 @@ interface DesktopWorkbenchMainProps { isBootstrapping: boolean currentTask: Task | null currentProject: ProjectWithTasks | null + devices: DeviceInfo[] messages: WorkbenchMessage[] queuedMessages: QueuedWorkbenchMessage[] guidanceMessages: GuidanceWorkbenchMessage[] @@ -60,6 +61,7 @@ export function DesktopWorkbenchMain({ isBootstrapping, currentTask, currentProject, + devices, messages, queuedMessages, guidanceMessages, @@ -186,6 +188,7 @@ export function DesktopWorkbenchMain({ {bottomPanelOpen && ( setBottomPanelOpen(false)} /> )} @@ -205,6 +208,7 @@ export function DesktopWorkbenchMain({ {rightPanelOpen && ( setRightPanelOpen(false)} /> )} diff --git a/wework/src/components/layout/MobileWorkbenchLayout.tsx b/wework/src/components/layout/MobileWorkbenchLayout.tsx index 5bf02441c1..ae31621c27 100644 --- a/wework/src/components/layout/MobileWorkbenchLayout.tsx +++ b/wework/src/components/layout/MobileWorkbenchLayout.tsx @@ -1,5 +1,5 @@ import { Bot, Menu } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { ChatInput } from '@/components/chat/ChatInput' import type { ProjectChatControls, @@ -8,7 +8,9 @@ import type { import { ModelSelector } from '@/components/chat/composer/ModelSelector' import { ProjectWorkBar } from '@/components/chat/composer/ProjectWorkBar' import { MobileSettingsPage } from '@/components/settings/MobileSettingsPage' +import { stripAppBasePath } from '@/config/runtime' import { useTranslation } from '@/hooks/useTranslation' +import { isSettingsRoute, navigateTo } from '@/lib/navigation' import { ScrollableMessageArea } from '@/components/chat/ScrollableMessageArea' import type { ArchivedTaskListResponse, @@ -100,7 +102,9 @@ export function MobileWorkbenchLayout({ }: MobileWorkbenchLayoutProps) { const { t } = useTranslation('common') const [drawerOpen, setDrawerOpen] = useState(false) - const [settingsOpen, setSettingsOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(() => + isSettingsRoute(stripAppBasePath(window.location.pathname)) + ) const hasConversation = messages.length > 0 || state.currentTask const effectiveProjectChat = projectChat ?? { models: [], @@ -126,10 +130,21 @@ export function MobileWorkbenchLayout({ onSelectStandaloneDevice: () => {}, } + useEffect(() => { + const handlePopState = () => { + setSettingsOpen(isSettingsRoute(stripAppBasePath(window.location.pathname))) + } + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, []) + if (settingsOpen) { return ( setSettingsOpen(false)} + onBack={() => { + setSettingsOpen(false) + navigateTo('/') + }} onOpenPlugins={onOpenPlugins} /> ) @@ -311,7 +326,10 @@ export function MobileWorkbenchLayout({ onClose={() => setDrawerOpen(false)} onNewChat={onNewChat} onStartStandaloneChat={onStartStandaloneChat} - onOpenSettings={() => setSettingsOpen(true)} + onOpenSettings={() => { + setSettingsOpen(true) + navigateTo('/settings') + }} onSelectProject={onSelectProject} onOpenTask={onOpenTask} /> diff --git a/wework/src/components/layout/workspace-panels/BottomWorkspacePanel.tsx b/wework/src/components/layout/workspace-panels/BottomWorkspacePanel.tsx index d3684c4e20..4096c6ff5e 100644 --- a/wework/src/components/layout/workspace-panels/BottomWorkspacePanel.tsx +++ b/wework/src/components/layout/workspace-panels/BottomWorkspacePanel.tsx @@ -1,15 +1,20 @@ import { X } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import type { ProjectWithTasks } from '@/types/api' +import { useTranslation } from '@/hooks/useTranslation' +import type { DeviceInfo, ProjectWithTasks } from '@/types/api' import { useResizableBottomPanel } from './useResizableWorkspacePanel' import { WorkspacePanelCards } from './WorkspacePanelCards' interface BottomWorkspacePanelProps { currentProject: ProjectWithTasks | null + devices: DeviceInfo[] onRequestClose: () => void } -export function BottomWorkspacePanel({ currentProject, onRequestClose }: BottomWorkspacePanelProps) { +export function BottomWorkspacePanel({ + currentProject, + devices, + onRequestClose, +}: BottomWorkspacePanelProps) { const { t } = useTranslation('common') const { height, handleResizeStart } = useResizableBottomPanel() @@ -23,19 +28,23 @@ export function BottomWorkspacePanel({ currentProject, onRequestClose }: BottomW data-testid="bottom-workspace-resize-handle" className="absolute left-0 top-[-4px] z-20 h-3 w-full cursor-row-resize bg-transparent" onPointerDown={handleResizeStart} - aria-label={t('workbench.resize_bottom_workspace_panel', '调整底部栏高度')} + aria-label={t('workbench.resize_bottom_workspace_panel')} />
- +
) diff --git a/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx b/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx index c021f47c89..25b9f78c94 100644 --- a/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx +++ b/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx @@ -1,15 +1,20 @@ import { X } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import type { ProjectWithTasks } from '@/types/api' +import { useTranslation } from '@/hooks/useTranslation' +import type { DeviceInfo, ProjectWithTasks } from '@/types/api' import { useResizableRightPanel } from './useResizableWorkspacePanel' import { WorkspacePanelCards } from './WorkspacePanelCards' interface RightWorkspacePanelProps { currentProject: ProjectWithTasks | null + devices: DeviceInfo[] onRequestClose: () => void } -export function RightWorkspacePanel({ currentProject, onRequestClose }: RightWorkspacePanelProps) { +export function RightWorkspacePanel({ + currentProject, + devices, + onRequestClose, +}: RightWorkspacePanelProps) { const { t } = useTranslation('common') const { width, handleResizeStart } = useResizableRightPanel() @@ -23,19 +28,23 @@ export function RightWorkspacePanel({ currentProject, onRequestClose }: RightWor data-testid="right-workspace-resize-handle" className="absolute left-[-4px] top-0 z-20 h-full w-3 cursor-col-resize bg-transparent" onPointerDown={handleResizeStart} - aria-label={t('workbench.resize_right_workspace_panel', '调整右侧栏宽度')} + aria-label={t('workbench.resize_right_workspace_panel')} />
- +
) diff --git a/wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx b/wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx index d199607dd9..97319e37f1 100644 --- a/wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx +++ b/wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' import { createDeviceApi } from '@/api/devices' import { createProjectApi } from '@/api/projects' import { WorkspacePanelCards } from './WorkspacePanelCards' +import type { DeviceInfo } from '@/types/api' vi.mock('@/config/runtime', () => ({ getRuntimeConfig: () => ({ apiBaseUrl: '/api' }), @@ -42,6 +43,30 @@ const project = { tasks: [], } +const cloudDevices: DeviceInfo[] = [ + { + id: 1, + device_id: 'device-1', + name: 'Cloud Device', + status: 'online', + is_default: false, + device_type: 'cloud', + bind_shell: 'claudecode', + }, +] + +const localDevices: DeviceInfo[] = [ + { + id: 2, + device_id: 'device-1', + name: 'Local Device', + status: 'online', + is_default: false, + device_type: 'local', + bind_shell: 'claudecode', + }, +] + describe('WorkspacePanelCards', () => { beforeEach(() => { vi.clearAllMocks() @@ -76,7 +101,7 @@ describe('WorkspacePanelCards', () => { }) test('renders terminal, IDE, and desktop project tools', () => { - render() + render() expect(screen.queryByTestId('workspace-browser-card')).not.toBeInTheDocument() expect(screen.getByTestId('workspace-terminal-card')).toHaveTextContent('终端') @@ -86,7 +111,7 @@ describe('WorkspacePanelCards', () => { test('embeds the project terminal in the workspace panel', async () => { const api = createProjectApiMock() - render() + render() await userEvent.click(screen.getByTestId('workspace-terminal-card')) @@ -110,7 +135,7 @@ describe('WorkspacePanelCards', () => { test('creates and switches to a new project terminal tab', async () => { const api = createProjectApiMock() - render() + render() await userEvent.click(screen.getByTestId('workspace-terminal-card')) await waitFor(() => @@ -144,7 +169,13 @@ describe('WorkspacePanelCards', () => { test('opens the project IDE in a new page', async () => { const api = createProjectApiMock() const onRequestClose = vi.fn() - render() + render( + , + ) await userEvent.click(screen.getByTestId('workspace-ide-card')) @@ -168,7 +199,13 @@ describe('WorkspacePanelCards', () => { test('opens the project desktop using the cloud device VNC page', async () => { const onRequestClose = vi.fn() - render() + render( + , + ) await userEvent.click(screen.getByTestId('workspace-desktop-card')) @@ -188,6 +225,17 @@ describe('WorkspacePanelCards', () => { expect(onRequestClose).toHaveBeenCalledTimes(1) }) + test('does not show cloud-only project tools for local devices', () => { + render() + + expect(screen.queryByTestId('workspace-terminal-card')).not.toBeInTheDocument() + expect(screen.queryByTestId('workspace-ide-card')).not.toBeInTheDocument() + expect(screen.queryByTestId('workspace-desktop-card')).not.toBeInTheDocument() + expect(screen.getByTestId('workspace-local-device-limited-tools')).toHaveTextContent( + 'workbench.local_device_limited_tools_title', + ) + }) + test('marks terminal as unavailable when session probing fails', async () => { const api = createProjectApiMock() vi.mocked(api.startTerminalSession).mockRejectedValueOnce( diff --git a/wework/src/components/layout/workspace-panels/WorkspacePanelCards.tsx b/wework/src/components/layout/workspace-panels/WorkspacePanelCards.tsx index c3ae801c9f..26fc0520b4 100644 --- a/wework/src/components/layout/workspace-panels/WorkspacePanelCards.tsx +++ b/wework/src/components/layout/workspace-panels/WorkspacePanelCards.tsx @@ -5,11 +5,13 @@ import { createHttpClient } from '@/api/http' import { createProjectApi } from '@/api/projects' import { getRuntimeConfig } from '@/config/runtime' import { useTranslation } from '@/hooks/useTranslation' +import { supportsCloudSessions } from '@/lib/device-capabilities' import { buildVncPageUrl } from '@/lib/vnc' -import type { ProjectDeviceSessionResponse, ProjectWithTasks } from '@/types/api' +import type { DeviceInfo, ProjectDeviceSessionResponse, ProjectWithTasks } from '@/types/api' interface WorkspacePanelCardsProps { currentProject: ProjectWithTasks | null + devices?: DeviceInfo[] onRequestClose?: () => void } @@ -90,7 +92,11 @@ async function probeSessionUrl(url: string): Promise { } } -export function WorkspacePanelCards({ currentProject, onRequestClose }: WorkspacePanelCardsProps) { +export function WorkspacePanelCards({ + currentProject, + devices = [], + onRequestClose, +}: WorkspacePanelCardsProps) { const { t } = useTranslation('common') const [terminalSessions, setTerminalSessions] = useState([]) const [activeTerminalSessionId, setActiveTerminalSessionId] = useState(null) @@ -106,6 +112,11 @@ export function WorkspacePanelCards({ currentProject, onRequestClose }: Workspac message: null, }) const projectDeviceId = getProjectDeviceId(currentProject) + const projectDevice = projectDeviceId + ? devices.find(device => device.device_id === projectDeviceId) + : undefined + const hasExplicitLocalDevice = Boolean(projectDevice && !supportsCloudSessions(projectDevice)) + const cloudToolsAvailable = !hasExplicitLocalDevice const projectKey = currentProject ? `${currentProject.id}:${projectDeviceId ?? ''}` : '' const availableTools = toolAvailability.projectKey === projectKey @@ -311,71 +322,86 @@ export function WorkspacePanelCards({ currentProject, onRequestClose }: Workspac {error}

)} -
- - - -
+

+ {t('workbench.local_device_limited_tools_title')} +

+

+ {t('workbench.local_device_limited_tools_desc')} +

+
+ )} + {cloudToolsAvailable && ( +
+ + + +
+ )}
) } diff --git a/wework/src/components/projects/ProjectCreateDialog.tsx b/wework/src/components/projects/ProjectCreateDialog.tsx index 6dea2cddbf..4de6270841 100644 --- a/wework/src/components/projects/ProjectCreateDialog.tsx +++ b/wework/src/components/projects/ProjectCreateDialog.tsx @@ -3,6 +3,11 @@ import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { useEscapeKey } from '@/hooks/useEscapeKey' import { useTranslation } from '@/hooks/useTranslation' +import { + canUseForProjectCreation, + isCloudDevice, + isUsableDevice, +} from '@/lib/device-capabilities' import type { CreateProjectRequest, DeviceInfo, ProjectWithTasks } from '@/types/api' type ProjectCreateMode = 'scratch' | 'existing' @@ -45,18 +50,10 @@ function basename(path: string): string { return segments.at(-1) || 'project' } -function isUsableDevice(device: DeviceInfo): boolean { - return device.status === 'online' || device.status === 'busy' -} - -function isCloudDevice(device: DeviceInfo): boolean { - return device.device_type === 'cloud' -} - function sortDevicesForProjectCreation(devices: DeviceInfo[]): DeviceInfo[] { return [...devices].sort((left, right) => { - const leftUsable = isUsableDevice(left) ? 0 : 1 - const rightUsable = isUsableDevice(right) ? 0 : 1 + const leftUsable = canUseForProjectCreation(left) ? 0 : 1 + const rightUsable = canUseForProjectCreation(right) ? 0 : 1 if (leftUsable !== rightUsable) return leftUsable - rightUsable const leftCloud = isUsableDevice(left) && isCloudDevice(left) ? 0 : 1 @@ -74,11 +71,11 @@ function getDefaultDeviceId( const preferredDevice = preferredDeviceId ? devices.find(device => device.device_id === preferredDeviceId) : undefined - if (preferredDevice && isUsableDevice(preferredDevice)) { + if (preferredDevice && canUseForProjectCreation(preferredDevice)) { return preferredDevice.device_id } - return sortDevicesForProjectCreation(devices).find(isUsableDevice)?.device_id ?? '' + return sortDevicesForProjectCreation(devices).find(canUseForProjectCreation)?.device_id ?? '' } function getParentPath(path: string): string { @@ -287,7 +284,7 @@ function ProjectCreateDialogContent({ }, [currentPath, deviceId, mode, onListDeviceDirectories, t]) const selectedDevice = sortedDevices.find(device => device.device_id === deviceId) - const selectedDeviceUsable = Boolean(selectedDevice && isUsableDevice(selectedDevice)) + const selectedDeviceUsable = Boolean(selectedDevice && canUseForProjectCreation(selectedDevice)) const scratchPath = useMemo( () => `${normalizePath(projectRoot)}/${sanitizePathSegment(projectName)}`, [projectName, projectRoot], diff --git a/wework/src/components/settings/AddCloudDeviceDialog.tsx b/wework/src/components/settings/AddCloudDeviceDialog.tsx index 1f4f3e66cf..d70a6ff648 100644 --- a/wework/src/components/settings/AddCloudDeviceDialog.tsx +++ b/wework/src/components/settings/AddCloudDeviceDialog.tsx @@ -50,16 +50,16 @@ export function AddCloudDeviceDialog({ >
e.stopPropagation()} >
-
+
-

添加云设备

-

+

添加云设备

+

将创建一台新的云设备,设备初始化约需 2-3 分钟。

@@ -68,14 +68,14 @@ export function AddCloudDeviceDialog({ data-testid="add-cloud-device-close" onClick={onClose} disabled={loading} - className="-mr-1 -mt-1 shrink-0 rounded-md p-1 text-[#9aa0a6] hover:bg-[#f5f5f5] hover:text-[#3c4043] disabled:cursor-not-allowed disabled:opacity-50" + className="-mr-1 -mt-1 shrink-0 rounded-md p-1 text-text-muted hover:bg-muted hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50" >
{error && ( -
+
{error}
)} @@ -86,7 +86,7 @@ export function AddCloudDeviceDialog({ data-testid="add-cloud-device-cancel" onClick={onClose} disabled={loading} - className="h-8 rounded-md px-3 text-sm text-[#3c4043] hover:bg-[#f5f5f5] disabled:cursor-not-allowed disabled:opacity-50" + className="h-8 rounded-md px-3 text-sm text-text-secondary hover:bg-muted hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50" > 取消 @@ -95,7 +95,7 @@ export function AddCloudDeviceDialog({ data-testid="add-cloud-device-confirm" onClick={handleCreate} disabled={loading} - className="h-8 rounded-md bg-[#409eff] px-3 text-sm font-medium text-white hover:bg-[#2f8ae6] disabled:cursor-not-allowed disabled:opacity-50" + className="h-8 rounded-md bg-primary px-3 text-sm font-medium text-primary-contrast hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" > {loading ? '创建中...' : '确认创建'} diff --git a/wework/src/components/settings/ConnectionsSettingsPage.test.tsx b/wework/src/components/settings/ConnectionsSettingsPage.test.tsx index 1f3625d6a8..d6f266485e 100644 --- a/wework/src/components/settings/ConnectionsSettingsPage.test.tsx +++ b/wework/src/components/settings/ConnectionsSettingsPage.test.tsx @@ -7,7 +7,8 @@ import { AppearanceProvider } from '@/features/appearance' import type { DeviceInfo } from '@/types/devices' vi.mock('@/config/runtime', () => ({ - getRuntimeConfig: () => ({ apiBaseUrl: '/api' }), + getRuntimeConfig: () => ({ appBasePath: '', apiBaseUrl: '/api' }), + stripAppBasePath: (path: string) => path, })) vi.mock('@/api/http', () => ({ @@ -37,6 +38,18 @@ function cloudDevice(overrides: Partial = {}): DeviceInfo { } } +function localDevice(overrides: Partial = {}): DeviceInfo { + return cloudDevice({ + id: 2, + device_id: 'local-device', + name: 'Local Claude Device', + device_type: 'local', + bind_shell: 'claudecode', + cloud_config: undefined, + ...overrides, + }) +} + describe('ConnectionsSettingsPage', () => { const api = { getAllDevices: vi.fn(), @@ -46,6 +59,7 @@ describe('ConnectionsSettingsPage', () => { renameDevice: vi.fn(), restartCloudDevice: vi.fn(), deleteCloudDevice: vi.fn(), + deleteDevice: vi.fn(), getMetrics: vi.fn(), getMetricsHistory: vi.fn(), getVncConfig: vi.fn(), @@ -53,6 +67,7 @@ describe('ConnectionsSettingsPage', () => { beforeEach(() => { vi.clearAllMocks() + window.history.pushState({}, '', '/') api.getMetrics.mockResolvedValue({ cpu_usage: 42, memory_usage: 68, @@ -87,7 +102,8 @@ describe('ConnectionsSettingsPage', () => { await userEvent.click(await screen.findByTestId('connection-add-device-button')) const createDialog = screen.getByTestId('add-cloud-device-dialog') expect(createDialog.querySelector('.text-\\[\\#0d9488\\]')).toBeNull() - expect(screen.getByTestId('add-cloud-device-confirm')).toHaveClass('bg-[#409eff]') + expect(createDialog).toHaveClass('bg-popover') + expect(screen.getByTestId('add-cloud-device-confirm')).toHaveClass('bg-primary') await userEvent.click(screen.getByTestId('add-cloud-device-confirm')) await waitFor(() => expect(api.createCloudDevice).toHaveBeenCalledTimes(1)) @@ -112,6 +128,22 @@ describe('ConnectionsSettingsPage', () => { expect(screen.getByTestId('appearance-mode-system')).toBeInTheDocument() }) + test('opens appearance settings from the browser path on reload', () => { + api.getAllDevices.mockResolvedValue([]) + window.history.pushState({}, '', '/settings/appearance') + + render( + + + , + ) + + expect(screen.getByTestId('appearance-settings-page')).toBeInTheDocument() + expect(screen.getByTestId('settings-nav-appearance')).toHaveClass( + 'bg-[rgb(var(--color-sidebar-active))]', + ) + }) + test('keeps uncommon cloud device actions in a compact more menu with confirmation', async () => { api.getAllDevices.mockResolvedValue([cloudDevice()]) api.restartCloudDevice.mockResolvedValue({ message: 'restart sent' }) @@ -138,7 +170,8 @@ describe('ConnectionsSettingsPage', () => { const restartDialog = screen.getByTestId('confirm-restart-device-dialog') const restartConfirmButton = screen.getByTestId('confirm-restart-device-button') expect(restartDialog.querySelector('.text-\\[\\#0d9488\\]')).toBeNull() - expect(restartConfirmButton).toHaveClass('bg-[#2d2d2d]') + expect(restartDialog).toHaveClass('bg-popover') + expect(restartConfirmButton).toHaveClass('bg-text-primary', 'text-background') await userEvent.click(restartConfirmButton) await userEvent.click(moreButton) @@ -150,7 +183,7 @@ describe('ConnectionsSettingsPage', () => { expect(api.deleteCloudDevice).toHaveBeenCalledWith('device-1') }) - test('only lists cloud Claude Code devices', async () => { + test('lists local and cloud Claude Code devices while excluding unsupported shells', async () => { api.getAllDevices.mockResolvedValue([ cloudDevice({ device_id: 'cloud-claude', @@ -164,18 +197,79 @@ describe('ConnectionsSettingsPage', () => { device_type: 'cloud', bind_shell: 'openclaw', }), - cloudDevice({ + localDevice({ device_id: 'local-claude', name: 'Local Claude Device', - device_type: 'local', - bind_shell: 'claudecode', }), ]) render() expect(await screen.findByText('Cloud Claude Device')).toBeInTheDocument() + expect(screen.getByText('Local Claude Device')).toBeInTheDocument() expect(screen.queryByText('Cloud OpenClaw Device')).not.toBeInTheDocument() - expect(screen.queryByText('Local Claude Device')).not.toBeInTheDocument() + }) + + test('uses theme-aware surfaces for device cards and controls', async () => { + api.getAllDevices.mockResolvedValue([cloudDevice()]) + + render() + + const deviceCard = await screen.findByTestId('connection-device-device-1') + const terminalButton = screen.getByTestId('connection-terminal-button-device-1') + const moreButton = screen.getByTestId('connection-more-button-device-1') + + expect(deviceCard).toHaveClass('bg-surface', 'border-border') + expect(deviceCard).not.toHaveClass('bg-white') + expect(terminalButton).toHaveClass('bg-background', 'text-text-primary') + expect(moreButton).toHaveClass('bg-background', 'text-text-secondary') + }) + + test('hides cloud-only actions and metrics for local devices', async () => { + api.getAllDevices.mockResolvedValue([ + localDevice({ + device_id: 'local-claude', + name: 'Local Claude Device', + }), + ]) + + render() + + expect(await screen.findByTestId('connection-device-local-claude')).toBeInTheDocument() + expect(screen.getByText('Local Claude Device')).toBeInTheDocument() + expect(screen.queryByTestId('connection-terminal-button-local-claude')).not.toBeInTheDocument() + expect(screen.queryByTestId('connection-code-server-button-local-claude')).not.toBeInTheDocument() + expect(screen.queryByTestId('connection-vnc-button-local-claude')).not.toBeInTheDocument() + expect(screen.queryByTestId('connection-more-button-local-claude')).not.toBeInTheDocument() + expect(screen.queryByTestId('connection-delete-button-local-claude')).not.toBeInTheDocument() + expect(screen.queryByTestId('device-metrics')).not.toBeInTheDocument() + expect(screen.queryByTestId('connection-scale-wiki')).not.toBeInTheDocument() + expect(api.getMetrics).not.toHaveBeenCalled() + }) + + test('allows deleting offline local device registrations', async () => { + api.getAllDevices.mockResolvedValue([ + localDevice({ + device_id: 'offline-local', + name: 'Offline Local Device', + status: 'offline', + }), + ]) + api.deleteDevice.mockResolvedValue({ message: 'deleted' }) + + render() + + expect(await screen.findByTestId('connection-device-offline-local')).toBeInTheDocument() + expect(screen.queryByTestId('connection-more-button-offline-local')).not.toBeInTheDocument() + + await userEvent.click(screen.getByTestId('connection-delete-button-offline-local')) + expect(screen.getByTestId('confirm-delete-device-dialog')).toHaveTextContent('删除本地设备') + expect(screen.getByTestId('confirm-delete-device-dialog')).toHaveTextContent( + '本地设备注册记录', + ) + await userEvent.click(screen.getByTestId('confirm-delete-device-button')) + + await waitFor(() => expect(api.deleteDevice).toHaveBeenCalledWith('offline-local')) + expect(api.deleteCloudDevice).not.toHaveBeenCalled() }) }) diff --git a/wework/src/components/settings/ConnectionsSettingsPage.tsx b/wework/src/components/settings/ConnectionsSettingsPage.tsx index 014422a7e0..76c0312f8b 100644 --- a/wework/src/components/settings/ConnectionsSettingsPage.tsx +++ b/wework/src/components/settings/ConnectionsSettingsPage.tsx @@ -6,7 +6,6 @@ import { Check, Cloud, Code2, - Folder, Globe2, Loader2, Monitor, @@ -22,9 +21,17 @@ import type { ComponentType } from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { createDeviceApi } from '@/api/devices' import { createHttpClient } from '@/api/http' -import { getRuntimeConfig } from '@/config/runtime' +import { getRuntimeConfig, stripAppBasePath } from '@/config/runtime' import { useTranslation } from '@/hooks/useTranslation' +import { navigateTo } from '@/lib/navigation' import { buildVncPageUrl } from '@/lib/vnc' +import { + isClaudeCodeDevice, + isCloudDevice, + supportsCloudLifecycleActions, + supportsCloudSessions, + supportsDeviceMetrics, +} from '@/lib/device-capabilities' import type { ArchivedTask } from '@/types/api' import type { CloudDeviceMetricsResponse, DeviceInfo } from '@/types/devices' import { AppearanceSettingsPage } from '@/features/appearance/AppearanceSettingsPage' @@ -58,7 +65,6 @@ const settingsNavItems: SettingsNavItem[] = [ label: 'settings_nav_appearance', fallback: '外观', }, - { key: 'projects', icon: Folder, label: 'settings_nav_projects', fallback: '项目' }, { key: 'archived-chats', icon: Archive, @@ -70,19 +76,30 @@ const settingsNavItems: SettingsNavItem[] = [ const emptyArchivedTasks = async () => ({ items: [], total: 0 }) const noopArchivedAction = async () => undefined +function getSettingsNavFromPath(path: string): string { + const normalizedPath = stripAppBasePath(path) + const match = normalizedPath.match(/^\/settings\/([^/]+)$/) + if (!match) return 'connections' + return settingsNavItems.some(item => item.key === match[1]) + ? match[1] + : 'connections' +} + +function getSettingsNavPath(key: string): string { + return key === 'connections' ? '/settings' : `/settings/${key}` +} + function StatusPill({ status }: { status: DeviceInfo['status'] }) { const isOnline = status === 'online' return (