diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index cfb2f515975..ba9ed4508d7 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -126,7 +126,7 @@ Use these anchors when moving from the docs into the latest `main` code: | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | HTTP routes | The route catalog lives in `qwen-serve-protocol.md`; this daemon set only references it and explains implementation ownership. | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md), [`20`](./20-quickstart-operations.md) | | Event schema | `EVENT_SCHEMA_VERSION = 1`; 53 known event types; id-less subscriber synthetic frames; `_meta.serverTimestamp` stamped by `EventBus.publish()` (with `formatSseFrame()` fallback for synthetic frames). | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) | -| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 147 registered tags; 41 conditional tags. | [`11`](./11-capabilities-versioning.md) | +| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 149 registered tags; 43 conditional tags. | [`11`](./11-capabilities-versioning.md) | | Session shell | `POST /session/:id/shell` exists behind `--enable-session-shell`, bearer or trusted-loopback authority, and session-bound `X-Qwen-Client-Id`; capability tag is conditional. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md), [`20`](./20-quickstart-operations.md) | | Rate limiting | Optional per-tier HTTP rate limit is exposed by CLI flags/env and conditional capability tag. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md) | diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index d55b892c9e9..b5830e018d3 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -558,6 +558,8 @@ operator diagnostic snapshot documented below. | `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. | | `native_directory_picker` | the daemon host can open a native OS directory picker (`osascript` on macOS, PowerShell on Windows, `zenity` on a Linux host with a display). Headless hosts omit the tag so clients hide the Browse affordance instead of surfacing a guaranteed picker failure. | | `workspace_runtime` | every active workspace bridge provides an authoritative runtime lifecycle snapshot; mixed or legacy injected bridges omit the tag. | +| `workspace_local_open` | the daemon host can open a workspace directory in the host's OS file manager (`open` on macOS, `explorer.exe` on Windows, `xdg-open` on a Linux host with a display). Headless hosts omit the tag so clients hide the Open-locally affordance instead of surfacing a guaranteed launch failure. The opened path is always the resolved registered workspace cwd, via `POST /workspaces/:workspace/open`; the route accepts an optional JSON body `{ "target": "terminal" }` (absent/other = folder) and answers `{ kind: 'workspace-local-open', opened: true, target }` with `target` set to `folder` or `terminal`. | +| `workspace_local_terminal` | the daemon host can open a terminal window in a workspace directory (`open -a Terminal` on macOS, `wt.exe` with `cmd.exe` fallback on Windows, `gnome-terminal`/`konsole`/`xterm` on a Linux host with a display). Headless hosts omit the tag so clients hide the Open-in-terminal affordance instead of surfacing a guaranteed launch failure. Served by `POST /workspaces/:workspace/open` with body `{ "target": "terminal" }`. | | `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. | | `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. | | `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. | diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 047e0c82b2d..3c276f515aa 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -41,6 +41,10 @@ import { type ChatRecord, } from '@qwen-code/qwen-code-core'; import { isNativeDirectoryPickerAvailable } from '../../packages/cli/src/serve/native-directory-picker.js'; +import { + isLocalPathOpenAvailable, + isLocalTerminalAvailable, +} from '../../packages/cli/src/serve/local-path-open.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Match the rest of the integration suite: prefer the bundled CLI @@ -67,6 +71,10 @@ let client: DaemonClient; // assertion time minutes later diverged when the host's GUI session state // drifted mid-run (red macOS E2E runs after #9406, tracked in #10453). let nativeDirectoryPickerAtBoot = false; +// Same boot-time probe pinning as above, for the workspace_local_open tag. +let localPathOpenAtBoot = false; +// Same boot-time probe pinning as above, for the workspace_local_terminal tag. +let localTerminalOpenAtBoot = false; function writePersistedTranscript( sessionId: string, @@ -119,6 +127,8 @@ function chatRecord( beforeAll(async () => { homeDir = mkdtempSync(path.join(tmpdir(), 'qwen-serve-routes-home-')); nativeDirectoryPickerAtBoot = isNativeDirectoryPickerAvailable(); + localPathOpenAtBoot = isLocalPathOpenAvailable(); + localTerminalOpenAtBoot = isLocalTerminalAvailable(); daemon = spawn( process.execPath, [ @@ -377,6 +387,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_metadata', 'session_organization', 'session_export', + 'standalone_sessions_v1', 'session_transcript', 'session_transcript_pagination', 'mcp_guardrails', @@ -398,6 +409,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_permissions', 'workspace_voice', 'workspace_trust', + 'workspace_trust_hot_reload', 'workspace_init', 'workspace_github_setup', 'workspace_github_prs', @@ -423,11 +435,15 @@ describe('qwen serve — capabilities envelope', () => { 'channel_control', 'channel_management', 'workspace_channel_observed_contacts', + 'dynamic_workspace_registration', 'persistent_workspace_registration', 'workspace_display_name', + 'scratch_workspace_registration', 'workspace_runtime_removal', ...(nativeDirectoryPickerAtBoot ? ['native_directory_picker'] : []), 'workspace_runtime', + ...(localPathOpenAtBoot ? ['workspace_local_open'] : []), + ...(localTerminalOpenAtBoot ? ['workspace_local_terminal'] : []), 'workspace_qualified_rest_core', 'extension_management_v2', 'extension_state', @@ -439,6 +455,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_session_live_state', 'workspace_session_metadata', 'voice_transcribe', + 'web_terminal', ]); }); }); diff --git a/package-lock.json b/package-lock.json index 490392b0ebe..8ea68ca4bfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4048,9 +4048,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4508,9 +4505,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4539,9 +4533,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4703,9 +4694,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4723,9 +4711,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4743,9 +4728,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4763,9 +4745,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4783,9 +4762,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4803,9 +4779,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4823,9 +4796,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4843,9 +4813,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5069,9 +5036,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5086,9 +5050,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5103,9 +5064,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5120,9 +5078,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5137,9 +5092,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5154,9 +5106,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5171,9 +5120,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5188,9 +5134,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5519,6 +5462,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@qwen-code/qwen-live": { + "resolved": "packages/qwen-live", + "link": true + }, "node_modules/@qwen-code/sdk": { "resolved": "packages/sdk-typescript", "link": true @@ -7172,9 +7119,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7189,9 +7133,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7206,9 +7147,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7223,9 +7161,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7240,9 +7175,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7257,9 +7189,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7274,9 +7203,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7291,9 +7217,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7308,9 +7231,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7325,9 +7245,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7342,9 +7259,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7359,9 +7273,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7376,9 +7287,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -19254,6 +19162,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19275,6 +19184,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19296,6 +19206,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19317,6 +19228,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19338,6 +19250,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19359,6 +19272,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19380,6 +19294,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19401,6 +19316,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19422,6 +19338,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19443,6 +19360,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19464,6 +19382,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -31365,6 +31284,40 @@ "undici-types": "~6.21.0" } }, + "packages/qwen-live": { + "name": "@qwen-code/qwen-live", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@qwen-code/sdk": "file:../sdk-typescript", + "ansi-regex": "^6.2.2", + "proper-lockfile": "^4.1.2", + "ws": "^8.18.0" + }, + "bin": { + "qwen-live": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "@types/proper-lockfile": "^4.1.4", + "@types/ws": "^8.5.0", + "typescript": "^5.3.3", + "vitest": "^3.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "packages/qwen-live/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", "version": "0.1.8", @@ -35323,44 +35276,6 @@ "optional": true } } - }, - "node_modules/@qwen-code/qwen-live": { - "resolved": "packages/qwen-live", - "link": true - }, - "packages/qwen-live": { - "name": "@qwen-code/qwen-live", - "version": "0.1.0", - "license": "Apache-2.0", - "dependencies": { - "@qwen-code/sdk": "file:../sdk-typescript", - "ansi-regex": "^6.2.2", - "proper-lockfile": "^4.1.2", - "ws": "^8.18.0" - }, - "bin": { - "qwen-live": "dist/index.js" - }, - "devDependencies": { - "@types/node": "^22.13.10", - "@types/proper-lockfile": "^4.1.4", - "@types/ws": "^8.5.0", - "typescript": "^5.3.3", - "vitest": "^3.1.1" - }, - "engines": { - "node": ">=22" - } - }, - "packages/qwen-live/node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } } } } diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index ab5e4aa3ac3..827ba373764 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -380,6 +380,18 @@ export const SERVE_CAPABILITY_REGISTRY = { native_directory_picker: { since: 'v1' }, // Workspace-owned runtime lifecycle status and explicit on-demand startup. workspace_runtime: { since: 'v1' }, + // The daemon host can open a workspace directory in the host's OS file + // manager (Finder via `open` on macOS, Explorer via `explorer.exe` on + // Windows, xdg-open on a Linux host with a display). Headless hosts omit + // the tag so clients hide the Open-locally affordance instead of + // surfacing a guaranteed launch failure. + workspace_local_open: { since: 'v1' }, + // The daemon host can open a terminal window in a workspace directory + // (`open -a Terminal` on macOS, wt.exe/cmd.exe on Windows, a common + // terminal emulator on a Linux host with a display). Headless hosts omit + // the tag so clients hide the Open-in-terminal affordance instead of + // surfacing a guaranteed launch failure. + workspace_local_terminal: { since: 'v1' }, // Workspace-qualified core REST routes under `/workspaces/:workspace/...`. // Covers core file read/write/upload, status/permissions/trust/lifecycle/MCP/tool, // memory, workspace agent CRUD, and persisted session organization surfaces. @@ -528,6 +540,8 @@ export interface AdvertiseFeatureToggles { workspaceRuntimeRemovalAvailable?: boolean; nativeDirectoryPickerAvailable?: boolean; workspaceRuntimeAvailable?: boolean; + localPathOpenAvailable?: boolean; + localTerminalOpenAvailable?: boolean; /** * Whether the HTTP ACP surface is enabled (default on; opts out via * QWEN_SERVE_ACP_HTTP=0). Workspace-qualified ACP is only advertised when on. @@ -675,6 +689,14 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'workspace_runtime', (toggles) => toggles.workspaceRuntimeAvailable === true, ], + [ + 'workspace_local_open', + (toggles) => toggles.localPathOpenAvailable === true, + ], + [ + 'workspace_local_terminal', + (toggles) => toggles.localTerminalOpenAvailable === true, + ], [ 'workspace_qualified_acp', // The plural routes are pre-mounted for workspaces registered after app diff --git a/packages/cli/src/serve/local-path-open.test.ts b/packages/cli/src/serve/local-path-open.test.ts new file mode 100644 index 00000000000..7b27965705c --- /dev/null +++ b/packages/cli/src/serve/local-path-open.test.ts @@ -0,0 +1,639 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execFileAsyncMock, spawnMock } = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn(), + spawnMock: vi.fn(), +})); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + const execFile = Object.assign(() => {}, { + [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock, + }); + return { + ...actual, + execFile, + spawn: spawnMock, + default: { ...actual, execFile, spawn: spawnMock }, + }; +}); + +const { + openPathLocally, + openTerminalLocally, + isLocalPathOpenAvailable, + isLocalTerminalAvailable, + LocalPathOpenUnavailableError, +} = await import('./local-path-open.js'); + +const xdgOpenDir = mkdtempSync(join(tmpdir(), 'open-xdg-')); +writeFileSync(join(xdgOpenDir, 'xdg-open'), '#!/bin/sh\nexit 0\n'); +chmodSync(join(xdgOpenDir, 'xdg-open'), 0o755); +const emptyDir = mkdtempSync(join(tmpdir(), 'open-empty-')); +const xdgOpenDirectoryEntryDir = mkdtempSync(join(tmpdir(), 'open-subdir-')); +mkdirSync(join(xdgOpenDirectoryEntryDir, 'xdg-open')); +const gnomeTerminalDir = mkdtempSync(join(tmpdir(), 'open-gnome-')); +writeFileSync(join(gnomeTerminalDir, 'gnome-terminal'), '#!/bin/sh\nexit 0\n'); +chmodSync(join(gnomeTerminalDir, 'gnome-terminal'), 0o755); +const konsoleDir = mkdtempSync(join(tmpdir(), 'open-konsole-')); +writeFileSync(join(konsoleDir, 'konsole'), '#!/bin/sh\nexit 0\n'); +chmodSync(join(konsoleDir, 'konsole'), 0o755); +const xtermDir = mkdtempSync(join(tmpdir(), 'open-xterm-')); +writeFileSync(join(xtermDir, 'xterm'), '#!/bin/sh\nexit 0\n'); +chmodSync(join(xtermDir, 'xterm'), 0o755); +// win32 launchers refuse a missing directory before spawning, so their tests +// must open a real one. +const existingDir = mkdtempSync(join(tmpdir(), 'open-existing-')); +const missingDir = join(tmpdir(), 'open-win32-missing'); + +function setPlatform(platform: NodeJS.Platform) { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); +} + +function fakeChild(result: 'close' | 'error' | 'spawn'): { + once: (event: string, cb: (error?: Error) => void) => void; + kill: () => void; + unref: () => void; +} { + return { + once: (event: string, cb: (error?: Error) => void) => { + if (event === result) { + queueMicrotask(() => + cb(result === 'error' ? new Error('spawn ENOENT') : undefined), + ); + } + }, + kill: vi.fn(), + unref: vi.fn(), + }; +} + +beforeEach(() => { + execFileAsyncMock.mockReset(); + spawnMock.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('openPathLocally', () => { + it('opens the path with `open` on macOS', async () => { + setPlatform('darwin'); + execFileAsyncMock.mockResolvedValue({ stdout: '' }); + + await openPathLocally('/Users/me/code'); + + expect(execFileAsyncMock).toHaveBeenCalledWith('open', ['/Users/me/code'], { + timeout: 10_000, + }); + }); + + it('wraps an `open` failure on macOS', async () => { + setPlatform('darwin'); + execFileAsyncMock.mockRejectedValue(new Error('boom')); + + await expect(openPathLocally('/tmp')).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + }); + + it('spawns explorer.exe on Windows and ignores its exit code', async () => { + setPlatform('win32'); + spawnMock.mockReturnValue(fakeChild('close')); + + await openPathLocally(existingDir); + + expect(spawnMock).toHaveBeenCalledWith('explorer.exe', [existingDir], { + stdio: 'ignore', + }); + }); + + it('wraps a spawn-level explorer.exe failure on Windows', async () => { + setPlatform('win32'); + spawnMock.mockReturnValue(fakeChild('error')); + + await expect(openPathLocally(existingDir)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + }); + + it('rejects a deleted directory on Windows without spawning', async () => { + setPlatform('win32'); + + await expect(openPathLocally(missingDir)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + await expect(openTerminalLocally(missingDir)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('opens the path with xdg-open on Linux', async () => { + setPlatform('linux'); + execFileAsyncMock.mockResolvedValue({ stdout: '' }); + + await openPathLocally('/home/me/code'); + + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'xdg-open', + ['/home/me/code'], + { timeout: 10_000 }, + ); + }); + + it('wraps an xdg-open failure on Linux', async () => { + setPlatform('linux'); + execFileAsyncMock.mockRejectedValue(new Error('boom')); + + await expect(openPathLocally('/tmp')).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + }); + + it('throws unavailable on an unsupported platform', async () => { + setPlatform('aix'); + + await expect(openPathLocally('/tmp')).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(execFileAsyncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); + +describe('isLocalPathOpenAvailable', () => { + it('requires positive graphical-session evidence on macOS and Windows', () => { + setPlatform('darwin'); + expect( + isLocalPathOpenAvailable({}, { processUid: 0, consoleUid: 501 }), + ).toBe(false); + expect( + isLocalPathOpenAvailable({}, { processUid: 501, consoleUid: 501 }), + ).toBe(true); + expect( + isLocalPathOpenAvailable( + { SSH_CONNECTION: 'remote' }, + { processUid: 501, consoleUid: 501 }, + ), + ).toBe(false); + setPlatform('win32'); + expect(isLocalPathOpenAvailable({})).toBe(false); + expect(isLocalPathOpenAvailable({ SESSIONNAME: 'Console' })).toBe(true); + expect(isLocalPathOpenAvailable({ SESSIONNAME: 'Services' })).toBe(false); + }); + + it('is unavailable on unsupported platforms', () => { + setPlatform('aix'); + expect(isLocalPathOpenAvailable({ DISPLAY: ':0', PATH: xdgOpenDir })).toBe( + false, + ); + }); + + it('requires a display on Linux', () => { + setPlatform('linux'); + expect(isLocalPathOpenAvailable({ PATH: xdgOpenDir })).toBe(false); + }); + + it('requires an executable xdg-open on PATH on Linux', () => { + setPlatform('linux'); + expect(isLocalPathOpenAvailable({ DISPLAY: ':0', PATH: emptyDir })).toBe( + false, + ); + expect(isLocalPathOpenAvailable({ DISPLAY: ':0', PATH: xdgOpenDir })).toBe( + true, + ); + expect( + isLocalPathOpenAvailable({ + WAYLAND_DISPLAY: 'wayland-0', + PATH: xdgOpenDir, + }), + ).toBe(true); + }); + + it('rejects a directory named xdg-open on PATH on Linux', () => { + setPlatform('linux'); + expect( + isLocalPathOpenAvailable({ + DISPLAY: ':0', + PATH: xdgOpenDirectoryEntryDir, + }), + ).toBe(false); + }); + + it('requires PATH to be set on Linux', () => { + setPlatform('linux'); + expect(isLocalPathOpenAvailable({ DISPLAY: ':0' })).toBe(false); + }); +}); + +describe('openTerminalLocally', () => { + it('opens Terminal.app at the path on macOS', async () => { + setPlatform('darwin'); + execFileAsyncMock.mockResolvedValue({ stdout: '' }); + + await openTerminalLocally('/Users/me/code'); + + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'open', + ['-a', 'Terminal', '/Users/me/code'], + { timeout: 10_000 }, + ); + }); + + it('wraps an `open` failure on macOS', async () => { + setPlatform('darwin'); + execFileAsyncMock.mockRejectedValue(new Error('boom')); + + await expect(openTerminalLocally('/tmp')).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + }); + + it('spawns wt.exe at the path on Windows and ignores its exit code', async () => { + setPlatform('win32'); + spawnMock.mockReturnValue(fakeChild('close')); + + await openTerminalLocally(existingDir); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledWith('wt.exe', ['-d', existingDir], { + stdio: 'ignore', + }); + }); + + it('falls back to a PowerShell-launched cmd window when wt.exe fails to spawn on Windows', async () => { + setPlatform('win32'); + spawnMock + .mockReturnValueOnce(fakeChild('error')) + .mockReturnValueOnce(fakeChild('close')); + + await openTerminalLocally(existingDir); + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock).toHaveBeenNthCalledWith( + 1, + 'wt.exe', + ['-d', existingDir], + { stdio: 'ignore' }, + ); + // The directory travels as the child's working directory, never + // embedded in a re-parsed command line. + expect(spawnMock).toHaveBeenNthCalledWith( + 2, + 'powershell.exe', + [ + '-NoProfile', + '-STA', + '-Command', + 'Start-Process cmd.exe -WorkingDirectory "$env:QWEN_LOCAL_OPEN_DIR"', + ], + expect.objectContaining({ + stdio: 'ignore', + env: expect.objectContaining({ + QWEN_LOCAL_OPEN_DIR: existingDir, + }), + }), + ); + }); + + it('wraps the failure when both wt.exe and the fallback fail on Windows', async () => { + setPlatform('win32'); + spawnMock.mockReturnValue(fakeChild('error')); + + await expect(openTerminalLocally(existingDir)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + }); + + it('spawns gnome-terminal with --working-directory on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', gnomeTerminalDir); + vi.stubEnv('DISPLAY', ':0'); + spawnMock.mockReturnValue(fakeChild('spawn')); + + await openTerminalLocally(existingDir); + + expect(spawnMock).toHaveBeenCalledWith( + join(gnomeTerminalDir, 'gnome-terminal'), + [`--working-directory=${existingDir}`], + { stdio: 'ignore', detached: true }, + ); + }); + + it('spawns konsole with --workdir when gnome-terminal is absent on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', konsoleDir); + vi.stubEnv('DISPLAY', ':0'); + spawnMock.mockReturnValue(fakeChild('spawn')); + + await openTerminalLocally(existingDir); + + expect(spawnMock).toHaveBeenCalledWith( + join(konsoleDir, 'konsole'), + ['--workdir', existingDir], + { stdio: 'ignore', detached: true }, + ); + }); + + it('spawns xterm with a cd-and-exec shell line when it is the only terminal on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', xtermDir); + vi.stubEnv('DISPLAY', ':0'); + spawnMock.mockReturnValue(fakeChild('spawn')); + + await openTerminalLocally(existingDir); + + expect(spawnMock).toHaveBeenCalledWith( + join(xtermDir, 'xterm'), + [ + '-e', + 'sh', + '-c', + 'cd "$1" && exec "${SHELL:-/bin/sh}"', + 'sh', + existingDir, + ], + { stdio: 'ignore', detached: true }, + ); + }); + + it('skips xterm on a pure-Wayland session on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', xtermDir); + vi.stubEnv('WAYLAND_DISPLAY', 'wayland-0'); + vi.stubEnv('DISPLAY', ''); + + // xterm is X11-only: with no DISPLAY there is no usable candidate even + // though WAYLAND_DISPLAY is set. + await expect(openTerminalLocally(existingDir)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('rejects a deleted directory on Linux without spawning', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', gnomeTerminalDir); + vi.stubEnv('DISPLAY', ':0'); + + await expect(openTerminalLocally(missingDir)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('rejects when the display went away after the boot-time probe on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', gnomeTerminalDir); + vi.stubEnv('DISPLAY', ''); + vi.stubEnv('WAYLAND_DISPLAY', ''); + + await expect(openTerminalLocally(existingDir)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('prefers gnome-terminal over konsole and xterm on Linux', async () => { + setPlatform('linux'); + vi.stubEnv( + 'PATH', + [xtermDir, gnomeTerminalDir, konsoleDir].join(delimiter), + ); + vi.stubEnv('DISPLAY', ':0'); + spawnMock.mockReturnValue(fakeChild('spawn')); + + await openTerminalLocally(existingDir); + + expect(spawnMock).toHaveBeenCalledWith( + join(gnomeTerminalDir, 'gnome-terminal'), + [`--working-directory=${existingDir}`], + { stdio: 'ignore', detached: true }, + ); + }); + + it('throws unavailable when no terminal emulator is on PATH on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', emptyDir); + vi.stubEnv('DISPLAY', ':0'); + + await expect(openTerminalLocally('/tmp')).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('wraps a spawn-level terminal failure on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', gnomeTerminalDir); + vi.stubEnv('DISPLAY', ':0'); + spawnMock.mockReturnValue(fakeChild('error')); + + await expect(openTerminalLocally('/tmp')).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + }); + + it('throws unavailable on an unsupported platform', async () => { + setPlatform('aix'); + + await expect(openTerminalLocally('/tmp')).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(execFileAsyncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); + +describe('isLocalTerminalAvailable', () => { + it('requires positive graphical-session evidence on macOS and Windows', () => { + setPlatform('darwin'); + expect( + isLocalTerminalAvailable({}, { processUid: 0, consoleUid: 501 }), + ).toBe(false); + expect( + isLocalTerminalAvailable({}, { processUid: 501, consoleUid: 501 }), + ).toBe(true); + expect( + isLocalTerminalAvailable( + { SSH_CONNECTION: 'remote' }, + { processUid: 501, consoleUid: 501 }, + ), + ).toBe(false); + setPlatform('win32'); + expect(isLocalTerminalAvailable({})).toBe(false); + expect(isLocalTerminalAvailable({ SESSIONNAME: 'Console' })).toBe(true); + expect(isLocalTerminalAvailable({ SESSIONNAME: 'Services' })).toBe(false); + }); + + it('is unavailable on unsupported platforms', () => { + setPlatform('aix'); + expect( + isLocalTerminalAvailable({ DISPLAY: ':0', PATH: gnomeTerminalDir }), + ).toBe(false); + }); + + it('requires a display on Linux', () => { + setPlatform('linux'); + expect(isLocalTerminalAvailable({ PATH: gnomeTerminalDir })).toBe(false); + }); + + it('accepts any supported terminal emulator on PATH on Linux', () => { + setPlatform('linux'); + expect(isLocalTerminalAvailable({ DISPLAY: ':0', PATH: emptyDir })).toBe( + false, + ); + expect( + isLocalTerminalAvailable({ DISPLAY: ':0', PATH: gnomeTerminalDir }), + ).toBe(true); + expect(isLocalTerminalAvailable({ DISPLAY: ':0', PATH: konsoleDir })).toBe( + true, + ); + // xterm is X11-only: a pure-Wayland session must not count it. + expect( + isLocalTerminalAvailable({ + WAYLAND_DISPLAY: 'wayland-0', + PATH: xtermDir, + }), + ).toBe(false); + expect(isLocalTerminalAvailable({ DISPLAY: ':0', PATH: xtermDir })).toBe( + true, + ); + }); + + it('ignores xdg-open for the terminal probe on Linux', () => { + setPlatform('linux'); + expect(isLocalTerminalAvailable({ DISPLAY: ':0', PATH: xdgOpenDir })).toBe( + false, + ); + }); + + it('requires PATH to be set on Linux', () => { + setPlatform('linux'); + expect(isLocalTerminalAvailable({ DISPLAY: ':0' })).toBe(false); + }); +}); + +describe('spawn helper contracts', () => { + it('detaches the long-lived terminal window from the daemon event loop', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', gnomeTerminalDir); + vi.stubEnv('DISPLAY', ':0'); + spawnMock.mockReturnValue(fakeChild('spawn')); + + await openTerminalLocally(existingDir); + + const child = spawnMock.mock.results[0]?.value as { + unref: ReturnType; + }; + expect(child.unref).toHaveBeenCalled(); + }); + + it('resolves even when explorer.exe exits non-zero on Windows', async () => { + setPlatform('win32'); + spawnMock.mockReturnValue({ + once: (event: string, cb: (code?: number) => void) => { + if (event === 'close') queueMicrotask(() => cb(1)); + }, + kill: vi.fn(), + unref: vi.fn(), + }); + + // explorer.exe commonly exits 1 on a successful open; that is the whole + // reason the exit code is ignored. + await expect(openPathLocally(existingDir)).resolves.toBeUndefined(); + }); + + it('kills and rejects a hung GUI launcher', async () => { + setPlatform('win32'); + const child = { once: vi.fn(), kill: vi.fn(), unref: vi.fn() }; + spawnMock.mockReturnValue(child); + + await expect(openPathLocally(existingDir, 5)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(child.kill).toHaveBeenCalled(); + }); + + it('does not fall back when a spawned wt.exe stalls and is killed by the hang guard', async () => { + setPlatform('win32'); + const child = { once: vi.fn(), kill: vi.fn(), unref: vi.fn() }; + spawnMock.mockReturnValue(child); + + // The hang guard fired — wt.exe exists, it just never exited; launching + // the PowerShell fallback here would open a second terminal. + await expect(openTerminalLocally(existingDir, 5)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(child.kill).toHaveBeenCalled(); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).not.toHaveBeenCalledWith( + 'powershell.exe', + expect.anything(), + expect.anything(), + ); + }); + + it('kills and rejects a hung terminal spawn', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', gnomeTerminalDir); + vi.stubEnv('DISPLAY', ':0'); + const child = { once: vi.fn(), kill: vi.fn(), unref: vi.fn() }; + spawnMock.mockReturnValue(child); + + await expect(openTerminalLocally(existingDir, 5)).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + expect(child.kill).toHaveBeenCalled(); + }); + + it('inherits the daemon environment alongside the injected directory', async () => { + setPlatform('win32'); + vi.stubEnv('QWEN_TEST_INHERITED_MARKER', 'present'); + spawnMock + .mockReturnValueOnce(fakeChild('error')) + .mockReturnValueOnce(fakeChild('close')); + + await openTerminalLocally(existingDir); + + expect(spawnMock).toHaveBeenNthCalledWith( + 2, + 'powershell.exe', + [ + '-NoProfile', + '-STA', + '-Command', + 'Start-Process cmd.exe -WorkingDirectory "$env:QWEN_LOCAL_OPEN_DIR"', + ], + expect.objectContaining({ + stdio: 'ignore', + env: expect.objectContaining({ + QWEN_LOCAL_OPEN_DIR: existingDir, + QWEN_TEST_INHERITED_MARKER: 'present', + }), + }), + ); + }); + + it('rejects a darwin session over an SSH tty, not just SSH_CONNECTION', () => { + setPlatform('darwin'); + expect( + isLocalTerminalAvailable( + { SSH_TTY: '/dev/ttys001' }, + { processUid: 501, consoleUid: 501 }, + ), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/serve/local-path-open.ts b/packages/cli/src/serve/local-path-open.ts new file mode 100644 index 00000000000..dea8219991a --- /dev/null +++ b/packages/cli/src/serve/local-path-open.ts @@ -0,0 +1,344 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFile, spawn } from 'node:child_process'; +import { accessSync, constants, statSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +// `open` / `explorer.exe` / `xdg-open` hand off to the GUI and return +// immediately; the timeout only guards against a hung launcher. +const OPEN_TIMEOUT_MS = 10_000; + +// Linux terminal emulators tried in order; the first executable on PATH wins. +const LINUX_TERMINAL_NAMES = ['gnome-terminal', 'konsole', 'xterm'] as const; + +// xterm is X11-only (Xt/Xaw, no Wayland backend): on a pure-Wayland session +// (no DISPLAY / Xwayland) it can only exit with "cannot open display", so +// probe and spawner must consider the same reduced candidate set. +function linuxTerminalCandidates( + env: Readonly>, +): readonly string[] { + return env['DISPLAY'] + ? LINUX_TERMINAL_NAMES + : LINUX_TERMINAL_NAMES.filter((name) => name !== 'xterm'); +} + +export class LocalPathOpenUnavailableError extends Error {} + +// A spawned launcher that never exited within the timeout and was killed — +// distinct from a spawn-level failure, so the wt.exe fallback does not treat +// a stalled-but-running terminal as "wt.exe missing" and double-open. +class SpawnHangError extends Error {} + +interface MacOsSessionUids { + readonly processUid?: number; + readonly consoleUid?: number; +} + +function defaultMacOsSessionUids(): MacOsSessionUids | undefined { + return process.platform === 'darwin' ? readMacOsSessionUids() : undefined; +} + +// Shared darwin/win32 graphical-session evidence. Returns undefined on other +// platforms so the caller falls through to its own platform probe. +function hasGuiSession( + env: Readonly>, + macOsSessionUids: MacOsSessionUids | undefined, +): boolean | undefined { + if (process.platform === 'darwin') { + return ( + macOsSessionUids?.processUid !== undefined && + macOsSessionUids.processUid > 0 && + macOsSessionUids.consoleUid === macOsSessionUids.processUid && + !env['SSH_CONNECTION'] && + !env['SSH_TTY'] + ); + } + if (process.platform === 'win32') { + const sessionName = env['SESSIONNAME']?.trim(); + return Boolean(sessionName && sessionName.toLowerCase() !== 'services'); + } + return undefined; +} + +function findExecutableOnPath( + env: Readonly>, + names: readonly string[], +): string | undefined { + // Names outer: the caller's preference order wins over PATH order. + for (const name of names) { + for (const dir of (env['PATH'] ?? '').split(delimiter)) { + if (dir === '') continue; + const candidate = join(dir, name); + if (isExecutableFile(candidate)) return candidate; + } + } + return undefined; +} + +// Startup probe so `/capabilities` can omit the local-open feature on headless +// hosts and clients hide the Open-locally affordance instead of surfacing a +// guaranteed `cannot open display` failure. Same session evidence as the +// native directory picker probe; only the Linux launcher differs (xdg-open +// instead of zenity). +export function isLocalPathOpenAvailable( + env: Readonly> = process.env, + macOsSessionUids = defaultMacOsSessionUids(), +): boolean { + const guiSession = hasGuiSession(env, macOsSessionUids); + if (guiSession !== undefined) return guiSession; + if (process.platform !== 'linux') return false; + if (!env['DISPLAY'] && !env['WAYLAND_DISPLAY']) return false; + return findExecutableOnPath(env, ['xdg-open']) !== undefined; +} + +// Same session evidence as isLocalPathOpenAvailable; on Linux any one common +// terminal emulator on PATH is enough. +export function isLocalTerminalAvailable( + env: Readonly> = process.env, + macOsSessionUids = defaultMacOsSessionUids(), +): boolean { + const guiSession = hasGuiSession(env, macOsSessionUids); + if (guiSession !== undefined) return guiSession; + if (process.platform !== 'linux') return false; + if (!env['DISPLAY'] && !env['WAYLAND_DISPLAY']) return false; + return findExecutableOnPath(env, linuxTerminalCandidates(env)) !== undefined; +} + +function readMacOsSessionUids(): MacOsSessionUids { + try { + return { + processUid: process.getuid?.(), + consoleUid: statSync('/dev/console').uid, + }; + } catch { + return {}; + } +} + +function isExecutableFile(file: string): boolean { + try { + // A directory passes an X_OK probe (search permission) but cannot be + // exec'd, so it must not count as an installed xdg-open. + if (!statSync(file).isFile()) return false; + accessSync(file, constants.X_OK); + return true; + } catch { + return false; + } +} + +// `timeoutMs` exists for tests; production callers use the default. +export async function openPathLocally( + path: string, + timeoutMs?: number, +): Promise { + try { + if (process.platform === 'darwin') { + await execFileAsync('open', [path], { timeout: OPEN_TIMEOUT_MS }); + return; + } + + if (process.platform === 'win32') { + // explorer.exe opens Quick Access for a deleted directory and still + // exits 0, which would report a successful open for a workspace that + // no longer exists — darwin/linux fail that case in the launcher, so + // probe first here. + assertPathExists(path); + await spawnAndIgnoreExitCode('explorer.exe', [path], { timeoutMs }); + return; + } + + if (process.platform === 'linux') { + await execFileAsync('xdg-open', [path], { timeout: OPEN_TIMEOUT_MS }); + return; + } + } catch (error) { + throw new LocalPathOpenUnavailableError( + error instanceof Error ? error.message : String(error), + ); + } + + throw new LocalPathOpenUnavailableError( + `Local path open is not supported on ${process.platform}`, + ); +} + +function assertPathExists(path: string): void { + try { + statSync(path); + } catch { + throw new LocalPathOpenUnavailableError(`Path does not exist: ${path}`); + } +} + +// `timeoutMs` exists for tests; production callers use the default. +export async function openTerminalLocally( + path: string, + timeoutMs?: number, +): Promise { + try { + if (process.platform === 'darwin') { + await execFileAsync('open', ['-a', 'Terminal', path], { + timeout: OPEN_TIMEOUT_MS, + }); + return; + } + + if (process.platform === 'win32') { + // Same deleted-directory honesty as the folder open: wt.exe ignores a + // missing -d target and opens its default profile directory. + assertPathExists(path); + await spawnWindowsTerminal(path, timeoutMs); + return; + } + + if (process.platform === 'linux') { + // win32 parity: a deleted directory must not report a successful open. + assertPathExists(path); + await spawnLinuxTerminal(path, timeoutMs); + return; + } + } catch (error) { + if (error instanceof LocalPathOpenUnavailableError) throw error; + throw new LocalPathOpenUnavailableError( + error instanceof Error ? error.message : String(error), + ); + } + + throw new LocalPathOpenUnavailableError( + `Local terminal open is not supported on ${process.platform}`, + ); +} + +// wt.exe (Windows Terminal) is absent on older installs; fall back to a +// PowerShell-launched cmd window. +async function spawnWindowsTerminal( + path: string, + timeoutMs?: number, +): Promise { + try { + await spawnAndIgnoreExitCode('wt.exe', ['-d', path], { timeoutMs }); + } catch (error) { + // A wt.exe that spawned but stalled is killed by the hang guard; the + // fallback would then double-open, so only a spawn-level failure (wt.exe + // absent) falls back. + if (error instanceof SpawnHangError) throw error; + // The directory travels as the child's working directory, never through + // a parsed command line: cmd.exe `start` disagrees with CreateProcess + // quoting, and any `cd /d ""` form re-exposes cmd's %VAR% + // expansion. PowerShell expands the env var into a parameter value, and + // the assertPathExists pre-check keeps a missing directory a clean 501 + // instead of a raw Start-Process error. + await spawnAndIgnoreExitCode( + 'powershell.exe', + [ + '-NoProfile', + '-STA', + '-Command', + 'Start-Process cmd.exe -WorkingDirectory "$env:QWEN_LOCAL_OPEN_DIR"', + ], + { extraEnv: { QWEN_LOCAL_OPEN_DIR: path }, timeoutMs }, + ); + } +} + +async function spawnLinuxTerminal( + path: string, + timeoutMs?: number, +): Promise { + const env = process.env; + // The capability probe runs once at boot; a display that went away since + // must not report a successful open now. + if (!env['DISPLAY'] && !env['WAYLAND_DISPLAY']) { + throw new LocalPathOpenUnavailableError('No display server available'); + } + const terminal = findExecutableOnPath(env, linuxTerminalCandidates(env)); + if (terminal === undefined) { + throw new LocalPathOpenUnavailableError( + 'No terminal emulator found on PATH', + ); + } + const name = terminal.split(/[\\/]/).pop(); + if (name === 'gnome-terminal') { + await spawnLongLived(terminal, [`--working-directory=${path}`], timeoutMs); + return; + } + if (name === 'konsole') { + await spawnLongLived(terminal, ['--workdir', path], timeoutMs); + return; + } + await spawnLongLived( + terminal, + ['-e', 'sh', '-c', 'cd "$1" && exec "${SHELL:-/bin/sh}"', 'sh', path], + timeoutMs, + ); +} + +// explorer.exe / wt.exe commonly exit 1 even when they did open the folder, +// so their exit code is meaningless; only a spawn-level failure (ENOENT, +// EACCES, ...) or a hang is a real error. +function spawnAndIgnoreExitCode( + command: string, + args: readonly string[], + options: { extraEnv?: Record; timeoutMs?: number } = {}, +): Promise { + const { extraEnv, timeoutMs = OPEN_TIMEOUT_MS } = options; + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + stdio: 'ignore', + ...(extraEnv ? { env: { ...process.env, ...extraEnv } } : {}), + }); + const timer = setTimeout(() => { + child.kill(); + reject( + new SpawnHangError(`${command} did not exit within ${timeoutMs}ms`), + ); + }, timeoutMs); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('close', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +// Terminal windows are long-lived child processes: resolve as soon as the OS +// accepts the spawn and detach so the daemon's lifetime stays independent of +// the window. Only a spawn-level failure is an error. +function spawnLongLived( + command: string, + args: readonly string[], + timeoutMs = OPEN_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + stdio: 'ignore', + detached: true, + }); + const timer = setTimeout(() => { + child.kill(); + reject( + new SpawnHangError(`${command} did not spawn within ${timeoutMs}ms`), + ); + }, timeoutMs); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('spawn', () => { + clearTimeout(timer); + child.unref(); + resolve(); + }); + }); +} diff --git a/packages/cli/src/serve/process-env-guard.test.ts b/packages/cli/src/serve/process-env-guard.test.ts index ddacd067157..50448fed6ca 100644 --- a/packages/cli/src/serve/process-env-guard.test.ts +++ b/packages/cli/src/serve/process-env-guard.test.ts @@ -150,6 +150,19 @@ const allowedProcessEnvAccesses = normalizeAllowances([ accesses: { 'key:QWEN_AUDIT_RAW_PATHS': 1 }, }, ], + [ + 'packages/cli/src/serve/local-path-open.ts', + { + reason: + 'Local-open availability probes process-scoped host session state ' + + '(SSH markers, display server, Windows session name, terminal ' + + 'emulators on PATH), so embedded callers may omit the environment ' + + 'argument; the win32 terminal fallback inherits the daemon ' + + 'environment to hand the target directory to PowerShell via one ' + + 'added variable.', + accesses: { whole: 4 }, + }, + ], [ 'packages/cli/src/serve/native-directory-picker.ts', { diff --git a/packages/cli/src/serve/routes/workspace-local-open.test.ts b/packages/cli/src/serve/routes/workspace-local-open.test.ts new file mode 100644 index 00000000000..b2ce5b46650 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-local-open.test.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import express, { type RequestHandler } from 'express'; +import request from 'supertest'; +import { registerWorkspaceLocalOpenRoutes } from './workspace-local-open.js'; +import { LocalPathOpenUnavailableError } from '../local-path-open.js'; +import type { + WorkspaceEntry, + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStderrLine: vi.fn(), +})); + +const WS_CWD = '/workspace/primary'; + +function makeRuntime( + overrides: Partial = {}, +): WorkspaceRuntime { + return { + workspaceId: 'primary-id', + workspaceCwd: WS_CWD, + primary: true, + trusted: true, + ...overrides, + } as WorkspaceRuntime; +} + +function createMockRegistry(runtimes: WorkspaceRuntime[]): WorkspaceRegistry { + const entries = runtimes.map( + (runtime) => + ({ + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + state: 'active', + current: { runtime }, + }) as unknown as WorkspaceEntry, + ); + return { + getEntryByWorkspaceId: (id: string) => + entries.find((entry) => entry.workspaceId === id), + getEntryByWorkspaceCwd: (cwd: string) => + entries.find((entry) => entry.workspaceCwd === cwd), + listEntries: () => entries, + } as unknown as WorkspaceRegistry; +} + +function createApp( + deps: { + runtimes?: WorkspaceRuntime[]; + mutate?: (opts?: { strict?: boolean }) => RequestHandler; + openPathLocally?: (path: string) => Promise; + openTerminalLocally?: (path: string) => Promise; + } = {}, +) { + const app = express(); + app.use(express.json()); + registerWorkspaceLocalOpenRoutes(app, { + workspaceRegistry: createMockRegistry(deps.runtimes ?? [makeRuntime()]), + mutate: + deps.mutate ?? + (() => (_req, _res, next) => { + next(); + }), + ...(deps.openPathLocally ? { openPathLocally: deps.openPathLocally } : {}), + ...(deps.openTerminalLocally + ? { openTerminalLocally: deps.openTerminalLocally } + : {}), + }); + return app; +} + +describe('POST /workspaces/:workspace/open', () => { + it('opens the resolved workspace cwd and reports opened=true', async () => { + const openPathLocally = vi.fn().mockResolvedValue(undefined); + const app = createApp({ openPathLocally }); + + const res = await request(app).post('/workspaces/primary-id/open'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + kind: 'workspace-local-open', + opened: true, + target: 'folder', + }); + expect(openPathLocally).toHaveBeenCalledWith(WS_CWD); + }); + + it('resolves the workspace by absolute cwd too', async () => { + const openPathLocally = vi.fn().mockResolvedValue(undefined); + const app = createApp({ openPathLocally }); + + const res = await request(app).post( + `/workspaces/${encodeURIComponent(WS_CWD)}/open`, + ); + + expect(res.status).toBe(200); + expect(openPathLocally).toHaveBeenCalledWith(WS_CWD); + }); + + it('returns 501 when the host cannot open a GUI', async () => { + const app = createApp({ + openPathLocally: vi + .fn() + .mockRejectedValue(new LocalPathOpenUnavailableError('no display')), + }); + + const res = await request(app).post('/workspaces/primary-id/open'); + + expect(res.status).toBe(501); + expect(res.body).toEqual({ + error: 'Local path open is unavailable', + code: 'local_path_open_unavailable', + }); + expect(writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining('local path open unavailable: no display'), + ); + }); + + it('returns 500 when the handoff fails unexpectedly', async () => { + const app = createApp({ + openPathLocally: vi.fn().mockRejectedValue(new Error('boom')), + }); + + const res = await request(app).post('/workspaces/primary-id/open'); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('local_path_open_failed'); + expect(writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining('local path open failed: boom'), + ); + }); + + it('rejects an unknown workspace with workspace_mismatch', async () => { + const openPathLocally = vi.fn().mockResolvedValue(undefined); + const app = createApp({ openPathLocally }); + + const res = await request(app).post('/workspaces/unknown-id/open'); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('workspace_mismatch'); + expect(openPathLocally).not.toHaveBeenCalled(); + }); + + it('rejects an untrusted workspace without opening anything', async () => { + const openPathLocally = vi.fn().mockResolvedValue(undefined); + const app = createApp({ + runtimes: [makeRuntime({ trusted: false })], + openPathLocally, + }); + + const res = await request(app).post('/workspaces/primary-id/open'); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('untrusted_workspace'); + expect(openPathLocally).not.toHaveBeenCalled(); + }); + + it('applies the mutate middleware', async () => { + const openPathLocally = vi.fn().mockResolvedValue(undefined); + const app = createApp({ + openPathLocally, + mutate: () => (_req, res) => { + res.status(401).json({ code: 'token_required' }); + }, + }); + + const res = await request(app).post('/workspaces/primary-id/open'); + + expect(res.status).toBe(401); + expect(openPathLocally).not.toHaveBeenCalled(); + }); + + it('dispatches target=terminal to the terminal handoff', async () => { + const openPathLocally = vi.fn().mockResolvedValue(undefined); + const openTerminalLocally = vi.fn().mockResolvedValue(undefined); + const app = createApp({ openPathLocally, openTerminalLocally }); + + const res = await request(app) + .post('/workspaces/primary-id/open') + .send({ target: 'terminal' }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + kind: 'workspace-local-open', + opened: true, + target: 'terminal', + }); + expect(openTerminalLocally).toHaveBeenCalledWith(WS_CWD); + expect(openPathLocally).not.toHaveBeenCalled(); + }); + + it('treats an absent or unrecognized target as folder', async () => { + const openPathLocally = vi.fn().mockResolvedValue(undefined); + const openTerminalLocally = vi.fn().mockResolvedValue(undefined); + const app = createApp({ openPathLocally, openTerminalLocally }); + + const empty = await request(app) + .post('/workspaces/primary-id/open') + .send({}); + const other = await request(app) + .post('/workspaces/primary-id/open') + .send({ target: 'files' }); + + expect(empty.body.target).toBe('folder'); + expect(other.body.target).toBe('folder'); + expect(openPathLocally).toHaveBeenCalledTimes(2); + expect(openTerminalLocally).not.toHaveBeenCalled(); + }); + + it('returns 501 when the host cannot open a terminal', async () => { + const app = createApp({ + openTerminalLocally: vi + .fn() + .mockRejectedValue( + new LocalPathOpenUnavailableError('no terminal emulator'), + ), + }); + + const res = await request(app) + .post('/workspaces/primary-id/open') + .send({ target: 'terminal' }); + + expect(res.status).toBe(501); + expect(res.body).toEqual({ + error: 'Local path open is unavailable', + code: 'local_path_open_unavailable', + }); + expect(writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining( + 'local path open unavailable: no terminal emulator', + ), + ); + }); + + it('returns 500 when the terminal handoff fails unexpectedly', async () => { + const app = createApp({ + openTerminalLocally: vi.fn().mockRejectedValue(new Error('boom')), + }); + + const res = await request(app) + .post('/workspaces/primary-id/open') + .send({ target: 'terminal' }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('local_path_open_failed'); + }); +}); diff --git a/packages/cli/src/serve/routes/workspace-local-open.ts b/packages/cli/src/serve/routes/workspace-local-open.ts new file mode 100644 index 00000000000..9478a7a00ab --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-local-open.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, RequestHandler, Response } from 'express'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { + LocalPathOpenUnavailableError, + openPathLocally, + openTerminalLocally, +} from '../local-path-open.js'; +import { safeBody } from '../server/request-helpers.js'; +import type { WorkspaceRegistry } from '../workspace-registry.js'; +import { resolveTrustedRuntime } from '../workspace-route-runtime.js'; + +type LocalOpenTarget = 'folder' | 'terminal'; + +export function registerWorkspaceLocalOpenRoutes( + app: Application, + deps: { + workspaceRegistry: WorkspaceRegistry; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + /** Test/embed override for the OS handoff; production uses the util. */ + openPathLocally?: (path: string) => Promise; + /** Test/embed override for the OS handoff; production uses the util. */ + openTerminalLocally?: (path: string) => Promise; + }, +): void { + const openLocally = deps.openPathLocally ?? openPathLocally; + const openTerminal = deps.openTerminalLocally ?? openTerminalLocally; + + app.post( + '/workspaces/:workspace/open', + deps.mutate(), + async (req: Request, res: Response) => { + const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res); + if (!runtime) return; + const target: LocalOpenTarget = + safeBody(req)['target'] === 'terminal' ? 'terminal' : 'folder'; + try { + // The opened path is always the resolved registered workspace cwd — + // never client-supplied beyond the route param. + if (target === 'terminal') { + await openTerminal(runtime.workspaceCwd); + } else { + await openLocally(runtime.workspaceCwd); + } + res + .status(200) + .json({ kind: 'workspace-local-open', opened: true, target }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + if (error instanceof LocalPathOpenUnavailableError) { + writeStderrLine(`qwen serve: local path open unavailable: ${detail}`); + res.status(501).json({ + error: 'Local path open is unavailable', + code: 'local_path_open_unavailable', + }); + return; + } + writeStderrLine(`qwen serve: local path open failed: ${detail}`); + res.status(500).json({ + error: 'Failed to open workspace locally', + code: 'local_path_open_failed', + }); + } + }, + ); +} diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index e79124fa307..0085214b813 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -35,6 +35,7 @@ import { } from './run-qwen-serve.js'; import { isBrowserAutomationMcpAvailable } from './cdp-mcp-command.js'; import * as nativeDirectoryPicker from './native-directory-picker.js'; +import * as localPathOpen from './local-path-open.js'; import { loadServeFastPathEnvironment } from './fast-path-settings.js'; import { loadEnvironment } from '../config/environment.js'; import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js'; @@ -11234,6 +11235,114 @@ describe('runQwenServe runtime startup failures', () => { }, ); + it.each([true, false])( + 'mirrors the local path open probe on the bootstrap envelopes (available: %s)', + async (available) => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-bootstrap-open-')), + ); + // Keep the runtime from mounting so the bootstrap `/capabilities` and + // `/daemon/status` envelopes stay the ones being served. + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockImplementation(() => { + throw new Error('runtime boom'); + }); + const probe = vi + .spyOn(localPathOpen, 'isLocalPathOpenAvailable') + .mockReturnValue(available); + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true }, + ); + try { + await expect(handle.runtimeReady).rejects.toThrow('runtime boom'); + const probeCallsAfterBoot = probe.mock.calls.length; + const capabilities = (await ( + await fetch(`${handle.url}/capabilities`) + ).json()) as { features: string[] }; + const status = (await ( + await fetch(`${handle.url}/daemon/status`) + ).json()) as { capabilities: { features: string[] } }; + if (available) { + expect(capabilities.features).toContain('workspace_local_open'); + expect(status.capabilities.features).toContain( + 'workspace_local_open', + ); + } else { + expect(capabilities.features).not.toContain('workspace_local_open'); + expect(status.capabilities.features).not.toContain( + 'workspace_local_open', + ); + } + // Probed once while the bootstrap app was built, not per request. + expect(probe.mock.calls.length).toBe(probeCallsAfterBoot); + } finally { + await handle.close(); + } + }, + ); + + it.each([true, false])( + 'mirrors the local terminal open probe on the bootstrap envelopes (available: %s)', + async (available) => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-bootstrap-terminal-')), + ); + // Keep the runtime from mounting so the bootstrap `/capabilities` and + // `/daemon/status` envelopes stay the ones being served. + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockImplementation(() => { + throw new Error('runtime boom'); + }); + const probe = vi + .spyOn(localPathOpen, 'isLocalTerminalAvailable') + .mockReturnValue(available); + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { resolveOnListen: true }, + ); + try { + await expect(handle.runtimeReady).rejects.toThrow('runtime boom'); + const probeCallsAfterBoot = probe.mock.calls.length; + const capabilities = (await ( + await fetch(`${handle.url}/capabilities`) + ).json()) as { features: string[] }; + const status = (await ( + await fetch(`${handle.url}/daemon/status`) + ).json()) as { capabilities: { features: string[] } }; + if (available) { + expect(capabilities.features).toContain('workspace_local_terminal'); + expect(status.capabilities.features).toContain( + 'workspace_local_terminal', + ); + } else { + expect(capabilities.features).not.toContain( + 'workspace_local_terminal', + ); + expect(status.capabilities.features).not.toContain( + 'workspace_local_terminal', + ); + } + // Probed once while the bootstrap app was built, not per request. + expect(probe.mock.calls.length).toBe(probeCallsAfterBoot); + } finally { + await handle.close(); + } + }, + ); + it('shuts down a bridge when runtime mounting fails after bridge creation', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-partial-fail-')), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 5564d2584bb..0019a52a6c2 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -120,6 +120,10 @@ import { SERVE_CAPABILITY_REGISTRY, } from './capabilities.js'; import { isNativeDirectoryPickerAvailable } from './native-directory-picker.js'; +import { + isLocalPathOpenAvailable, + isLocalTerminalAvailable, +} from './local-path-open.js'; import { EXTERNAL_TOOL_GUARD_PROVIDER_ATTACHED_VALUE, EXTERNAL_TOOL_GUARD_REQUIRED_VALUE, @@ -2325,6 +2329,8 @@ function currentServeFeaturesForRunQwenServe( currentSessionSchedulingAvailable: boolean, env: Readonly>, nativeDirectoryPickerAvailable: boolean, + localPathOpenAvailable: boolean, + localTerminalOpenAvailable: boolean, ): string[] { return getAdvertisedServeFeatures(undefined, { requireAuth: opts.requireAuth === true, @@ -2356,6 +2362,16 @@ function currentServeFeaturesForRunQwenServe( // briefly under-report them. nativeDirectoryPickerAvailable, workspaceRuntimeAvailable, + localPathOpenAvailable, + localTerminalOpenAvailable, + // The production wiring below always registers these, so the bootstrap + // envelope must not under-report them either — the runtime envelope + // serves all five once the app is up. + dynamicWorkspaceRegistrationAvailable: true, + scratchWorkspaceRegistrationAvailable: true, + standaloneSessionsAvailable: true, + workspaceTrustHotReloadAvailable: true, + acpHttpEnabled: resolveAcpHttpEnabled(env as NodeJS.ProcessEnv), clientMcpOverWsEnabled: opts.clientMcpOverWs === true, cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true, browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(opts, env), @@ -2373,6 +2389,8 @@ function createBootstrapCapabilities(input: { permissionPolicy: PermissionPolicy | undefined; env: Readonly>; nativeDirectoryPickerAvailable: boolean; + localPathOpenAvailable: boolean; + localTerminalOpenAvailable: boolean; }): CapabilitiesEnvelope { return { v: CAPABILITIES_SCHEMA_VERSION, @@ -2389,6 +2407,8 @@ function createBootstrapCapabilities(input: { input.currentSessionSchedulingAvailable, input.env, input.nativeDirectoryPickerAvailable, + input.localPathOpenAvailable, + input.localTerminalOpenAvailable, ), modelServices: [], workspaceCwd: input.boundWorkspace, @@ -2566,6 +2586,8 @@ function createBootstrapServeApp(input: { // request, so evaluate it once here — the runtime path likewise probes once, // at `createApp` time (server.ts). const nativeDirectoryPickerAvailable = isNativeDirectoryPickerAvailable(); + const localPathOpenAvailable = isLocalPathOpenAvailable(); + const localTerminalOpenAvailable = isLocalTerminalAvailable(); installSelfOriginStripMiddleware(app, getPort, opts.hostname); if (opts.allowOrigins && opts.allowOrigins.length > 0) { @@ -2628,6 +2650,8 @@ function createBootstrapServeApp(input: { permissionPolicy, env: process.env, nativeDirectoryPickerAvailable, + localPathOpenAvailable, + localTerminalOpenAvailable, }), ); }); @@ -2772,6 +2796,8 @@ function createBootstrapServeApp(input: { currentSessionSchedulingAvailable, process.env, nativeDirectoryPickerAvailable, + localPathOpenAvailable, + localTerminalOpenAvailable, ), }, runtime: { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index b14309c7fee..ae5ad11a8e0 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -72,6 +72,10 @@ import { type ServeProtocolVersion, } from './capabilities.js'; import { isNativeDirectoryPickerAvailable } from './native-directory-picker.js'; +import { + isLocalPathOpenAvailable, + isLocalTerminalAvailable, +} from './local-path-open.js'; import type { CancelNotification, PromptRequest, @@ -800,6 +804,8 @@ const EXPECTED_REGISTERED_FEATURES = [ 'workspace_runtime_removal', 'native_directory_picker', 'workspace_runtime', + 'workspace_local_open', + 'workspace_local_terminal', 'workspace_qualified_rest_core', 'workspace_qualified_voice', 'workspace_qualified_memory', @@ -3361,6 +3367,34 @@ describe('createServeApp', () => { ); continue; } + if (feature === 'workspace_local_open') { + expect(predicate({ localPathOpenAvailable: true })).toBe(true); + expect(predicate({ localPathOpenAvailable: false })).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + localPathOpenAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } + if (feature === 'workspace_local_terminal') { + expect(predicate({ localTerminalOpenAvailable: true })).toBe(true); + expect(predicate({ localTerminalOpenAvailable: false })).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + localTerminalOpenAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } if (feature === 'workspace_trust_hot_reload') { expect(predicate({ workspaceTrustHotReloadAvailable: true })).toBe( true, @@ -4388,6 +4422,8 @@ describe('createServeApp', () => { // Mirror the server.ts probe so the expectation matches on both // GUI and headless hosts. nativeDirectoryPickerAvailable: isNativeDirectoryPickerAvailable(), + localPathOpenAvailable: isLocalPathOpenAvailable(), + localTerminalOpenAvailable: isLocalTerminalAvailable(), }), ); expect(res.body.modelServices).toEqual([]); @@ -4425,6 +4461,46 @@ describe('createServeApp', () => { expect(disabled.body.features).not.toContain('native_directory_picker'); }); + it('forwards the local path open probe result to capabilities', async () => { + const enabled = await request( + createServeApp(baseOpts, undefined, { + localPathOpenAvailable: true, + }), + ) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(enabled.body.features).toContain('workspace_local_open'); + + const disabled = await request( + createServeApp(baseOpts, undefined, { + localPathOpenAvailable: false, + }), + ) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(disabled.body.features).not.toContain('workspace_local_open'); + }); + + it('forwards the local terminal open probe result to capabilities', async () => { + const enabled = await request( + createServeApp(baseOpts, undefined, { + localTerminalOpenAvailable: true, + }), + ) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(enabled.body.features).toContain('workspace_local_terminal'); + + const disabled = await request( + createServeApp(baseOpts, undefined, { + localTerminalOpenAvailable: false, + }), + ) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(disabled.body.features).not.toContain('workspace_local_terminal'); + }); + it('omits artifact persistence when the durable sink is unavailable', async () => { const app = createServeApp(baseOpts, undefined, { sessionArtifactsPersistenceAvailable: false, diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 21eb66508fd..e407a449c5d 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -260,6 +260,10 @@ import { type WorkspaceRuntimeRemovalController, } from './routes/workspace-management.js'; import { isNativeDirectoryPickerAvailable } from './native-directory-picker.js'; +import { + isLocalPathOpenAvailable, + isLocalTerminalAvailable, +} from './local-path-open.js'; import type { WorkspaceRegistrationStore } from './workspace-registration-store.js'; import { registerWorkspaceGitRoutes, @@ -278,6 +282,7 @@ import { registerWorkspaceQualifiedGitBranchRoutes, } from './routes/workspace-git-branches.js'; import { registerWorkspaceQualifiedGitHubPrsRoutes } from './routes/workspace-github-prs.js'; +import { registerWorkspaceLocalOpenRoutes } from './routes/workspace-local-open.js'; import { WorkspaceGitState } from './workspace-git-state.js'; import { registerWorkspaceMcpControlRoutes, @@ -611,6 +616,18 @@ export interface ServeAppDeps { * capability wiring is assertable on headless hosts too. */ nativeDirectoryPickerAvailable?: boolean; + /** + * Test/embed override for the local path open probe. Production evaluates + * `isLocalPathOpenAvailable()`; tests pin this so the capability wiring is + * assertable on headless hosts too. + */ + localPathOpenAvailable?: boolean; + /** + * Test/embed override for the local terminal open probe. Production + * evaluates `isLocalTerminalAvailable()`; tests pin this so the capability + * wiring is assertable on headless hosts too. + */ + localTerminalOpenAvailable?: boolean; /** * Reverse tool channel (issue #5626, Phase 2). Shared sender registry that * bridges the daemon WS (per-connection `ClientMcpRegistrar`) and the ACP @@ -1061,6 +1078,10 @@ export function createServeApp( ) ); }, + localPathOpenAvailable: + deps.localPathOpenAvailable ?? isLocalPathOpenAvailable(), + localTerminalOpenAvailable: + deps.localTerminalOpenAvailable ?? isLocalTerminalAvailable(), workspaceTrustHotReloadAvailable: deps.workspaceTrustHotReloadAvailable === true, isPrimaryWorkspaceTrusted: () => isPrimaryWorkspaceTrusted(), @@ -2302,6 +2323,10 @@ export function createServeApp( gitState: workspaceGitState, sendBridgeError, }); + registerWorkspaceLocalOpenRoutes(app, { + workspaceRegistry, + mutate, + }); registerWorkspaceGitDiffRoutes(app, { boundWorkspace: primaryBoundWorkspace, sendBridgeError, diff --git a/packages/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index 2565621b2cd..beda034ff7f 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -61,6 +61,8 @@ interface CreateServeFeaturesDeps { workspaceRuntimeRemovalAvailable?: boolean; nativeDirectoryPickerAvailable?: boolean; workspaceRuntimeAvailable: () => boolean; + localPathOpenAvailable?: boolean; + localTerminalOpenAvailable?: boolean; workspaceTrustHotReloadAvailable?: boolean; isPrimaryWorkspaceTrusted?: () => boolean; env?: Readonly>; @@ -99,6 +101,8 @@ export function createServeFeatures( workspaceRuntimeRemovalAvailable, nativeDirectoryPickerAvailable, workspaceRuntimeAvailable, + localPathOpenAvailable, + localTerminalOpenAvailable, workspaceTrustHotReloadAvailable, } = deps; const getEnv = deps.getEnv ?? (() => deps.env ?? process.env); @@ -154,6 +158,8 @@ export function createServeFeatures( workspaceRuntimeRemovalAvailable, nativeDirectoryPickerAvailable, workspaceRuntimeAvailable: workspaceRuntimeAvailable(), + localPathOpenAvailable, + localTerminalOpenAvailable, workspaceTrustHotReloadAvailable, acpHttpEnabled: currentAcpHttpEnabled, realtimeVoiceEnabled: realtimeVoiceEnabled(), diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 3e589d0e7b9..ab61241bf69 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -5950,6 +5950,36 @@ export class WorkspaceDaemonClient { ); } + /** + * Ask the daemon host to open this workspace's directory in the host's OS + * file manager (Finder/Explorer/xdg-open). Only advertised via the + * `workspace_local_open` capability; on a headless host the daemon answers + * 501 `local_path_open_unavailable`. + */ + async openLocally(): Promise { + await this.client.workspaceJsonRequest( + this.workspaceSelector, + '/open', + 'POST /workspaces/:workspace/open', + { method: 'POST', body: {}, mode: 'rest' }, + ); + } + + /** + * Ask the daemon host to open a terminal window in this workspace's + * directory (Terminal.app/wt.exe/a Linux terminal emulator). Only + * advertised via the `workspace_local_terminal` capability; on a headless + * host the daemon answers 501 `local_path_open_unavailable`. + */ + async openTerminalLocally(): Promise { + await this.client.workspaceJsonRequest( + this.workspaceSelector, + '/open', + 'POST /workspaces/:workspace/open', + { method: 'POST', body: { target: 'terminal' }, mode: 'rest' }, + ); + } + /** * Send text directly through this exact workspace's channel worker. * A successful capability pre-flight does not guarantee worker liveness; diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 9e67478b248..ba8cac67f65 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1324,6 +1324,46 @@ describe('DaemonClient', () => { ]); }); + it('asks the daemon host to open the workspace locally', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + kind: 'workspace-local-open', + opened: true, + target: 'folder', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.workspaceByCwd('/work/secondary').openLocally(), + ).resolves.toBeUndefined(); + + expect(calls.map((call) => [call.method, call.url])).toEqual([ + ['POST', 'http://daemon/workspaces/%2Fwork%2Fsecondary/open'], + ]); + expect(calls.map((call) => call.body)).toEqual(['{}']); + }); + + it('asks the daemon host to open a terminal in the workspace', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + kind: 'workspace-local-open', + opened: true, + target: 'terminal', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.workspaceByCwd('/work/secondary').openTerminalLocally(), + ).resolves.toBeUndefined(); + + expect(calls.map((call) => [call.method, call.url])).toEqual([ + ['POST', 'http://daemon/workspaces/%2Fwork%2Fsecondary/open'], + ]); + expect(calls.map((call) => call.body)).toEqual(['{"target":"terminal"}']); + }); + it('reads primary and workspace-qualified Git status over REST', async () => { const primary = { v: 1 as const, diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index e9e381913f6..3c7c07d8cd2 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -404,7 +404,6 @@ interface ChatEditorRenderProps { builtinAtProviders?: WebShellCustomization['builtinAtProviders']; atProviders?: WebShellCustomization['atProviders']; skills?: Array<{ name: string; description: string }>; - language?: WebShellLanguage; reasoning?: DaemonReasoningControls; onSelectReasoningEffort?: (value: ReasoningSelection) => Promise | void; } @@ -422,7 +421,6 @@ function renderChatEditorInto( language = 'en', renderComposerTagTooltip, onComposerTagClick, - language = 'en', ...chatEditorProps } = props; if (composerTags) { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 20a6f136afa..66c5394a42f 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -475,19 +475,22 @@ } .workspaceHeaderActions { + position: absolute; + top: 50%; + right: 6px; display: flex; - flex: 0 0 auto; align-items: center; - gap: 2px; - margin-left: auto; - margin-right: 8px; + gap: 1px; + border-radius: 6px; + background: var(--sidebar-accent); + transform: translateY(-50%); } .workspaceHeaderAction { appearance: none; - width: 24px; - height: 24px; - flex: 0 0 24px; + width: 22px; + height: 22px; + flex: 0 0 22px; display: inline-flex; align-items: center; justify-content: center; @@ -547,7 +550,7 @@ flex: 0 0 auto; display: flex; flex-direction: column; - gap: 4px; + gap: 2px; overflow: visible; } @@ -676,12 +679,12 @@ .showAllSessions { align-self: flex-start; margin-left: 26px; - padding: 2px 0; + padding: 1px 0; border: 0; background: transparent; color: var(--muted-foreground); - font-size: 14px; - line-height: 22px; + font-size: 12px; + line-height: 18px; cursor: pointer; } @@ -703,7 +706,7 @@ gap: 2px; padding: 2px 0 2px 26px; border-radius: 8px; - color: color-mix(in srgb, var(--sidebar-foreground) 68%, transparent); + color: color-mix(in srgb, var(--sidebar-foreground) 78%, transparent); cursor: pointer; } @@ -728,6 +731,19 @@ color: var(--sidebar-accent-foreground); } +/* Active-row marker on the left edge, mirroring editor list conventions. */ +.currentSession::before { + content: ''; + position: absolute; + top: 50%; + left: 3px; + width: 2px; + height: 14px; + border-radius: 1px; + background: var(--primary); + transform: translateY(-50%); +} + .busySession { opacity: 0.72; pointer-events: none; @@ -778,6 +794,56 @@ color: var(--popover-foreground); } +.sessionDetailsRowValue { + margin-left: auto; + padding-left: 16px; + color: var(--muted-foreground); + font-variant-numeric: tabular-nums; +} + +.sessionDetailsRowIssue, +.sessionDetailsRowIssue .sessionDetailsRowValue { + color: var(--warning-color, #d29922); +} + +/* Session counts inside the workspace details popover — the same dot-count + language the folder header used before the counts moved here. */ +.sessionDetailsSessionCounts { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + padding-left: 16px; + color: var(--muted-foreground); + font-variant-numeric: tabular-nums; +} + +.sessionDetailsSessionCount { + display: inline-flex; + align-items: center; + gap: 3px; +} + +.sessionDetailsSessionCount::before { + content: ''; + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} + +.sessionDetailsSessionCountRunning { + color: var(--success-color, #3fb950); +} + +.sessionDetailsSessionCountAttention { + color: var(--warning-color, #d29922); +} + +.sessionDetailsSessionCountTotal::before { + display: none; +} + .sessionDetailsRow svg { width: 14px; height: 14px; @@ -855,6 +921,25 @@ font-size: 12px; } +/* Path row in the workspace details popover: the path truncates, the + local-open action buttons pin to the row's right edge. */ +.sessionDetailsPath { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sessionDetailsRowActions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 2px; + margin-left: auto; + padding-left: 8px; +} + .sessionText { position: relative; min-width: 0; @@ -862,8 +947,8 @@ margin-right: 4px; overflow: hidden; color: currentColor; - font-size: 14px; - line-height: 22px; + font-size: 13px; + line-height: 20px; } /* The fade hints at a clipped tail; only overflowing titles clip, and the @@ -933,6 +1018,12 @@ box-shadow: 0 0 0 1.5px var(--sidebar-background); } +/* A running turn gets the same pulsing dot in green, so busy sessions are + scannable without waiting for the right-edge spinner. */ +.sessionStatusDotRunning { + color: var(--success-color, #3fb950); +} + .sessionSourceIcon { width: 18px; height: 18px; diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 3eeb4eace0e..0791c09d638 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -83,7 +83,7 @@ import { DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; -import { formatRelativeTime } from '../../utils/formatRelativeTime'; +import { formatDateTime } from '../../utils/formatDateTime'; import { DialogShell } from '../dialogs/DialogShell'; import { WorkspaceSection, isAbsolutePath } from './WorkspaceSection'; import { WorkspaceMenu, type WorkspaceMenuActions } from './WorkspaceMenu'; @@ -95,6 +95,7 @@ import { type WorkspaceOverviewItem, } from './workspaceOverviewModel'; import { writeClipboardText } from '../../utils/clipboard'; +import { isLocalDaemon } from '../../config/daemon'; import { mergeSessionContentHits, sessionMatchesGitQuery, @@ -1422,6 +1423,16 @@ export function WebShellSidebar({ 'dynamic_workspace_registration', ), ); + // Host-local affordance: the daemon opens the folder on ITS host, so the + // button is only honest when the browser sits on that same machine. + const localOpenEnabled = + Boolean( + connection.capabilities?.features?.includes('workspace_local_open'), + ) && isLocalDaemon(); + const localTerminalEnabled = + Boolean( + connection.capabilities?.features?.includes('workspace_local_terminal'), + ) && isLocalDaemon(); const workspaceOverviewEnabled = workspaceOverview !== false; const workspaceOverviewItems = workspaceOverview === false @@ -2519,6 +2530,32 @@ export function WebShellSidebar({ [onError, t], ); + const openWorkspaceFolderLocally = useCallback( + async (cwd: string): Promise => { + try { + await workspace.client.workspaceByCwd(cwd).openLocally(); + } catch (error) { + onError(error, t('sidebar.openWorkspaceFolderFailed')); + // Rethrow so the hover popover keeps its idle icon on a failure the + // toast already reported. + throw error; + } + }, + [onError, t, workspace.client], + ); + + const openWorkspaceTerminalLocally = useCallback( + async (cwd: string): Promise => { + try { + await workspace.client.workspaceByCwd(cwd).openTerminalLocally(); + } catch (error) { + onError(error, t('sidebar.openWorkspaceTerminalFailed')); + throw error; + } + }, + [onError, t, workspace.client], + ); + const reloadWorkspaceRuntime = useCallback( (candidate: DaemonWorkspaceCapability) => { // Deferred so a client without the reload method (older SDK) reports @@ -4156,7 +4193,8 @@ export function WebShellSidebar({ const sessionIdentity = getIdentityForSession(session); const label = getSessionLabel(session); const stamp = session.updatedAt || session.createdAt; - const time = stamp ? formatRelativeTime(stamp, t) : ''; + // Rows stay text-only; the precise date lives in the hover popover. + const time = stamp ? formatDateTime(stamp) : ''; const busy = busySessionIds.has(sessionIdentity); const exporting = exportingSessionIds.has(sessionIdentity); const completedUnread = @@ -4427,6 +4465,18 @@ export function WebShellSidebar({ aria-hidden="true" /> ) : null} + {session.hasActivePrompt && + !scheduledTaskIcon && + !completedUnread ? ( + {isEditing && canRenameSession(session) ? (
{ + void openWorkspaceFolderLocally( + ws.cwd, + ).catch(() => undefined); + }, + } + : {}), + ...(localTerminalEnabled && + ws.trusted && + realPath + ? { + openTerminal: () => { + void openWorkspaceTerminalLocally( + ws.cwd, + ).catch(() => undefined); + }, + } + : {}), ...(ws.trusted ? { newSession: () => @@ -5937,11 +6018,23 @@ export function WebShellSidebar({ } : {}), }; + // The section caps the folder name so the + // git chip never slides under this overlay; + // the count drives the cap's width. The + // menu trigger is absent under a lock. + const headerActionCount = + (ws.trusted + ? 1 + + Number(canOrganizeWorkspace(ws.cwd)) + : 0) + (lockedWorkspaceCwd ? 0 : 1); return (
{ + // Radix restores focus to the + // trigger, which lives inside the + // details popover's focus-open + // anchor — without suppression the + // popover reopens 300 ms after + // every menu close and never + // closes. + event.preventDefault(); + }} /> )}
diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx index 670efa6a481..a4edb3a9588 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx @@ -3524,21 +3524,105 @@ describe('WebShellSidebar workspace removal', () => { expect(workspaceGit).not.toHaveBeenCalled(); }); - it('renders only the facets the embedder selected', async () => { + it('fetches and shows only the facets the embedder selected', async () => { + const mcpServer = { + kind: 'mcp_server', + name: 'github', + status: 'ok', + transport: 'stdio', + disabled: false, + mcpStatus: 'connected', + }; + const workspaceMcp = vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: '/tmp/other', + initialized: true, + discoveryState: 'completed', + servers: [mcpServer], + }); + const workspaceSkills = vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: '/tmp/other', + initialized: true, + skills: [], + }); + const previous = workspace.client.workspaceByCwd.getMockImplementation(); + workspace.client.workspaceByCwd.mockImplementation((cwd: string) => ({ + ...(previous?.(cwd) ?? {}), + workspaceMcp, + workspaceSkills, + })); renderSidebar({ workspaceOverview: { items: ['mcp'] } }); - // Chips appear once the first facet round lands. await act(async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); - const kinds = Array.from( - container.querySelectorAll( - '[data-web-shell-workspace-overview]', - ), - ).map((chip) => chip.getAttribute('data-web-shell-workspace-overview')); - expect(kinds.length).toBeGreaterThan(0); - expect(new Set(kinds)).toEqual(new Set(['mcp'])); + // The selection drives the fetch: skills is never requested. + expect(workspaceMcp).toHaveBeenCalled(); + expect(workspaceSkills).not.toHaveBeenCalled(); + + // Hovering the header lists only the selected facet in the popover. + const header = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('other')); + vi.useFakeTimers(); + await act(async () => { + header?.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + vi.useRealTimers(); + const rows = document.querySelectorAll( + '[role="dialog"] [data-web-shell-workspace-overview]', + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.getAttribute('data-web-shell-workspace-overview')).toBe( + 'mcp', + ); + expect(rows[0]?.textContent).toBe('MCP1/1'); + }); + + it('does not reopen the details popover after a workspace menu selection', async () => { + renderSidebar(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const header = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('other')); + expect(header).toBeDefined(); + + // Hover opens the workspace details popover (300 ms delay, real timers + // so Radix's focus-restore rAF behaves like production). + await act(async () => { + header!.dispatchEvent(new Event('pointerover', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + + // Open the workspace menu (this closes the popover) and pick an item. + await act(async () => { + click(workspaceAction('/tmp/other')!); + await Promise.resolve(); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + const item = Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((element) => element.textContent === 'Copy path'); + expect(item).toBeDefined(); + await act(async () => { + click(item!); + await Promise.resolve(); + }); + + // Radix restoring focus to the trigger inside the focus-open anchor must + // not reopen the details popover 300 ms later. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 450)); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); }); it('counts the registered workspaces next to the Projects label', () => { @@ -3623,12 +3707,14 @@ describe('WebShellSidebar workspace removal', () => { it('keeps plain folder headers when the overview is switched off', () => { renderSidebar({ workspaceOverview: false }); expect( - container.querySelector('[data-web-shell-workspace-path]'), + document.querySelector('[data-web-shell-workspace-path]'), + ).toBeNull(); + expect( + document.querySelector('[data-web-shell-workspace-overview]'), ).toBeNull(); expect( - container.querySelector('[data-web-shell-workspace-overview]'), + document.querySelector('[data-web-shell-workspace-sessions]'), ).toBeNull(); - expect(container.querySelector('[class*="headerCounts"]')).toBeNull(); expect( container.querySelector('[class*="projectsHeaderCount"]'), ).toBeNull(); diff --git a/packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.test.tsx new file mode 100644 index 00000000000..d3900b0cde5 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.test.tsx @@ -0,0 +1,494 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { I18nProvider } from '../../i18n'; +import { WorkspaceDetailsTooltip } from './WorkspaceDetailsTooltip'; +import type { WorkspaceOverviewSnapshot } from './workspaceOverviewModel'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + vi.useRealTimers(); + await act(async () => { + root.unmount(); + }); + container.remove(); + document.body.replaceChildren(); +}); + +const snapshot: WorkspaceOverviewSnapshot = { + mcp: { + initialized: true, + discoveryState: 'completed', + configured: 3, + connected: 1, + failed: 1, + disabled: 1, + }, + skills: { initialized: true, total: 2, enabled: 1 }, + extensions: { total: 0, active: 0 }, + channels: { configured: 0, connected: 0, failed: 0 }, + context: { initialized: true, fileCount: 0, ruleCount: 0 }, + fetchedAt: 1, +}; + +async function openDetails(node: ReactNode): Promise { + vi.useFakeTimers(); + await act(async () => { + root.render({node}); + }); + const trigger = container.querySelector('button'); + if (!trigger) throw new Error('trigger was not rendered'); + await act(async () => { + trigger.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + const details = document.querySelector('[role="dialog"]'); + expect(details).not.toBeNull(); + return details!; +} + +function facetRow(details: HTMLElement, item: string): HTMLElement | null { + return details.querySelector( + `[data-web-shell-workspace-overview="${item}"]`, + ); +} + +describe('WorkspaceDetailsTooltip', () => { + it('shows the path, branch and non-zero facets on hover', async () => { + const details = await openDetails( + + + , + ); + + expect(details.textContent).toContain('qwen-code'); + expect( + details.querySelector('[data-web-shell-workspace-path]')?.textContent, + ).toBe('/work/qwen-code'); + expect(details.textContent).toContain('main'); + expect(facetRow(details, 'mcp')?.textContent).toBe('MCP1/2'); + expect(facetRow(details, 'mcp')?.getAttribute('title')).toBe( + 'MCP: 1 of 3 connected, 1 failed, 1 disabled', + ); + expect(facetRow(details, 'mcp')?.getAttribute('aria-label')).toBe( + 'MCP: 1 of 3 connected, 1 failed, 1 disabled', + ); + expect(facetRow(details, 'skills')?.textContent).toBe('Skills1'); + // The popover takes no persistent space, so known zeros show too. + expect(facetRow(details, 'extensions')?.textContent).toBe('Extensions0'); + expect(facetRow(details, 'channels')?.textContent).toBe('Channels0'); + expect(facetRow(details, 'context')?.textContent).toBe('Context0'); + }); + + it('marks a facet with an issue in the warning tone', async () => { + const details = await openDetails( + + + , + ); + expect(facetRow(details, 'mcp')?.className).toMatch( + /sessionDetailsRowIssue/, + ); + expect(facetRow(details, 'skills')?.className).not.toMatch( + /sessionDetailsRowIssue/, + ); + }); + + it('omits the path row and every facet when there is nothing to show', async () => { + const details = await openDetails( + + + , + ); + expect(details.textContent).toContain('My Project'); + expect(details.querySelector('[data-web-shell-workspace-path]')).toBeNull(); + expect(facetRow(details, 'mcp')).toBeNull(); + }); + + it('skips unknown facets instead of rendering placeholders', async () => { + const details = await openDetails( + + + , + ); + expect(facetRow(details, 'skills')).toBeNull(); + expect(facetRow(details, 'extensions')).toBeNull(); + }); + + it('hides the open-folder button unless the handler is wired', async () => { + const details = await openDetails( + + + , + ); + expect( + details.querySelector('[data-web-shell-open-workspace-folder]'), + ).toBeNull(); + }); + + it('dispatches the open-folder handler and confirms with a check', async () => { + const onOpenPathLocally = vi.fn().mockResolvedValue(undefined); + const details = await openDetails( + + + , + ); + const openButton = details.querySelector( + '[data-web-shell-open-workspace-folder]', + ); + expect(openButton).not.toBeNull(); + await act(async () => { + openButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect(onOpenPathLocally).toHaveBeenCalledTimes(1); + // Success swaps the folder icon for the check. + expect(openButton!.querySelector('svg.lucide-check')).not.toBeNull(); + expect(openButton!.querySelector('svg.lucide-folder-open')).toBeNull(); + }); + + it('keeps the idle icon when opening fails', async () => { + const onOpenPathLocally = vi + .fn() + .mockRejectedValue(new Error('no display')); + const details = await openDetails( + + + , + ); + const openButton = details.querySelector( + '[data-web-shell-open-workspace-folder]', + ); + await act(async () => { + openButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect(onOpenPathLocally).toHaveBeenCalledTimes(1); + // A rejection is swallowed (the sidebar toasts): no false check appears + // and the folder icon stays. + expect(openButton!.querySelector('svg.lucide-check')).toBeNull(); + expect(openButton!.querySelector('svg.lucide-folder-open')).not.toBeNull(); + }); + + it('shows session counts with a breakdown tooltip', async () => { + const details = await openDetails( + + + , + ); + const row = details.querySelector( + '[data-web-shell-workspace-sessions]', + ); + expect(row?.textContent).toBe('Sessions214+'); + expect(row?.getAttribute('aria-label')).toBe( + '2 sessions waiting for you · 1 running session · 4+ sessions', + ); + expect(row?.querySelector('[class*="CountRunning"]')?.className).toMatch( + /CountRunning/, + ); + expect(row?.querySelector('[class*="CountAttention"]')?.className).toMatch( + /CountAttention/, + ); + }); + + it('omits the sessions row when there is nothing to count', async () => { + const details = await openDetails( + + + , + ); + expect( + details.querySelector('[data-web-shell-workspace-sessions]'), + ).toBeNull(); + }); + + it('dispatches the open-terminal handler from the path row', async () => { + const onOpenTerminalLocally = vi.fn().mockResolvedValue(undefined); + const details = await openDetails( + + + , + ); + const openButton = details.querySelector( + '[data-web-shell-open-workspace-terminal]', + ); + expect(openButton).not.toBeNull(); + // The folder button stays hidden without its own handler. + expect( + details.querySelector('[data-web-shell-open-workspace-folder]'), + ).toBeNull(); + await act(async () => { + openButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect(onOpenTerminalLocally).toHaveBeenCalledTimes(1); + }); + + it('opens on keyboard focus with the same delay as hover', async () => { + vi.useFakeTimers(); + await act(async () => { + root.render( + + + + + , + ); + }); + const trigger = container.querySelector('button'); + await act(async () => { + trigger!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + const details = document.querySelector('[role="dialog"]'); + expect(details).not.toBeNull(); + expect(details?.textContent).toContain('/work/qwen-code'); + }); + + it('announces a successful open through a live region', async () => { + const onOpenPathLocally = vi.fn().mockResolvedValue(undefined); + const details = await openDetails( + + + , + ); + const openButton = details.querySelector( + '[data-web-shell-open-workspace-folder]', + ); + await act(async () => { + openButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect(details.querySelector('[aria-live="polite"]')?.textContent).toBe( + 'Opened the workspace folder', + ); + }); + + it('ignores clicks while an open request is in flight', async () => { + let resolveOpen: () => void = () => {}; + const onOpenPathLocally = vi.fn( + () => + new Promise((resolve) => { + resolveOpen = resolve; + }), + ); + const details = await openDetails( + + + , + ); + const openButton = details.querySelector( + '[data-web-shell-open-workspace-folder]', + ); + await act(async () => { + openButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + await act(async () => { + openButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect(onOpenPathLocally).toHaveBeenCalledTimes(1); + await act(async () => { + resolveOpen(); + await Promise.resolve(); + }); + // Released after the request settles: a follow-up click fires again. + await act(async () => { + openButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect(onOpenPathLocally).toHaveBeenCalledTimes(2); + }); + + it('does not open when the pointer leaves before the open delay elapses', async () => { + vi.useFakeTimers(); + await act(async () => { + root.render( + + + + + , + ); + }); + const trigger = container.querySelector('button'); + await act(async () => { + trigger!.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + await act(async () => { + trigger!.dispatchEvent(new Event('pointerout', { bubbles: true })); + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + + it('does not open before the 300ms hover delay', async () => { + vi.useFakeTimers(); + await act(async () => { + root.render( + + + + + , + ); + }); + const trigger = container.querySelector('button'); + await act(async () => { + trigger!.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(299); + await Promise.resolve(); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + + it('keeps focus on the anchor when the popover opens', async () => { + const details = await openDetails( + + + , + ); + expect(details).not.toBeNull(); + // onOpenAutoFocus is prevented: opening must not steal focus. + expect(document.activeElement).not.toBe(details); + }); + + it('resets the check icon two seconds after a successful open', async () => { + const onOpenPathLocally = vi.fn().mockResolvedValue(undefined); + const details = await openDetails( + + + , + ); + const openButton = details.querySelector( + '[data-web-shell-open-workspace-folder]', + ); + await act(async () => { + openButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect(openButton!.querySelector('svg.lucide-check')).not.toBeNull(); + await act(async () => { + vi.advanceTimersByTime(2_000); + await Promise.resolve(); + }); + expect(openButton!.querySelector('svg.lucide-check')).toBeNull(); + expect(openButton!.querySelector('svg.lucide-folder-open')).not.toBeNull(); + expect(details.querySelector('[aria-live="polite"]')?.textContent).toBe(''); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.tsx b/packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.tsx new file mode 100644 index 00000000000..486f7521daf --- /dev/null +++ b/packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.tsx @@ -0,0 +1,396 @@ +import { + useEffect, + useRef, + useState, + type ComponentType, + type ReactElement, +} from 'react'; +import { + BlocksIcon, + CheckIcon, + FileTextIcon, + FolderClosedIcon, + FolderOpenIcon, + GitBranchIcon, + MessageSquareIcon, + PlugIcon, + RadioTowerIcon, + SparklesIcon, + TerminalIcon, + WebhookIcon, +} from 'lucide-react'; +import { useI18n } from '../../i18n'; +import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover'; +import { + formatOverviewValue, + overviewDetail, + overviewFacetHasIssue, + type WorkspaceOverviewItem, + type WorkspaceOverviewSnapshot, + type WorkspaceSessionStats, +} from './workspaceOverviewModel'; +import { resolveSessionDetailsCollisionBoundary } from './sessionDetailsCollisionBoundary'; +import sidebarStyles from './WebShellSidebar.module.css'; + +function cx(...classes: Array): string { + return classes.filter(Boolean).join(' '); +} + +const ICONS: Record> = { + mcp: PlugIcon, + skills: SparklesIcon, + extensions: BlocksIcon, + channels: RadioTowerIcon, + context: FileTextIcon, + hooks: WebhookIcon, +}; + +interface WorkspaceDetailsTooltipProps { + label: string; + /** Real filesystem path; undefined for a synthetic fallback workspace. */ + cwd?: string; + branch?: string | null; + /** Session counts lifted out of the header into this popover. */ + sessions?: WorkspaceSessionStats; + overview: WorkspaceOverviewSnapshot | undefined; + items: readonly WorkspaceOverviewItem[]; + /** + * Open the workspace folder in the daemon host's file manager. Only wired + * when the daemon advertises `workspace_local_open` and the browser is on + * the same machine; rejects when the host could not open it. + */ + onOpenPathLocally?: () => Promise; + /** + * Open a terminal at the workspace path on the daemon host. Only wired + * when the daemon advertises `workspace_local_terminal` and the browser is + * on the same machine; rejects when the host could not open it. + */ + onOpenTerminalLocally?: () => Promise; + children: ReactElement; +} + +/** Icon button with a 2 s check confirmation, for the local-open actions. */ +function OpenLocallyButton({ + label, + announcement, + icon: Icon, + onOpen, + testId, +}: { + label: string; + /** Spoken via the live region on success; failures toast via onError. */ + announcement: string; + icon: ComponentType<{ size?: number }>; + onOpen: () => Promise; + testId: string; +}) { + const [opened, setOpened] = useState(false); + const [pending, setPending] = useState(false); + const [announced, setAnnounced] = useState(false); + const resetTimerRef = useRef(undefined); + useEffect(() => () => window.clearTimeout(resetTimerRef.current), []); + return ( + <> + + + {announced ? announcement : ''} + + + ); +} + +/** + * Hover details for a workspace header row: full path, git branch and the + * facet counts that used to sit as chips under the expanded row. The popover + * takes no persistent space, so known facets show even when their count is + * zero; only unknown (unreported) facets stay hidden. + */ +export function WorkspaceDetailsTooltip({ + label, + cwd, + branch, + sessions, + overview, + items, + onOpenPathLocally, + onOpenTerminalLocally, + children, +}: WorkspaceDetailsTooltipProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const openTimerRef = useRef(undefined); + const closeTimerRef = useRef(undefined); + const anchorRef = useRef(null); + const contentRef = useRef(null); + const collisionBoundary = open + ? resolveSessionDetailsCollisionBoundary( + anchorRef.current?.closest('aside') ?? null, + ) + : null; + + useEffect(() => { + return () => { + window.clearTimeout(openTimerRef.current); + window.clearTimeout(closeTimerRef.current); + }; + }, []); + + const cancelClose = () => window.clearTimeout(closeTimerRef.current); + const openAfterDelay = () => { + cancelClose(); + if (open) return; + window.clearTimeout(openTimerRef.current); + openTimerRef.current = window.setTimeout(() => setOpen(true), 300); + }; + const close = () => { + window.clearTimeout(openTimerRef.current); + cancelClose(); + setOpen(false); + }; + const closeAfterDelay = () => { + window.clearTimeout(openTimerRef.current); + cancelClose(); + closeTimerRef.current = window.setTimeout(close, 100); + }; + // The content is portaled, so "focus stayed inside" spans two trees: the + // anchor (header row) and the popover content. + const containsFocusTarget = (node: EventTarget | null): boolean => + node instanceof Node && + (anchorRef.current?.contains(node) === true || + contentRef.current?.contains(node) === true); + + const sessionsBreakdown = + sessions && sessions.total > 0 + ? [ + sessions.attention > 0 + ? t('sidebar.sessionsAttention', { count: sessions.attention }) + : undefined, + sessions.running > 0 + ? t('sidebar.sessionsRunning', { count: sessions.running }) + : undefined, + t('sidebar.sessionsTotal', { + count: sessions.total, + truncated: sessions.truncated ? 1 : 0, + }), + ] + .filter(Boolean) + .join(' · ') + : undefined; + + return ( + (nextOpen ? setOpen(true) : close())} + > + { + if (event.currentTarget.contains(event.target as Node)) { + openAfterDelay(); + } + }} + onPointerLeave={() => { + if (!containsFocusTarget(document.activeElement)) { + closeAfterDelay(); + } + }} + // Keyboard parity with hover: focusing the header button opens the + // details after the same delay; moving focus out closes them. + onFocus={(event) => { + if (event.currentTarget.contains(event.target as Node)) { + openAfterDelay(); + } + }} + onBlur={(event) => { + if (!containsFocusTarget(event.relatedTarget)) { + closeAfterDelay(); + } + }} + onPointerDownCapture={close} + onClick={() => close()} + > + {children} + + event.preventDefault()} + onPointerEnter={cancelClose} + onPointerLeave={() => { + // Keyboard parity: while focus lives inside the content the + // pointer leaving is not a dismiss signal. + if (!containsFocusTarget(document.activeElement)) { + closeAfterDelay(); + } + }} + onFocus={cancelClose} + onBlur={(event) => { + if (!containsFocusTarget(event.relatedTarget)) { + closeAfterDelay(); + } + }} + className={sidebarStyles.sessionDetailsTooltip} + > +
+ + {label} + +
+ {cwd && ( +
+
+ )} + {branch && ( +
+
+ )} + {sessions && sessions.total > 0 && ( +
+
+ )} + {overview && + items.map((item) => { + const value = formatOverviewValue(overview, item); + const issue = overviewFacetHasIssue(overview, item); + // Only an unknown facet (not reported yet, or unavailable on + // this daemon) earns no row; a known zero is real information. + if (value === undefined) return null; + const Icon = ICONS[item]; + const facetLabel = t(`sidebar.overview.${item}`); + const detail = overviewDetail(t, overview, item); + const title = `${facetLabel}: ${detail}`; + return ( +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/web-shell/client/components/sidebar/WorkspaceMenu.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceMenu.test.tsx index e99ca68840e..a3ec88d9885 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceMenu.test.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceMenu.test.tsx @@ -144,6 +144,37 @@ describe('WorkspaceMenu', () => { expect(document.body.querySelectorAll('[role="separator"]').length).toBe(1); }); + it('offers the local-open actions after Copy path and dispatches them', async () => { + const openFolder = vi.fn(); + const openTerminal = vi.fn(); + const actions = { + copyPath: vi.fn(), + openFolder, + openTerminal, + newSession: vi.fn(), + }; + await render(); + const items = await open(); + expect(labels(items)).toEqual([ + 'Copy path', + 'Open folder', + 'Open terminal', + 'New task', + ]); + await act(async () => { + click(items[1]!); + await Promise.resolve(); + }); + expect(openFolder).toHaveBeenCalledTimes(1); + // Selecting an item closes the menu; reopen for the next one. + const reopened = await open(); + await act(async () => { + click(reopened[2]!); + await Promise.resolve(); + }); + expect(openTerminal).toHaveBeenCalledTimes(1); + }); + it('shows the management group with live counts and dispatches its target', async () => { const openManagement = vi.fn(); await render( @@ -242,6 +273,8 @@ describe('WorkspaceMenu', () => { actions={{ rename: vi.fn(), copyPath: vi.fn(), + openFolder: vi.fn(), + openTerminal: vi.fn(), newSession: vi.fn(), newWorktreeSession: vi.fn(), openManagement: vi.fn(), @@ -254,6 +287,8 @@ describe('WorkspaceMenu', () => { expect(labels(items)).toEqual([ 'Rename…', 'Copy path', + 'Open folder', + 'Open terminal', 'New task', 'New worktree task', 'MCP', diff --git a/packages/web-shell/client/components/sidebar/WorkspaceMenu.tsx b/packages/web-shell/client/components/sidebar/WorkspaceMenu.tsx index ef9cabd62d7..8e86e2ee58e 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceMenu.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceMenu.tsx @@ -16,6 +16,7 @@ import { BlocksIcon, CopyIcon, EllipsisVerticalIcon, + FolderOpenIcon, GitForkIcon, PencilIcon, PlugIcon, @@ -24,6 +25,7 @@ import { SettingsIcon, SparklesIcon, SquarePenIcon, + TerminalIcon, Trash2Icon, } from 'lucide-react'; import { useI18n } from '../../i18n'; @@ -36,8 +38,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu'; -import { formatOverviewValue } from './WorkspaceOverview'; import { + formatOverviewValue, type WorkspaceManagementTarget, type WorkspaceOverviewSnapshot, } from './workspaceOverviewModel'; @@ -50,6 +52,8 @@ import { export interface WorkspaceMenuActions { rename?: () => void; copyPath?: () => void; + openFolder?: () => void; + openTerminal?: () => void; newSession?: () => void; newWorktreeSession?: () => void; openManagement?: (target: WorkspaceManagementTarget) => void; @@ -133,6 +137,22 @@ export function WorkspaceMenu({ , ); } + if (actions.openFolder) { + primary.push( + + + {t('sidebar.openWorkspaceFolder')} + , + ); + } + if (actions.openTerminal) { + primary.push( + + + {t('sidebar.openWorkspaceTerminal')} + , + ); + } if (actions.newSession) { primary.push( diff --git a/packages/web-shell/client/components/sidebar/WorkspaceOverview.module.css b/packages/web-shell/client/components/sidebar/WorkspaceOverview.module.css deleted file mode 100644 index 70a765249ab..00000000000 --- a/packages/web-shell/client/components/sidebar/WorkspaceOverview.module.css +++ /dev/null @@ -1,66 +0,0 @@ -/* Facet chips under an expanded workspace header. */ -.chips { - display: flex; - flex-wrap: wrap; - gap: 4px; - padding: 2px 8px 6px 30px; -} - -.chipsCompact { - padding-left: 26px; -} - -.chipItem { - display: inline-flex; - min-width: 0; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 4px; - max-width: 100%; - height: 18px; - padding: 0 6px; - border: 0; - border-radius: 9px; - background: var(--sidebar-accent); - color: var(--muted-foreground); - font: inherit; - font-size: 11px; - line-height: 1; - white-space: nowrap; -} - -.chip svg { - flex: 0 0 auto; - display: block; - stroke: currentColor; - fill: none; - stroke-width: 1.8; - stroke-linecap: round; - stroke-linejoin: round; -} - -.chipLabel { - overflow: hidden; - text-overflow: ellipsis; -} - -.chipValue { - font-variant-numeric: tabular-nums; - color: var(--sidebar-foreground); -} - -.chipUnknown .chipValue { - color: var(--muted-foreground); -} - -.chipIssue { - background: color-mix(in srgb, var(--warning-color, #d29922) 18%, transparent); - color: var(--warning-color, #d29922); -} - -.chipIssue .chipValue { - color: inherit; -} diff --git a/packages/web-shell/client/components/sidebar/WorkspaceOverview.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceOverview.test.tsx deleted file mode 100644 index 51f04af5503..00000000000 --- a/packages/web-shell/client/components/sidebar/WorkspaceOverview.test.tsx +++ /dev/null @@ -1,353 +0,0 @@ -// @vitest-environment jsdom -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import type { ReactNode } from 'react'; -import { I18nProvider } from '../../i18n'; -import { WorkspaceOverview, formatOverviewValue } from './WorkspaceOverview'; -import type { WorkspaceOverviewSnapshot } from './workspaceOverviewModel'; - -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => { - root.unmount(); - }); - container.remove(); -}); - -async function render(node: ReactNode): Promise { - await act(async () => { - root.render({node}); - }); -} - -const snapshot: WorkspaceOverviewSnapshot = { - mcp: { - initialized: true, - discoveryState: 'completed', - configured: 4, - connected: 3, - failed: 1, - disabled: 0, - }, - skills: { initialized: true, total: 12, enabled: 11 }, - extensions: { total: 4, active: 4 }, - channels: { configured: 2, connected: 2, failed: 0 }, - context: { initialized: true, fileCount: 2, ruleCount: 5 }, - fetchedAt: 1, -}; - -function chip(item: string): HTMLElement { - const element = container.querySelector( - `[data-web-shell-workspace-overview="${item}"]`, - ); - expect(element).not.toBeNull(); - return element!; -} - -describe('formatOverviewValue', () => { - it('formats known facets and leaves unknown ones undefined', () => { - expect(formatOverviewValue(snapshot, 'mcp')).toBe('3/4'); - expect(formatOverviewValue(snapshot, 'skills')).toBe('11'); - expect(formatOverviewValue(snapshot, 'extensions')).toBe('4'); - expect(formatOverviewValue(snapshot, 'channels')).toBe('2/2'); - // Context shows the file count, never the rule count or their sum. - expect(formatOverviewValue(snapshot, 'context')).toBe('2'); - // The daemon reads context files itself: no files is a known zero. - expect( - formatOverviewValue( - { - context: { initialized: false, fileCount: 0, ruleCount: 0 }, - fetchedAt: 1, - }, - 'context', - ), - ).toBe('0'); - // A runtime facet that has not reported is unknown, never "0". - expect( - formatOverviewValue( - { - mcp: { - initialized: false, - configured: 0, - connected: 0, - failed: 0, - disabled: 0, - }, - fetchedAt: 1, - }, - 'mcp', - ), - ).toBeUndefined(); - expect(formatOverviewValue(undefined, 'mcp')).toBeUndefined(); - }); - - it('counts MCP against enabled servers only', () => { - expect( - formatOverviewValue( - { - mcp: { - initialized: true, - configured: 2, - connected: 0, - failed: 0, - disabled: 2, - }, - fetchedAt: 1, - }, - 'mcp', - ), - ).toBe('0'); - expect( - formatOverviewValue( - { - mcp: { - initialized: true, - configured: 4, - connected: 2, - failed: 1, - disabled: 1, - }, - fetchedAt: 1, - }, - 'mcp', - ), - ).toBe('2/3'); - }); - - it('explains the disabled servers the chip denominator leaves out', async () => { - await render( - , - ); - expect(chip('mcp').textContent).toBe('MCP2/3'); - expect(chip('mcp').getAttribute('title')).toBe( - 'MCP: 2 of 4 connected, 1 failed, 1 disabled', - ); - }); - - it('renders an uninitialized skills placeholder as unknown', async () => { - await render( - , - ); - expect(chip('skills').textContent).toBe('Skills—'); - expect(chip('skills').getAttribute('title')).toBe( - 'Skills: not initialized yet', - ); - }); - - it('renders a workspace without channel instances as 0, not 0/0', () => { - expect( - formatOverviewValue( - { channels: { configured: 0, connected: 0, failed: 0 }, fetchedAt: 1 }, - 'channels', - ), - ).toBe('0'); - }); - - it('keeps an uninitialized skills placeholder unknown', () => { - expect( - formatOverviewValue( - { - skills: { initialized: false, total: 0, enabled: 0 }, - fetchedAt: 1, - }, - 'skills', - ), - ).toBeUndefined(); - }); - - it('shows the active/total split only when they differ', () => { - expect( - formatOverviewValue( - { extensions: { total: 4, active: 2 }, fetchedAt: 1 }, - 'extensions', - ), - ).toBe('2/4'); - }); -}); - -describe('WorkspaceOverview', () => { - it('renders one chip per item with value, label and detail tooltip', async () => { - await render( - , - ); - expect(chip('mcp').textContent).toBe('MCP3/4'); - expect(chip('mcp').getAttribute('title')).toBe( - 'MCP: 3 of 4 connected, 1 failed', - ); - expect(chip('skills').textContent).toBe('Skills11'); - expect(chip('skills').getAttribute('title')).toBe( - 'Skills: 11 of 12 enabled', - ); - expect(chip('context').textContent).toBe('Context2'); - expect(chip('context').getAttribute('title')).toBe( - 'Context: 2 context files, 5 rules', - ); - expect(container.querySelector('[role="list"]')).not.toBeNull(); - }); - - it('marks a facet with an issue and drops labels in compact mode', async () => { - await render( - , - ); - expect(chip('mcp').className).toMatch(/chipIssue/); - expect(chip('skills').className).not.toMatch(/chipIssue/); - expect(chip('mcp').textContent).toBe('3/4'); - expect(chip('mcp').getAttribute('aria-label')).toBe( - 'MCP: 3 of 4 connected, 1 failed', - ); - }); - - it('keeps chips out of the button role', async () => { - await render(); - expect(chip('mcp').tagName).toBe('SPAN'); - expect(container.querySelector('button')).toBeNull(); - }); - - it('renders the opt-in hooks facet as unknown until initialized, then as a count', async () => { - await render( - , - ); - expect(chip('hooks').textContent).toBe('Hooks—'); - expect(chip('hooks').getAttribute('title')).toBe( - 'Hooks: not initialized yet', - ); - await render( - , - ); - expect(chip('hooks').textContent).toBe('Hooks3'); - expect(chip('hooks').getAttribute('title')).toBe( - 'Hooks: 3 hooks (disabled)', - ); - await render( - , - ); - expect(chip('hooks').getAttribute('title')).toBe('Hooks: 1 hook'); - }); - - it('calls a missing daemon-side facet unavailable, not uninitialized', async () => { - await render( - , - ); - expect(chip('extensions').getAttribute('title')).toBe( - 'Extensions: unavailable on this daemon', - ); - expect(chip('channels').getAttribute('title')).toBe( - 'Channels: unavailable on this daemon', - ); - expect(chip('context').getAttribute('title')).toBe( - 'Context: unavailable on this daemon', - ); - expect(chip('mcp').getAttribute('title')).toBe('MCP: not initialized yet'); - }); - - it('spells out the extensions and channels tooltips', async () => { - await render( - , - ); - expect(chip('extensions').getAttribute('title')).toBe( - 'Extensions: 4 of 4 active', - ); - expect(chip('channels').getAttribute('title')).toBe( - 'Channels: 2 of 2 connected', - ); - await render( - , - ); - expect(chip('extensions').getAttribute('title')).toBe( - 'Extensions: 2 of 4 active', - ); - expect(chip('channels').getAttribute('title')).toBe( - 'Channels: 1 of 2 connected, 1 failed', - ); - expect(chip('channels').className).toMatch(/chipIssue/); - }); - - it('renders nothing until the first snapshot lands', async () => { - await render( - , - ); - expect(container.innerHTML).toBe(''); - }); - - it('renders nothing for an empty item list', async () => { - await render(); - expect(container.innerHTML).toBe(''); - }); -}); diff --git a/packages/web-shell/client/components/sidebar/WorkspaceOverview.tsx b/packages/web-shell/client/components/sidebar/WorkspaceOverview.tsx deleted file mode 100644 index 6a97cf24085..00000000000 --- a/packages/web-shell/client/components/sidebar/WorkspaceOverview.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ComponentType } from 'react'; -import { - BlocksIcon, - FileTextIcon, - PlugIcon, - RadioTowerIcon, - SparklesIcon, - WebhookIcon, -} from 'lucide-react'; -import { useI18n } from '../../i18n'; -import { - isOverviewFacetKnown, - isRuntimeDiscoveredFacet, - overviewFacetHasIssue, - type WorkspaceOverviewItem, - type WorkspaceOverviewSnapshot, -} from './workspaceOverviewModel'; -import styles from './WorkspaceOverview.module.css'; - -function cx(...classes: Array): string { - return classes.filter(Boolean).join(' '); -} - -const ICONS: Record> = { - mcp: PlugIcon, - skills: SparklesIcon, - extensions: BlocksIcon, - channels: RadioTowerIcon, - context: FileTextIcon, - hooks: WebhookIcon, -}; - -/** The short value printed on a chip, or `undefined` while the facet is unknown. */ -export function formatOverviewValue( - snapshot: WorkspaceOverviewSnapshot | undefined, - item: WorkspaceOverviewItem, -): string | undefined { - if (!isOverviewFacetKnown(snapshot, item) || !snapshot) return undefined; - switch (item) { - case 'mcp': { - const mcp = snapshot.mcp!; - const enabled = mcp.configured - mcp.disabled; - return enabled === 0 ? '0' : `${mcp.connected}/${enabled}`; - } - case 'skills': - return String(snapshot.skills!.enabled); - case 'extensions': { - const ext = snapshot.extensions!; - return ext.active === ext.total - ? String(ext.total) - : `${ext.active}/${ext.total}`; - } - case 'channels': { - const ch = snapshot.channels!; - return ch.configured === 0 ? '0' : `${ch.connected}/${ch.configured}`; - } - case 'context': - return String(snapshot.context!.fileCount); - case 'hooks': - return String(snapshot.hooks!.count); - default: - return undefined; - } -} - -interface WorkspaceOverviewProps { - overview: WorkspaceOverviewSnapshot | undefined; - items: readonly WorkspaceOverviewItem[]; - /** Narrow sidebar: icons and values only, no text labels. */ - compact?: boolean; -} - -/** - * Facet chips are read-only: the management entries live in the workspace - * menu, which knows whether a page can be bound to this workspace. Keeping - * chips out of the button role also keeps their accessible names from - * colliding with the navigation buttons that share the same facet words. - */ -export function WorkspaceOverview({ - overview, - items, - compact = false, -}: WorkspaceOverviewProps) { - const { t } = useI18n(); - // No snapshot yet means the first round is still in flight; an absent - // facet inside a snapshot is what "unavailable" describes. - if (items.length === 0 || overview === undefined) return null; - return ( -
- {items.map((item) => { - const Icon = ICONS[item]; - const label = t(`sidebar.overview.${item}`); - const value = formatOverviewValue(overview, item); - const known = value !== undefined; - const issue = overviewFacetHasIssue(overview, item); - // Runtime-discovered facets are unknown until the ACP child reports; - // daemon-side facets are unknown only when the route is missing or - // failed, where waiting changes nothing. - const detail = known - ? overviewDetail(t, overview!, item) - : isRuntimeDiscoveredFacet(item) - ? t('sidebar.overview.unknown') - : t('sidebar.overview.unavailable'); - const title = `${label}: ${detail}`; - return ( -
- - -
- ); - })} -
- ); -} - -function overviewDetail( - t: (key: string, vars?: Record) => string, - snapshot: WorkspaceOverviewSnapshot, - item: WorkspaceOverviewItem, -): string { - switch (item) { - case 'mcp': { - const mcp = snapshot.mcp!; - return t('sidebar.overview.mcpDetail', { - configured: mcp.configured, - connected: mcp.connected, - failed: mcp.failed, - disabled: mcp.disabled, - }); - } - case 'skills': { - const skills = snapshot.skills!; - return t('sidebar.overview.skillsDetail', { - total: skills.total, - enabled: skills.enabled, - }); - } - case 'extensions': { - const ext = snapshot.extensions!; - return t('sidebar.overview.extensionsDetail', { - total: ext.total, - active: ext.active, - }); - } - case 'channels': { - const ch = snapshot.channels!; - return t('sidebar.overview.channelsDetail', { - configured: ch.configured, - connected: ch.connected, - failed: ch.failed, - }); - } - case 'context': { - const ctx = snapshot.context!; - return t('sidebar.overview.contextDetail', { - files: ctx.fileCount, - rules: ctx.ruleCount, - }); - } - case 'hooks': { - const hooks = snapshot.hooks!; - return hooks.disabled - ? t('sidebar.overview.hooksDisabled', { count: hooks.count }) - : t('sidebar.overview.hooksDetail', { count: hooks.count }); - } - default: - return ''; - } -} diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css b/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css index 0353487a4cb..2f659f87a15 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css @@ -4,6 +4,7 @@ } .headerRow { + position: relative; display: flex; align-items: center; width: 100%; @@ -17,12 +18,13 @@ flex: 1 1 auto; display: flex; align-items: center; - gap: 6px; - padding: 6px 8px; + gap: 5px; + padding: 5px 4px 5px 8px; border: none; background: transparent; color: var(--sidebar-foreground); - font-size: 14px; + font-size: 13px; + font-weight: 500; text-align: left; cursor: pointer; } @@ -85,8 +87,8 @@ --git-branch-badge-offset: -1px; display: inline-flex; - flex: 0 0 24px; - width: 24px; + flex: 0 0 18px; + width: 18px; padding: 0; border: none; background: none; @@ -97,27 +99,54 @@ } .gitPill [data-web-shell-git-branch] { - width: 24px; - max-width: 24px; - flex-basis: 24px; - padding: 0 5px; + width: 18px; + max-width: 18px; + flex-basis: 18px; + padding: 0 3px; } -/* Keep the icon directly after the folder name — unless the header carries - session counts, which sit at its right edge and push the icon after them. */ -.headerRow:has(.gitPill) .header:not(:has(.headerCounts)) { +/* The git chip rides directly after the folder name. Cap the header by the + rendered hover-action count so the chip never slides under that overlay — + text may truncate, an interactive chip may not. Rows without the count + attribute render no actions and keep the full width. */ +.headerRow:has(.gitPill) .header { flex-grow: 0; } +.headerRow:has(.gitPill):has([data-workspace-action-count='1']) .header { + max-width: calc(100% - 49px); +} + +.headerRow:has(.gitPill):has([data-workspace-action-count='2']) .header { + max-width: calc(100% - 72px); +} + +.headerRow:has(.gitPill):has([data-workspace-action-count='3']) .header { + max-width: calc(100% - 95px); +} + /* Trusted sessions use the sidebar's shared row with its own left indent; read-only rows apply the same indent below. */ .sessions { + position: relative; display: flex; flex-direction: column; - gap: 4px; + gap: 2px; margin-bottom: 12px; } +/* Tree guide under the folder icon, in the spirit of file-explorer indent + guides; hovered/selected rows paint over it with their own background. */ +.sessions::before { + content: ''; + position: absolute; + top: 2px; + bottom: 10px; + left: 15px; + width: 1px; + background: color-mix(in srgb, var(--sidebar-foreground) 14%, transparent); +} + .empty { padding: 4px 8px 4px 26px; font-size: 12px; @@ -179,58 +208,3 @@ animation: none; } } - -/* Session counts at the right edge of the folder header. */ -.headerCounts { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - gap: 6px; - margin-left: auto; - padding-left: 6px; - font-size: 11px; - font-variant-numeric: tabular-nums; - color: var(--muted-foreground); -} - -.headerCount { - display: inline-flex; - align-items: center; - gap: 3px; -} - -.headerCount::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; -} - -.headerCountRunning { - color: var(--success-color, #3fb950); -} - -.headerCountAttention { - color: var(--warning-color, #d29922); -} - -.headerCountTotal::before { - display: none; -} - -/* Full path under an expanded folder header. */ -.path { - padding: 0 8px 2px 30px; - overflow: hidden; - font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 10.5px; - line-height: 14px; - color: var(--muted-foreground); - white-space: nowrap; - text-overflow: ellipsis; -} - -.pathCompact { - padding-left: 26px; -} diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx index 5ef39fa8504..24b5e6e0ccf 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx @@ -148,11 +148,12 @@ function renderSection( excludePinned: boolean; searchQuery: string; gitBranchWanted: boolean; - compact: boolean; renderSession: ( session: DaemonSessionSummary, options?: { searchSnippet?: string | undefined }, ) => ReactNode; + onOpenPathLocally: (cwd: string) => Promise; + onOpenTerminalLocally: (cwd: string) => Promise; }> = {}, ): void { act(() => { @@ -192,7 +193,8 @@ function renderSection( sessionStats={overrides.sessionStats} renderSessions={overrides.renderSessions} gitBranchWanted={overrides.gitBranchWanted} - compact={overrides.compact} + onOpenPathLocally={overrides.onOpenPathLocally} + onOpenTerminalLocally={overrides.onOpenTerminalLocally} /> , ); @@ -234,6 +236,36 @@ function gitChip(): HTMLElement | null { return container.querySelector('[data-web-shell-git-branch]'); } +/** Open the workspace hover popover (300 ms delay) and return its dialog. */ +async function openDetailsDialog(): Promise { + vi.useFakeTimers(); + const headerRow = container.querySelector( + '[class*="headerRow"]', + ); + await act(async () => { + headerRow?.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + await Promise.resolve(); + }); + vi.useRealTimers(); + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog).not.toBeNull(); + return dialog!; +} + +function sessionCounts(): HTMLElement | null { + return document.querySelector( + '[data-web-shell-workspace-sessions]', + ); +} + +function sessionCount(kind: 'Running' | 'Attention' | 'Total'): string | null { + return ( + sessionCounts()?.querySelector(`[class*="Count${kind}"]`) + ?.textContent ?? null + ); +} + beforeEach(() => { window.localStorage.clear(); container = document.createElement('div'); @@ -1224,22 +1256,19 @@ describe('WorkspaceSection overview', () => { await flush(); expect(client.workspaceMcp).not.toHaveBeenCalled(); expect( - container.querySelector('[data-web-shell-workspace-path]'), + document.querySelector('[data-web-shell-workspace-path]'), ).toBeNull(); expect( - container.querySelector('[data-web-shell-workspace-overview]'), + document.querySelector('[data-web-shell-workspace-overview]'), ).toBeNull(); - // Control arm: the default header renders the path and chips and fetches. + // Control arm: the default header consumes the snapshot and fetches. renderSection({ client, expanded: true, overviewEnabled: true }); await flush(); expect(client.workspaceMcp).toHaveBeenCalledTimes(1); - expect( - container.querySelector('[data-web-shell-workspace-path]')?.textContent, - ).toBe('/tmp/project'); }); - it('renders no path or chips for a synthetic workspace without a real cwd', async () => { + it('fetches nothing and shows no path for a synthetic workspace without a real cwd', async () => { const client = makeOverviewClient(); renderSection({ client, @@ -1250,10 +1279,10 @@ describe('WorkspaceSection overview', () => { await flush(); expect(client.workspaceMcp).not.toHaveBeenCalled(); expect( - container.querySelector('[data-web-shell-workspace-path]'), + document.querySelector('[data-web-shell-workspace-path]'), ).toBeNull(); expect( - container.querySelector('[data-web-shell-workspace-overview]'), + document.querySelector('[data-web-shell-workspace-overview]'), ).toBeNull(); }); @@ -1275,12 +1304,9 @@ describe('WorkspaceSection overview', () => { }; renderSection({ client, workspace, expanded: true, overviewEnabled: true }); await flush(); - const counts = () => - container.querySelector('[class*="headerCounts"]'); - expect(counts()?.textContent).toBe('12'); - expect( - counts()?.querySelector('[class*="headerCountRunning"]')?.textContent, - ).toBe('1'); + await openDetailsDialog(); + expect(sessionCount('Total')).toBe('2'); + expect(sessionCount('Running')).toBe('1'); renderSection({ client, @@ -1289,22 +1315,19 @@ describe('WorkspaceSection overview', () => { overviewEnabled: true, }); await flush(); - expect( - container.querySelector('[data-web-shell-workspace-path]'), - ).toBeNull(); - expect(counts()?.textContent).toBe('12'); + expect(sessionCount('Total')).toBe('2'); }); - it('shows no counts, path or chips when the overview is disabled', async () => { + it('shows no counts or path when the overview is disabled', async () => { const client = makeOverviewClient([ { sessionId: 'a', workspaceCwd: '/tmp/project', hasActivePrompt: true }, ]); renderSection({ client, expanded: true }); await flush(); expect(client.workspaceMcp).not.toHaveBeenCalled(); - expect(container.querySelector('[class*="headerCounts"]')).toBeNull(); + expect(sessionCounts()).toBeNull(); expect( - container.querySelector('[data-web-shell-workspace-path]'), + document.querySelector('[data-web-shell-workspace-path]'), ).toBeNull(); }); }); @@ -1338,8 +1361,6 @@ describe('WorkspaceSection counts across a source switch', () => { })), } as unknown as DaemonClient; const workspace = { ...trustedWorkspace, id: 'other', cwd: '/tmp/other' }; - const counts = () => - container.querySelector('[class*="headerCounts"]'); renderSection({ client, @@ -1349,10 +1370,12 @@ describe('WorkspaceSection counts across a source switch', () => { sourceType: 'default', }); await flush(); - expect(counts()?.textContent).toBe('13'); + await openDetailsDialog(); + expect(sessionCount('Total')).toBe('3'); + expect(sessionCount('Running')).toBe('1'); // The channel query starts without a page: stale default counts above an - // empty channel list would mislead, so the header shows none. + // empty channel list would mislead, so the popover shows none. renderSection({ client, workspace, @@ -1361,7 +1384,7 @@ describe('WorkspaceSection counts across a source switch', () => { sourceType: 'channel', }); await flush(); - expect(counts()).toBeNull(); + expect(sessionCounts()).toBeNull(); resolveChannel({ sessions: [ @@ -1369,7 +1392,7 @@ describe('WorkspaceSection counts across a source switch', () => { ] as DaemonSessionSummary[], }); await flush(); - expect(counts()?.textContent).toBe('1'); + expect(sessionCount('Total')).toBe('1'); // Collapsing keeps the last counts of the active source. renderSection({ @@ -1380,7 +1403,53 @@ describe('WorkspaceSection counts across a source switch', () => { sourceType: 'channel', }); await flush(); - expect(counts()?.textContent).toBe('1'); + expect(sessionCount('Total')).toBe('1'); + }); +}); + +describe('WorkspaceSection local-open gates', () => { + it('shows the open-locally buttons only for a trusted workspace with a real path', async () => { + const onOpenPathLocally = vi.fn().mockResolvedValue(undefined); + const onOpenTerminalLocally = vi.fn().mockResolvedValue(undefined); + renderSection({ + client: makeOverviewClient(), + expanded: true, + overviewEnabled: true, + onOpenPathLocally, + onOpenTerminalLocally, + }); + await flush(); + const details = await openDetailsDialog(); + const folderButton = details.querySelector( + '[data-web-shell-open-workspace-folder]', + ); + expect(folderButton).not.toBeNull(); + expect( + details.querySelector('[data-web-shell-open-workspace-terminal]'), + ).not.toBeNull(); + await act(async () => { + folderButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect(onOpenPathLocally).toHaveBeenCalledWith('/tmp/project'); + + // Untrusted rows get no local-open surface. + renderSection({ + client: makeOverviewClient(), + workspace: untrustedWorkspace, + expanded: true, + overviewEnabled: true, + onOpenPathLocally, + onOpenTerminalLocally, + }); + await flush(); + const lockedDetails = await openDetailsDialog(); + expect( + lockedDetails.querySelector('[data-web-shell-open-workspace-folder]'), + ).toBeNull(); + expect( + lockedDetails.querySelector('[data-web-shell-open-workspace-terminal]'), + ).toBeNull(); }); }); @@ -1396,7 +1465,7 @@ describe('WorkspaceSection overview gates', () => { await flush(); expect(client.workspaceMcp).not.toHaveBeenCalled(); expect( - container.querySelector('[data-web-shell-workspace-overview]'), + document.querySelector('[data-web-shell-workspace-overview]'), ).toBeNull(); }); @@ -1437,8 +1506,6 @@ describe('WorkspaceSection overview gates', () => { it('shows no counts while parent-owned stats are loading', async () => { const client = makeOverviewClient(); - const counts = () => - container.querySelector('[class*="headerCounts"]'); // Production wiring for the primary row: the sidebar lists its sessions // itself, so the section renders none and owns no catalog query. renderSection({ @@ -1449,18 +1516,13 @@ describe('WorkspaceSection overview gates', () => { sessionStats: { total: 4, running: 1, attention: 2, truncated: true }, }); await flush(); - expect(counts()?.textContent).toBe('214+'); - expect( - counts()?.querySelector('[class*="headerCountAttention"]')?.textContent, - ).toBe('2'); - expect( - counts()?.querySelector('[class*="headerCountTotal"]')?.textContent, - ).toBe('4+'); - expect( - counts() - ?.querySelector('[class*="headerCountTotal"]') - ?.getAttribute('aria-label'), - ).toBe('4+ sessions'); + await openDetailsDialog(); + expect(sessionCount('Attention')).toBe('2'); + expect(sessionCount('Running')).toBe('1'); + expect(sessionCount('Total')).toBe('4+'); + expect(sessionCounts()?.getAttribute('aria-label')).toBe( + '2 sessions waiting for you · 1 running session · 4+ sessions', + ); // A source switch: the sidebar has no page for the new source yet, and // the retained counts must not fill the gap. renderSection({ @@ -1471,7 +1533,7 @@ describe('WorkspaceSection overview gates', () => { sessionStats: null, }); await flush(); - expect(counts()).toBeNull(); + expect(sessionCounts()).toBeNull(); }); it('passes the overview snapshot to the header actions', async () => { @@ -1514,8 +1576,6 @@ describe('WorkspaceSection retained counts across a source switch', () => { })), } as unknown as DaemonClient; const workspace = { ...trustedWorkspace, id: 'other', cwd: '/tmp/other' }; - const counts = () => - container.querySelector('[class*="headerCounts"]'); const render = (expanded: boolean, sourceType: string) => renderSection({ client, @@ -1527,19 +1587,20 @@ describe('WorkspaceSection retained counts across a source switch', () => { render(true, 'default'); await flush(); - expect(counts()?.textContent).toBe('3'); + await openDetailsDialog(); + expect(sessionCount('Total')).toBe('3'); render(false, 'default'); await flush(); - expect(counts()?.textContent).toBe('3'); + expect(sessionCount('Total')).toBe('3'); // The global source switches while the row stays collapsed: the default // source's counts no longer describe the active source. render(false, 'channel'); await flush(); - expect(counts()).toBeNull(); + expect(sessionCounts()).toBeNull(); // Switching back restores the counts that source still owns. render(false, 'default'); await flush(); - expect(counts()?.textContent).toBe('3'); + expect(sessionCount('Total')).toBe('3'); }); }); @@ -2009,29 +2070,10 @@ describe('WorkspaceSection overview plumbing', () => { ).toBe(true); // The path and chips stay hidden under a custom header. expect( - container.querySelector('[data-web-shell-workspace-overview]'), + document.querySelector('[data-web-shell-workspace-overview]'), ).toBeNull(); }); - it('passes compact mode through to the path and chips', async () => { - renderSection({ - client: makeOverviewClient(), - expanded: true, - overviewEnabled: true, - compact: true, - }); - await flush(); - await flush(); - const path = container.querySelector( - '[data-web-shell-workspace-path]', - ); - expect(path?.className).toMatch(/pathCompact/); - expect( - container.querySelectorAll('[data-web-shell-workspace-overview]').length, - ).toBeGreaterThan(0); - expect(container.querySelector('[class*="chipLabel"]')).toBeNull(); - }); - it('keeps the last snapshot for the header actions while collapsed', async () => { const client = makeOverviewClient(); const headerActions = vi.fn(() => null); @@ -2110,8 +2152,7 @@ describe('WorkspaceSection overview plumbing', () => { overviewEnabled: true, }); await flush(); - expect( - container.querySelector('[class*="headerCountTotal"]')?.textContent, - ).toBe('3+'); + await openDetailsDialog(); + expect(sessionCount('Total')).toBe('3+'); }); }); diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index f73813b358c..2bcdbb73a47 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -22,7 +22,7 @@ import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react'; import { GitBranchIndicator } from '../GitBranchIndicator'; import { BranchPickerPopover } from '../BranchPickerPopover'; import { useI18n } from '../../i18n'; -import { formatRelativeTime } from '../../utils/formatRelativeTime'; +import { formatDateTime } from '../../utils/formatDateTime'; import { SESSION_LIST_PAGE_SIZE, SIDEBAR_SESSION_PREVIEW_LIMIT, @@ -47,7 +47,7 @@ import { useSessionContentSearch } from './useSessionContentSearch'; import { measureSessionTitleScroll } from './sessionTitleScroll'; import { groupSessionsByChannelType } from './channelSessionGroups'; import { useWorkspaceOverview } from './useWorkspaceOverview'; -import { WorkspaceOverview } from './WorkspaceOverview'; +import { WorkspaceDetailsTooltip } from './WorkspaceDetailsTooltip'; import { DEFAULT_WORKSPACE_OVERVIEW_ITEMS, summarizeSessions, @@ -84,8 +84,8 @@ function WorkspaceFolderIcon({ open }: { open: boolean }) { return (