From 68c54e7e67c8c13a03033dabfad252577f7ce118 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 31 Aug 2026 13:57:31 +0800 Subject: [PATCH 01/11] feat(web-shell): declutter workspace sidebar and add loopback open actions --- docs/developers/qwen-serve-protocol.md | 4 +- .../cli/qwen-serve-routes.test.ts | 12 + packages/cli/src/serve/capabilities.ts | 22 + .../cli/src/serve/local-path-open.test.ts | 449 ++++++++++++++++++ packages/cli/src/serve/local-path-open.ts | 273 +++++++++++ .../serve/routes/workspace-local-open.test.ts | 256 ++++++++++ .../src/serve/routes/workspace-local-open.ts | 71 +++ packages/cli/src/serve/run-qwen-serve.test.ts | 109 +++++ packages/cli/src/serve/run-qwen-serve.ts | 18 + packages/cli/src/serve/server.test.ts | 76 +++ packages/cli/src/serve/server.ts | 25 + .../cli/src/serve/server/serve-features.ts | 6 + .../sdk-typescript/src/daemon/DaemonClient.ts | 30 ++ .../test/unit/DaemonClient.test.ts | 40 ++ .../sidebar/WebShellSidebar.module.css | 137 +++++- .../components/sidebar/WebShellSidebar.tsx | 99 +++- ...WebShellSidebar.workspace-removal.test.tsx | 64 ++- .../sidebar/WorkspaceDetailsTooltip.test.tsx | 292 ++++++++++++ .../sidebar/WorkspaceDetailsTooltip.tsx | 338 +++++++++++++ .../components/sidebar/WorkspaceMenu.test.tsx | 35 ++ .../components/sidebar/WorkspaceMenu.tsx | 22 +- .../sidebar/WorkspaceOverview.module.css | 66 --- .../sidebar/WorkspaceOverview.test.tsx | 353 -------------- .../components/sidebar/WorkspaceOverview.tsx | 191 -------- .../sidebar/WorkspaceSection.module.css | 110 ++--- .../sidebar/WorkspaceSection.test.tsx | 129 +++-- .../components/sidebar/WorkspaceSection.tsx | 283 +++++------ .../sidebar/workspaceOverviewModel.test.ts | 118 +++++ .../sidebar/workspaceOverviewModel.ts | 92 ++++ packages/web-shell/client/config/daemon.ts | 28 +- .../web-shell/client/e2e/utils/mockDaemon.ts | 8 + .../e2e/web-shell.workspace-overview.spec.ts | 166 +++++-- packages/web-shell/client/i18n.tsx | 10 + 33 files changed, 2962 insertions(+), 970 deletions(-) create mode 100644 packages/cli/src/serve/local-path-open.test.ts create mode 100644 packages/cli/src/serve/local-path-open.ts create mode 100644 packages/cli/src/serve/routes/workspace-local-open.test.ts create mode 100644 packages/cli/src/serve/routes/workspace-local-open.ts create mode 100644 packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.test.tsx create mode 100644 packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.tsx delete mode 100644 packages/web-shell/client/components/sidebar/WorkspaceOverview.module.css delete mode 100644 packages/web-shell/client/components/sidebar/WorkspaceOverview.test.tsx delete mode 100644 packages/web-shell/client/components/sidebar/WorkspaceOverview.tsx diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 6584c419eba..5b95411c113 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -482,7 +482,7 @@ operator diagnostic snapshot documented below. | Tag | Advertised when … | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | | `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every normal API route, including `/health` on loopback binds; channel webhook ingress keeps its independent shared-secret authentication, and Web Shell document and asset routes remain pre-auth. | | `mcp_workspace_pool` | the shared MCP transport pool is active. Omitted when `QWEN_SERVE_NO_MCP_POOL=1` disables the pool. | | `mcp_pool_restart` | the shared MCP transport pool is active; restart responses may include pool-aware multi-entry shapes. | @@ -513,6 +513,8 @@ operator diagnostic snapshot documented below. | `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. | | `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_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: 'folder' | '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 5af5fbe9a2a..0accab01806 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, [ @@ -426,6 +436,8 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_display_name', 'workspace_runtime_removal', ...(nativeDirectoryPickerAtBoot ? ['native_directory_picker'] : []), + ...(localPathOpenAtBoot ? ['workspace_local_open'] : []), + ...(localTerminalOpenAtBoot ? ['workspace_local_terminal'] : []), 'workspace_qualified_rest_core', 'extension_management_v2', 'extension_git_credentials', diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index cf05cfaf858..d10564edd1f 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -372,6 +372,18 @@ export const SERVE_CAPABILITY_REGISTRY = { // with a display). Headless hosts omit the tag so clients hide the // Browse affordance instead of surfacing a guaranteed picker failure. native_directory_picker: { 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. @@ -518,6 +530,8 @@ export interface AdvertiseFeatureToggles { scratchWorkspaceRegistrationAvailable?: boolean; workspaceRuntimeRemovalAvailable?: boolean; nativeDirectoryPickerAvailable?: 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. @@ -660,6 +674,14 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'native_directory_picker', (toggles) => toggles.nativeDirectoryPickerAvailable === 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..d6e64f93874 --- /dev/null +++ b/packages/cli/src/serve/local-path-open.test.ts @@ -0,0 +1,449 @@ +/** + * @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); + +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('C:\\code'); + + expect(spawnMock).toHaveBeenCalledWith('explorer.exe', ['C:\\code'], { + stdio: 'ignore', + }); + }); + + it('wraps a spawn-level explorer.exe failure on Windows', async () => { + setPlatform('win32'); + spawnMock.mockReturnValue(fakeChild('error')); + + await expect(openPathLocally('C:\\code')).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + }); + + 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('C:\\code'); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledWith('wt.exe', ['-d', 'C:\\code'], { + stdio: 'ignore', + }); + }); + + it('falls back to cmd.exe when wt.exe fails to spawn on Windows', async () => { + setPlatform('win32'); + spawnMock + .mockReturnValueOnce(fakeChild('error')) + .mockReturnValueOnce(fakeChild('close')); + + await openTerminalLocally('C:\\code'); + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock).toHaveBeenNthCalledWith(1, 'wt.exe', ['-d', 'C:\\code'], { + stdio: 'ignore', + }); + expect(spawnMock).toHaveBeenNthCalledWith( + 2, + 'cmd.exe', + ['/c', 'start', '""', 'cmd', '/k', 'cd', '/d', '"C:\\code"'], + { stdio: 'ignore' }, + ); + }); + + it('wraps the failure when both wt.exe and cmd.exe fail on Windows', async () => { + setPlatform('win32'); + spawnMock.mockReturnValue(fakeChild('error')); + + await expect(openTerminalLocally('C:\\code')).rejects.toBeInstanceOf( + LocalPathOpenUnavailableError, + ); + }); + + it('spawns gnome-terminal with --working-directory on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', gnomeTerminalDir); + spawnMock.mockReturnValue(fakeChild('spawn')); + + await openTerminalLocally('/home/me/code'); + + expect(spawnMock).toHaveBeenCalledWith( + join(gnomeTerminalDir, 'gnome-terminal'), + ['--working-directory=/home/me/code'], + { stdio: 'ignore', detached: true }, + ); + }); + + it('spawns konsole with --workdir when gnome-terminal is absent on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', konsoleDir); + spawnMock.mockReturnValue(fakeChild('spawn')); + + await openTerminalLocally('/home/me/code'); + + expect(spawnMock).toHaveBeenCalledWith( + join(konsoleDir, 'konsole'), + ['--workdir', '/home/me/code'], + { 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); + spawnMock.mockReturnValue(fakeChild('spawn')); + + await openTerminalLocally('/home/me/code'); + + expect(spawnMock).toHaveBeenCalledWith( + join(xtermDir, 'xterm'), + [ + '-e', + 'sh', + '-c', + 'cd "$1" && exec "${SHELL:-/bin/sh}"', + 'sh', + '/home/me/code', + ], + { stdio: 'ignore', detached: true }, + ); + }); + + it('prefers gnome-terminal over konsole and xterm on Linux', async () => { + setPlatform('linux'); + vi.stubEnv( + 'PATH', + [xtermDir, gnomeTerminalDir, konsoleDir].join(delimiter), + ); + spawnMock.mockReturnValue(fakeChild('spawn')); + + await openTerminalLocally('/home/me/code'); + + expect(spawnMock).toHaveBeenCalledWith( + join(gnomeTerminalDir, 'gnome-terminal'), + ['--working-directory=/home/me/code'], + { stdio: 'ignore', detached: true }, + ); + }); + + it('throws unavailable when no terminal emulator is on PATH on Linux', async () => { + setPlatform('linux'); + vi.stubEnv('PATH', emptyDir); + + 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); + 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, + ); + expect( + isLocalTerminalAvailable({ + WAYLAND_DISPLAY: 'wayland-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); + }); +}); 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..cbc6a87d161 --- /dev/null +++ b/packages/cli/src/serve/local-path-open.ts @@ -0,0 +1,273 @@ +/** + * @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; + +export class LocalPathOpenUnavailableError 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, LINUX_TERMINAL_NAMES) !== 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; + } +} + +export async function openPathLocally(path: string): Promise { + try { + if (process.platform === 'darwin') { + await execFileAsync('open', [path], { timeout: OPEN_TIMEOUT_MS }); + return; + } + + if (process.platform === 'win32') { + await spawnAndIgnoreExitCode('explorer.exe', [path]); + 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}`, + ); +} + +export async function openTerminalLocally(path: string): Promise { + try { + if (process.platform === 'darwin') { + await execFileAsync('open', ['-a', 'Terminal', path], { + timeout: OPEN_TIMEOUT_MS, + }); + return; + } + + if (process.platform === 'win32') { + await spawnWindowsTerminal(path); + return; + } + + if (process.platform === 'linux') { + await spawnLinuxTerminal(path); + 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 +// cmd.exe's `start`, which is always present. +async function spawnWindowsTerminal(path: string): Promise { + try { + await spawnAndIgnoreExitCode('wt.exe', ['-d', path]); + } catch { + await spawnAndIgnoreExitCode('cmd.exe', [ + '/c', + 'start', + '""', + 'cmd', + '/k', + 'cd', + '/d', + `"${path}"`, + ]); + } +} + +async function spawnLinuxTerminal(path: string): Promise { + const terminal = findExecutableOnPath(process.env, LINUX_TERMINAL_NAMES); + 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}`]); + return; + } + if (name === 'konsole') { + await spawnLongLived(terminal, ['--workdir', path]); + return; + } + await spawnLongLived(terminal, [ + '-e', + 'sh', + '-c', + 'cd "$1" && exec "${SHELL:-/bin/sh}"', + 'sh', + path, + ]); +} + +// 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[], +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { stdio: 'ignore' }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`${command} did not exit within 10s`)); + }, OPEN_TIMEOUT_MS); + 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[], +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + stdio: 'ignore', + detached: true, + }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`${command} did not spawn within 10s`)); + }, OPEN_TIMEOUT_MS); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('spawn', () => { + clearTimeout(timer); + child.unref(); + resolve(); + }); + }); +} 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 8616d3ae93a..1b78610d311 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'; @@ -11026,6 +11027,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 33458a75f4e..66c89f340bc 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -117,6 +117,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, @@ -2311,6 +2315,8 @@ function currentServeFeaturesForRunQwenServe( currentSessionSchedulingAvailable: boolean, env: Readonly>, nativeDirectoryPickerAvailable: boolean, + localPathOpenAvailable: boolean, + localTerminalOpenAvailable: boolean, ): string[] { return getAdvertisedServeFeatures(undefined, { requireAuth: opts.requireAuth === true, @@ -2341,6 +2347,8 @@ function currentServeFeaturesForRunQwenServe( // path (serve-features.ts) so the bootstrap `/capabilities` window doesn't // briefly under-report them. nativeDirectoryPickerAvailable, + localPathOpenAvailable, + localTerminalOpenAvailable, clientMcpOverWsEnabled: opts.clientMcpOverWs === true, cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true, browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(opts, env), @@ -2357,6 +2365,8 @@ function createBootstrapCapabilities(input: { permissionPolicy: PermissionPolicy | undefined; env: Readonly>; nativeDirectoryPickerAvailable: boolean; + localPathOpenAvailable: boolean; + localTerminalOpenAvailable: boolean; }): CapabilitiesEnvelope { return { v: CAPABILITIES_SCHEMA_VERSION, @@ -2372,6 +2382,8 @@ function createBootstrapCapabilities(input: { input.currentSessionSchedulingAvailable, input.env, input.nativeDirectoryPickerAvailable, + input.localPathOpenAvailable, + input.localTerminalOpenAvailable, ), modelServices: [], workspaceCwd: input.boundWorkspace, @@ -2547,6 +2559,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) { @@ -2608,6 +2622,8 @@ function createBootstrapServeApp(input: { permissionPolicy, env: process.env, nativeDirectoryPickerAvailable, + localPathOpenAvailable, + localTerminalOpenAvailable, }), ); }); @@ -2748,6 +2764,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 a81201cfd34..d2e317a4c3f 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -71,6 +71,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, @@ -789,6 +793,8 @@ const EXPECTED_REGISTERED_FEATURES = [ 'scratch_workspace_registration', 'workspace_runtime_removal', 'native_directory_picker', + 'workspace_local_open', + 'workspace_local_terminal', 'workspace_qualified_rest_core', 'workspace_qualified_voice', 'workspace_qualified_memory', @@ -3277,6 +3283,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, @@ -4219,6 +4253,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([]); @@ -4256,6 +4292,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 69f654d11cf..2b290efa79b 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -250,6 +250,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, @@ -268,6 +272,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, @@ -601,6 +606,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 @@ -1042,6 +1059,10 @@ export function createServeApp( nativeDirectoryPickerAvailable: deps.nativeDirectoryPickerAvailable ?? isNativeDirectoryPickerAvailable(), + localPathOpenAvailable: + deps.localPathOpenAvailable ?? isLocalPathOpenAvailable(), + localTerminalOpenAvailable: + deps.localTerminalOpenAvailable ?? isLocalTerminalAvailable(), workspaceTrustHotReloadAvailable: deps.workspaceTrustHotReloadAvailable === true, isPrimaryWorkspaceTrusted: () => isPrimaryWorkspaceTrusted(), @@ -2268,6 +2289,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 5f049f0c5c3..d1e01446511 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -60,6 +60,8 @@ interface CreateServeFeaturesDeps { acpHttpEnabled?: boolean; workspaceRuntimeRemovalAvailable?: boolean; nativeDirectoryPickerAvailable?: boolean; + localPathOpenAvailable?: boolean; + localTerminalOpenAvailable?: boolean; workspaceTrustHotReloadAvailable?: boolean; isPrimaryWorkspaceTrusted?: () => boolean; env?: Readonly>; @@ -97,6 +99,8 @@ export function createServeFeatures( acpHttpEnabled, workspaceRuntimeRemovalAvailable, nativeDirectoryPickerAvailable, + localPathOpenAvailable, + localTerminalOpenAvailable, workspaceTrustHotReloadAvailable, } = deps; const getEnv = deps.getEnv ?? (() => deps.env ?? process.env); @@ -151,6 +155,8 @@ export function createServeFeatures( scratchWorkspaceRegistrationAvailable(), workspaceRuntimeRemovalAvailable, nativeDirectoryPickerAvailable, + 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 1dc769a1d0c..347c93a4ea1 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -5741,6 +5741,36 @@ export class WorkspaceDaemonClient { return this.get('/mcp', 'GET /workspaces/:workspace/mcp'); } + /** + * 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 81df66e260b..fdfe486a5c1 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1184,6 +1184,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/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 4db84df52f7..cba5b029cf6 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 @@ -923,6 +1008,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; @@ -1003,11 +1094,22 @@ } .sessionMetaSlot:not( - :has(.sessionLoading, .sessionAttention, .sessionGitIcon) + :has(.sessionLoading, .sessionAttention, .sessionGitIcon, .sessionTime) ) { padding-right: 10px; } +/* Relative timestamp at the row's right edge; yields to hover actions the + same way the git icon and attention pill do. */ +.sessionTime { + padding: 0 8px 0 4px; + color: var(--muted-foreground); + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 20px; + white-space: nowrap; +} + .sessionAttention { height: 22px; display: inline-flex; @@ -1063,13 +1165,18 @@ .sessionRow:hover:not(.runningSession) .sessionGitIcon, .sessionRow:focus-within:not(.runningSession) .sessionGitIcon, +.sessionRow:hover:not(.runningSession) .sessionTime, +.sessionRow:focus-within:not(.runningSession) .sessionTime, .sessionRow:hover:not(.runningSession) .sessionAttention, .sessionRow:focus-within:not(.runningSession) .sessionAttention, .sessionMetaSlot:hover .sessionGitIcon, +.sessionMetaSlot:hover .sessionTime, .sessionMetaSlot:hover .sessionLoading, .sessionMetaSlot:hover .sessionAttention, .sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionGitIcon, .sessionMetaSlot:has([data-state='open']) .sessionGitIcon, +.sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionTime, +.sessionMetaSlot:has([data-state='open']) .sessionTime, .sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionLoading, .sessionMetaSlot:has([data-state='open']) .sessionLoading, .sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionAttention, diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 2da1a01b610..154d1d71902 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -95,6 +95,7 @@ import { type WorkspaceOverviewItem, } from './workspaceOverviewModel'; import { writeClipboardText } from '../../utils/clipboard'; +import { isLocalDaemon } from '../../config/daemon'; import { sessionMatchesGitQuery } from './sessionSearch'; import { SessionPrBadge } from '../SessionPrBadge'; import { @@ -1395,6 +1396,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 @@ -2483,6 +2494,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 @@ -4311,6 +4348,18 @@ export function WebShellSidebar({ aria-hidden="true" /> ) : null} + {session.hasActivePrompt && + !scheduledTaskIcon && + !completedUnread ? ( + {isEditing && canRenameSession(session) ? (
- ) : !attention && gitIcon ? ( + ) : !attention && time ? ( + + ) : null} + {!session.hasActivePrompt && !attention && gitIcon ? ( {gitIcon} ) : null} {(showPin || @@ -5677,7 +5731,16 @@ export function WebShellSidebar({ showSessionDetails={sessionActionItems.has('details')} overviewEnabled={workspaceOverviewEnabled} overviewItems={workspaceOverviewItems} - compact={footerTight} + onOpenPathLocally={ + localOpenEnabled + ? openWorkspaceFolderLocally + : undefined + } + onOpenTerminalLocally={ + localTerminalEnabled + ? openWorkspaceTerminalLocally + : undefined + } gitBranchWanted={ Boolean(onNewWorktreeSession) && !lockedWorkspaceCwd } @@ -5730,6 +5793,28 @@ export function WebShellSidebar({ copyPath: () => copyWorkspacePath(ws), } : {}), + ...(localOpenEnabled && + ws.trusted && + realPath + ? { + openFolder: () => { + void openWorkspaceFolderLocally( + ws.cwd, + ).catch(() => undefined); + }, + } + : {}), + ...(localTerminalEnabled && + ws.trusted && + realPath + ? { + openTerminal: () => { + void openWorkspaceTerminalLocally( + ws.cwd, + ).catch(() => undefined); + }, + } + : {}), ...(ws.trusted ? { newSession: () => @@ -5771,9 +5856,19 @@ 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. + const headerActionCount = + (ws.trusted + ? 1 + Number(canOrganizeWorkspace(ws.cwd)) + : 0) + 1; return (
{ 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('counts the registered workspaces next to the Projects label', () => { @@ -3523,7 +3565,9 @@ describe('WebShellSidebar workspace removal', () => { expect( container.querySelector('[data-web-shell-workspace-overview]'), ).toBeNull(); - expect(container.querySelector('[class*="headerCounts"]')).toBeNull(); + expect( + container.querySelector('[data-web-shell-workspace-sessions]'), + ).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..6ae96e45725 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.test.tsx @@ -0,0 +1,292 @@ +// @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, '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 (non-interactive state). + expect(openButton!.querySelector('svg')).not.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) without a false check. + expect(openButton!.disabled).toBe(false); + }); + + 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); + }); +}); 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..5ed29206047 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/WorkspaceDetailsTooltip.tsx @@ -0,0 +1,338 @@ +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, + icon: Icon, + onOpen, + testId, +}: { + label: string; + icon: ComponentType<{ size?: number }>; + onOpen: () => Promise; + testId: string; +}) { + const [opened, setOpened] = useState(false); + const resetTimerRef = useRef(undefined); + useEffect(() => () => window.clearTimeout(resetTimerRef.current), []); + return ( + + ); +} + +/** + * 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 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); + }; + + 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={closeAfterDelay} + onPointerDownCapture={close} + onClick={() => close()} + > + {children} + + event.preventDefault()} + onPointerEnter={cancelClose} + onPointerLeave={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 55a6e78bf89..223a41299d3 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx @@ -147,7 +147,6 @@ function renderSection( excludePinned: boolean; searchQuery: string; gitBranchWanted: boolean; - compact: boolean; }> = {}, ): void { act(() => { @@ -184,7 +183,6 @@ function renderSection( sessionStats={overrides.sessionStats} renderSessions={overrides.renderSessions} gitBranchWanted={overrides.gitBranchWanted} - compact={overrides.compact} /> , ); @@ -226,6 +224,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'); @@ -1222,16 +1250,13 @@ describe('WorkspaceSection overview', () => { container.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, @@ -1267,12 +1292,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, @@ -1281,20 +1303,17 @@ 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]'), ).toBeNull(); @@ -1330,8 +1349,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, @@ -1341,10 +1358,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, @@ -1353,7 +1372,7 @@ describe('WorkspaceSection counts across a source switch', () => { sourceType: 'channel', }); await flush(); - expect(counts()).toBeNull(); + expect(sessionCounts()).toBeNull(); resolveChannel({ sessions: [ @@ -1361,7 +1380,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({ @@ -1372,7 +1391,7 @@ describe('WorkspaceSection counts across a source switch', () => { sourceType: 'channel', }); await flush(); - expect(counts()?.textContent).toBe('1'); + expect(sessionCount('Total')).toBe('1'); }); }); @@ -1429,8 +1448,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({ @@ -1441,18 +1458,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({ @@ -1463,7 +1475,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 () => { @@ -1506,8 +1518,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, @@ -1519,19 +1529,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'); }); }); @@ -1753,25 +1764,6 @@ describe('WorkspaceSection overview plumbing', () => { ).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); @@ -1850,8 +1842,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 1d979299bc7..dce9028aac8 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -43,7 +43,7 @@ import { sessionMatchesGitQuery } from './sessionSearch'; 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, @@ -80,8 +80,8 @@ function WorkspaceFolderIcon({ open }: { open: boolean }) { return (