diff --git a/docs/design/2026-08-13-web-shell-sidebar-session-details.md b/docs/design/2026-08-13-web-shell-sidebar-session-details.md new file mode 100644 index 00000000000..0e0f2078b6d --- /dev/null +++ b/docs/design/2026-08-13-web-shell-sidebar-session-details.md @@ -0,0 +1,41 @@ +# Web Shell sidebar session details + +## Goal + +Make session rows easier to scan without adding another navigation surface: + +- show the existing details panel from row hover and remove the Details action + from the overflow menu; +- preview five sessions per expanded folder or session group, with an explicit + control to reveal the remainder until that section is collapsed; +- move timestamps into the details panel and reserve the row's trailing slot + for branch or worktree state; +- fade overflowing titles at the right edge and scroll them slowly on hover; +- use a neutral spinner for running sessions; +- keep the brand, New task action, and footer fixed while the remaining + navigation and session content share one scroll area. + +## Design + +The row remains the only session-selection and keyboard target. A controlled +Radix popover is anchored to it and opens only from pointer hover. The panel +does not participate in keyboard navigation; its session ID copy action is a +pointer-only affordance. The panel contains the title and relative time, final +workspace path segment, optional git branch or worktree, session status, and a +copyable session ID. Existing action menus keep all mutation actions but no +longer include Details. Rename targets the selected session through its owning +workspace, so current, background, secondary-workspace, and archived sessions +share the same action. + +Session limits are local UI state. Direct workspace lists and grouped lists +show the first five items; revealing the remainder is not persisted, so +collapsing and reopening the owning section restores the five-item preview. + +Title overflow uses a CSS mask for the trailing fade. On hover, one DOM width +measurement supplies the exact scroll distance to a CSS animation, avoiding a +timer or dependency. + +The workspace-qualified metadata route keeps background-session renames inside +the resolved workspace runtime. Its dedicated `workspace_session_metadata` +capability prevents clients from exposing the action against older daemons +that do not mount the route. No session schema changes are required. diff --git a/docs/design/2026-08-14-web-shell-collapsed-session-switcher.md b/docs/design/2026-08-14-web-shell-collapsed-session-switcher.md new file mode 100644 index 00000000000..dc0fe569fad --- /dev/null +++ b/docs/design/2026-08-14-web-shell-collapsed-session-switcher.md @@ -0,0 +1,15 @@ +# Web Shell collapsed session switcher + +## Goal + +Keep session switching available while the sidebar is collapsed without adding +another navigation model. + +## Design + +The collapsed sidebar shows one Project icon in the scrolling navigation area. +Pointer hover or click opens a Popover containing the same complete session +browser used by the expanded sidebar. Source tabs, pinned and live sessions, +project search, workspace actions, grouping, preview limits, archived sessions, +and expansion preferences therefore follow one implementation in both states. +Selecting a session closes the Popover. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index be731880170..1d86070d104 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -406,6 +406,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', + 'workspace_session_metadata', 'voice_transcribe', ]); }); diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 39a60718cbf..69b80771b17 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -385,6 +385,9 @@ export const SERVE_CAPABILITY_REGISTRY = { // This remains independent from active export so older daemons cannot ignore // archive intent and return an active transcript with the same session id. workspace_archived_session_export: { since: 'v1' }, + // Workspace-qualified metadata updates for active, inactive, and archived + // persisted sessions. + workspace_session_metadata: { since: 'v1' }, // Workspace-qualified ACP transport (issue #6378 Phase 4): // `/workspaces/:workspace/acp` mounts a per-runtime ACP dispatcher (HTTP + // WebSocket) for each registered workspace, with per-runtime device-flow and diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 246b25f8010..638bd868e74 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -51,6 +51,7 @@ import { parseChannelDelivery } from '../../runtime/channel-delivery.js'; import { canonicalizeWorkspace, InvalidClientIdError, + InvalidSessionMetadataError, PromptQueueFullError, SessionArtifactValidationError, SessionArchivedError, @@ -4844,6 +4845,124 @@ export function registerSessionRoutes( ), ); + app.patch( + '/workspaces/:workspace/session/:id/metadata', + mutate({ strict: true }), + async (req, res) => { + const route = 'PATCH /workspaces/:workspace/session/:id/metadata'; + const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route); + if (!runtime) return; + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const rawDisplayName = safeBody(req)['displayName']; + if (typeof rawDisplayName !== 'string') { + res.status(400).json({ + error: '`displayName` must be a string', + code: 'invalid_metadata', + field: 'displayName', + }); + return; + } + try { + const displayName = rawDisplayName.slice(0, 256); + if (displayName.trim() === '') { + // An empty name would append an empty custom_title record to + // persisted sessions, which the title readers disagree on. + throw new InvalidSessionMetadataError( + 'displayName', + 'must not be empty', + ); + } + if ( + Array.from(displayName).some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127; + }) + ) { + throw new InvalidSessionMetadataError( + 'displayName', + 'must not contain control characters', + ); + } + await archiveCoordinator.runExclusiveMany([sessionId], async () => { + const assertRuntimeGenerationOpen = + captureRuntimeGenerationAssertion(runtime); + assertRuntimeGenerationOpen?.(); + const liveOwner = + workspaceRegistry.resolveLiveSessionOwner(sessionId); + if (liveOwner.kind === 'unavailable') { + sendWorkspaceRuntimeUnavailable(res); + return; + } + if (liveOwner.kind === 'ambiguous') { + sendAmbiguousSessionOwner( + res, + route, + sessionId, + liveOwner.runtimes, + ); + return; + } + if ( + liveOwner.kind === 'found' && + liveOwner.runtime.workspaceCwd !== runtime.workspaceCwd + ) { + sendSessionWorkspaceConflict( + res, + route, + sessionId, + runtime, + liveOwner.runtime, + ); + return; + } + await runWithWorkspaceRuntimeStorage(runtime, async () => { + let effective: { displayName?: string }; + try { + effective = runtime.bridge.updateSessionMetadata( + sessionId, + { displayName }, + clientId !== undefined ? { clientId } : undefined, + ); + assertRuntimeGenerationOpen?.(); + } catch (err) { + if (!(err instanceof SessionNotFoundError)) throw err; + const service = createWorkspaceRuntimeSessionService(runtime); + const location = await service.getSessionLocation(sessionId); + assertRuntimeGenerationOpen?.(); + if (location === 'conflict') { + throw new SessionConflictError(sessionId); + } + const renamed = location + ? await service.renameSession( + sessionId, + displayName, + 'manual', + location, + ) + : false; + assertRuntimeGenerationOpen?.(); + if (!renamed) { + throw new SessionNotFoundError(sessionId); + } + effective = { displayName: displayName || undefined }; + } + invalidateSessionLists(runtime, ['active', 'archived']); + res.status(200).json({ sessionId, ...effective }); + }); + }); + } catch (err) { + sendBridgeError(res, err, { + route, + sessionId, + workspaceCwd: runtime.workspaceCwd, + }); + } + }, + ); + type SessionOrganizationTarget = { runtime?: WorkspaceRuntime; resolveRuntime?: ( diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 59056ec1c2b..aea2548ea7b 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -641,6 +641,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', + 'workspace_session_metadata', // Baseline (always advertised) — presence means the `/voice/stream` // endpoint exists; the WS errors if no voice model is configured. 'voice_transcribe', @@ -700,6 +701,7 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'workspace_persisted_transcript' && f !== 'workspace_session_export' && f !== 'workspace_archived_session_export' && + f !== 'workspace_session_metadata' && f !== 'voice_transcribe' && f !== 'realtime_voice', ), @@ -755,6 +757,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'workspace_persisted_transcript', 'workspace_session_export', 'workspace_archived_session_export', + 'workspace_session_metadata', 'workspace_qualified_acp', 'client_mcp_over_ws', 'cdp_tunnel_over_ws', @@ -23363,6 +23366,47 @@ describe('createServeApp', () => { req .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret'); + const createWorkspaceMetadataApp = ( + secondaryBridge: FakeBridge, + options: { + trusted?: boolean; + sessionRuntimeBaseDir?: string; + primaryBridge?: FakeBridge; + generationGuard?: WorkspaceGenerationGuard; + } = {}, + ) => { + const primaryBridge = options.primaryBridge ?? fakeBridge(); + const registry = createWorkspaceRegistry([ + makeWorkspaceRuntimeForTest({ + workspaceId: 'ws-primary', + workspaceCwd: WS_BOUND, + primary: true, + bridge: primaryBridge, + }), + makeWorkspaceRuntimeForTest({ + workspaceId: 'ws-secondary', + workspaceCwd: WS_DIFFERENT, + primary: false, + bridge: secondaryBridge, + ...(options.trusted !== undefined + ? { trusted: options.trusted } + : {}), + ...(options.sessionRuntimeBaseDir !== undefined + ? { sessionRuntimeBaseDir: options.sessionRuntimeBaseDir } + : {}), + ...(options.generationGuard + ? { generationGuard: options.generationGuard } + : {}), + }), + ]); + return { + app: createServeApp(tokenOpts, undefined, { + workspaceRegistry: registry, + }), + primaryBridge, + registry, + }; + }; it('200 on successful metadata update', async () => { const bridge = fakeBridge(); @@ -23458,6 +23502,311 @@ describe('createServeApp', () => { expect(res.status).toBe(400); expect(res.body.code).toBe('invalid_metadata'); }); + + it('updates the selected workspace runtime with client identity', async () => { + const secondaryBridge = fakeBridge(); + const { app, primaryBridge } = + createWorkspaceMetadataApp(secondaryBridge); + const res = await auth( + request(app).patch( + '/workspaces/ws-secondary/session/session-A/metadata', + ), + ) + .set('X-Qwen-Client-Id', 'client-1') + .send({ displayName: 'Secondary session' }); + + expect(res.status).toBe(200); + expect(secondaryBridge.updateMetadataCalls).toEqual([ + { + sessionId: 'session-A', + metadata: { displayName: 'Secondary session' }, + context: { clientId: 'client-1' }, + }, + ]); + expect(primaryBridge.updateMetadataCalls).toEqual([]); + }); + + it('fails closed when the live session owner is unavailable', async () => { + const secondaryBridge = fakeBridge(); + const { app, registry } = createWorkspaceMetadataApp(secondaryBridge); + vi.spyOn(registry, 'resolveLiveSessionOwner').mockReturnValue({ + kind: 'unavailable', + }); + + const res = await auth( + request(app).patch( + '/workspaces/ws-secondary/session/session-A/metadata', + ), + ).send({ displayName: 'Blocked' }); + + expect(res.status).toBe(503); + expect(res.body.code).toBe('workspace_runtime_unavailable'); + expect(secondaryBridge.updateMetadataCalls).toEqual([]); + }); + + it('fails closed when the selected workspace generation closes', async () => { + const generationGuard = createWorkspaceGenerationGuard(); + const secondaryBridge = fakeBridge({ + updateMetadataImpl: (_sessionId, metadata) => { + generationGuard.close(); + return metadata; + }, + }); + const { app } = createWorkspaceMetadataApp(secondaryBridge, { + generationGuard, + }); + + const res = await auth( + request(app).patch( + '/workspaces/ws-secondary/session/session-A/metadata', + ), + ).send({ displayName: 'Blocked' }); + + expect(res.status).toBe(503); + expect(res.body.code).toBe('workspace_runtime_unavailable'); + }); + + it.each([ + [{}, 'displayName'], + [{ displayName: 123 }, 'displayName'], + [{ displayName: '' }, 'displayName'], + [{ displayName: ' ' }, 'displayName'], + [{ displayName: 'bad\nname' }, 'displayName'], + ] as const)( + 'rejects invalid workspace metadata %#', + async (body, field) => { + const secondaryBridge = fakeBridge(); + const { app } = createWorkspaceMetadataApp(secondaryBridge); + const res = await auth( + request(app).patch( + '/workspaces/ws-secondary/session/session-A/metadata', + ), + ).send(body); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ code: 'invalid_metadata', field }); + expect(secondaryBridge.updateMetadataCalls).toEqual([]); + }, + ); + + it('clamps workspace metadata displayName to 256 characters', async () => { + const secondaryBridge = fakeBridge(); + const { app } = createWorkspaceMetadataApp(secondaryBridge); + const res = await auth( + request(app).patch( + '/workspaces/ws-secondary/session/session-A/metadata', + ), + ).send({ displayName: 'x'.repeat(300) }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + sessionId: 'session-A', + displayName: 'x'.repeat(256), + }); + expect(secondaryBridge.updateMetadataCalls).toEqual([ + { + sessionId: 'session-A', + metadata: { displayName: 'x'.repeat(256) }, + }, + ]); + }); + + it('rejects metadata updates for an untrusted workspace', async () => { + const secondaryBridge = fakeBridge(); + const { app } = createWorkspaceMetadataApp(secondaryBridge, { + trusted: false, + }); + const res = await auth( + request(app).patch( + '/workspaces/ws-secondary/session/session-A/metadata', + ), + ).send({ displayName: 'Blocked' }); + + expect(res.status).toBe(403); + expect(secondaryBridge.updateMetadataCalls).toEqual([]); + }); + + it.each(['active', 'archived'] as const)( + 'renames a persisted %s session in the selected workspace', + async (state) => { + const runtimeBaseDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-workspace-metadata-'), + ); + const sessionId = `550e8400-e29b-41d4-a716-4466554400${ + state === 'active' ? '31' : '32' + }`; + const chatsDir = path.join( + new Storage(WS_DIFFERENT, runtimeBaseDir).getProjectDir(), + 'chats', + ...(state === 'archived' ? ['archive'] : []), + ); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + await fsp.mkdir(chatsDir, { recursive: true }); + await fsp.writeFile( + filePath, + `${JSON.stringify({ + uuid: 'record-1', + parentUuid: null, + sessionId, + timestamp: '2026-05-17T12:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'original' }] }, + cwd: WS_DIFFERENT, + })}\n`, + 'utf8', + ); + const secondaryBridge = fakeBridge({ + updateMetadataImpl: () => { + throw new SessionNotFoundError(sessionId); + }, + }); + const { app } = createWorkspaceMetadataApp(secondaryBridge, { + sessionRuntimeBaseDir: runtimeBaseDir, + }); + + try { + const res = await auth( + request(app).patch( + `/workspaces/ws-secondary/session/${sessionId}/metadata`, + ), + ).send({ displayName: 'Persisted rename' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + sessionId, + displayName: 'Persisted rename', + }); + expect(await fsp.readFile(filePath, 'utf8')).toContain( + 'Persisted rename', + ); + } finally { + await fsp.rm(runtimeBaseDir, { recursive: true, force: true }); + } + }, + ); + + it('returns 404 for a missing persisted session and 409 for a store conflict', async () => { + const runtimeBaseDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-workspace-metadata-conflict-'), + ); + const sessionId = '550e8400-e29b-41d4-a716-446655440033'; + const secondaryBridge = fakeBridge({ + updateMetadataImpl: () => { + throw new SessionNotFoundError(sessionId); + }, + }); + const { app } = createWorkspaceMetadataApp(secondaryBridge, { + sessionRuntimeBaseDir: runtimeBaseDir, + }); + const patchMetadata = () => + auth( + request(app).patch( + `/workspaces/ws-secondary/session/${sessionId}/metadata`, + ), + ).send({ displayName: 'Rename' }); + + try { + expect((await patchMetadata()).status).toBe(404); + + const chatsDir = path.join( + new Storage(WS_DIFFERENT, runtimeBaseDir).getProjectDir(), + 'chats', + ); + const record = `${JSON.stringify({ + uuid: 'record-1', + parentUuid: null, + sessionId, + timestamp: '2026-05-17T12:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'original' }] }, + cwd: WS_DIFFERENT, + })}\n`; + await fsp.mkdir(path.join(chatsDir, 'archive'), { recursive: true }); + await Promise.all([ + fsp.writeFile(path.join(chatsDir, `${sessionId}.jsonl`), record), + fsp.writeFile( + path.join(chatsDir, 'archive', `${sessionId}.jsonl`), + record, + ), + ]); + + const conflict = await patchMetadata(); + expect(conflict.status).toBe(409); + expect(conflict.body).toMatchObject({ + code: 'session_conflict', + sessionId, + }); + } finally { + await fsp.rm(runtimeBaseDir, { recursive: true, force: true }); + } + }); + + it('rejects a rename when the session is live in another workspace runtime', async () => { + const runtimeBaseDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-workspace-metadata-live-owner-'), + ); + const sessionId = '550e8400-e29b-41d4-a716-446655440034'; + const chatsDir = path.join( + new Storage(WS_DIFFERENT, runtimeBaseDir).getProjectDir(), + 'chats', + ); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + await fsp.mkdir(chatsDir, { recursive: true }); + await fsp.writeFile( + filePath, + `${JSON.stringify({ + uuid: 'record-1', + parentUuid: null, + sessionId, + timestamp: '2026-05-17T12:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'original' }] }, + cwd: WS_DIFFERENT, + })}\n`, + 'utf8', + ); + const primaryBridge = fakeBridge({ + summaryImpl: (id: string) => ({ + sessionId: id, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }), + }); + const secondaryBridge = fakeBridge({ + updateMetadataImpl: () => { + throw new SessionNotFoundError(sessionId); + }, + }); + const { app } = createWorkspaceMetadataApp(secondaryBridge, { + sessionRuntimeBaseDir: runtimeBaseDir, + primaryBridge, + }); + + try { + const res = await auth( + request(app).patch( + `/workspaces/ws-secondary/session/${sessionId}/metadata`, + ), + ).send({ displayName: 'Live elsewhere' }); + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + code: 'session_workspace_conflict', + sessionId, + workspaceCwd: WS_DIFFERENT, + liveWorkspaceCwd: WS_BOUND, + liveWorkspaceId: 'ws-primary', + }); + expect(secondaryBridge.updateMetadataCalls).toEqual([]); + expect(primaryBridge.updateMetadataCalls).toEqual([]); + expect(await fsp.readFile(filePath, 'utf8')).not.toContain( + 'Live elsewhere', + ); + } finally { + await fsp.rm(runtimeBaseDir, { recursive: true, force: true }); + } + }); }); describe('POST /session/:id/heartbeat', () => { diff --git a/packages/core/src/services/sessionService.rename.test.ts b/packages/core/src/services/sessionService.rename.test.ts index 9d2c9b1657d..a5043b75ed2 100644 --- a/packages/core/src/services/sessionService.rename.test.ts +++ b/packages/core/src/services/sessionService.rename.test.ts @@ -116,6 +116,22 @@ describe('SessionService - rename and custom title', () => { expect(writtenRecord.sessionId).toBe(sessionIdA); }); + it('should rename a session in the archive store', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.renameSession( + sessionIdA, + 'archived session', + 'manual', + 'archived', + ); + + expect(result).toBe(true); + expect(vi.mocked(jsonl.writeLineSync).mock.calls[0][0]).toContain( + `/archive/${sessionIdA}.jsonl`, + ); + }); + it('should return false when session does not exist', async () => { vi.mocked(jsonl.readLines).mockResolvedValue([]); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 4e778e43864..78d3ae21347 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1795,20 +1795,19 @@ export class SessionService { * @param titleSource Where the title came from. Defaults to `'manual'` so * existing callers are unchanged — pass `'auto'` only for titles produced * by the auto-title generator. + * @param archiveState Which session store to rename. Defaults to active. * @returns true if renamed successfully, false if session not found - * @remarks Only checks active sessions. Use `getSessionLocation()` or - * `sessionExistsInAnyState()` for archive-aware lookups. */ async renameSession( sessionId: string, title: string, titleSource: TitleSource = 'manual', + archiveState: SessionArchiveState = 'active', ): Promise { if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { return false; } - const chatsDir = this.getChatsDir(); - const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + const filePath = this.getSessionFilePath(sessionId, archiveState); try { // Verify the file exists and belongs to this project diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 263888c83f7..d6390468ef7 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -5933,6 +5933,19 @@ export class WorkspaceDaemonClient { ); } + updateSessionMetadata( + sessionId: string, + metadata: { displayName: string }, + clientId?: string, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/session/${urlEncode(sessionId)}/metadata`, + 'PATCH /workspaces/:workspace/session/:id/metadata', + { method: 'PATCH', body: metadata, clientId, mode: 'rest' }, + ); + } + listSessionGroups(): Promise { return this.get( '/session-groups', diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index ef8060cd504..6f892acde55 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -7367,6 +7367,54 @@ describe('DaemonClient', () => { } }); + it('workspace metadata update uses encoded direct REST and client identity', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + sessionId: 'session/1', + displayName: 'Renamed', + }), + ); + const transportFetch = vi.fn(async () => + jsonResponse(404, { error: 'transport route not mapped' }), + ); + const transport: DaemonTransport = { + type: 'acp-http', + supportsReplay: true, + connected: true, + fetch: transportFetch, + async *subscribeEvents() {}, + dispose() {}, + }; + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + transport, + }); + + await expect( + client + .workspaceByCwd('/tmp/work space') + .updateSessionMetadata( + 'session/1', + { displayName: 'Renamed' }, + 'client-1', + ), + ).resolves.toEqual({ + sessionId: 'session/1', + displayName: 'Renamed', + }); + + expect(transportFetch).not.toHaveBeenCalled(); + expect(calls[0]).toMatchObject({ + method: 'PATCH', + url: 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/session/session%2F1/metadata', + headers: { 'x-qwen-client-id': 'client-1' }, + }); + expect(JSON.parse(calls[0]!.body!)).toEqual({ + displayName: 'Renamed', + }); + }); + it('workspace transcript paging forces direct REST transport', async () => { const body = { v: 1 as const, diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index dbe766f7f9e..006d8b6662a 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -978,14 +978,14 @@ .gitBranchIcon { display: inline-flex; - width: 16px; - height: 16px; - flex: 0 0 16px; + width: var(--git-branch-icon-size, 16px); + height: var(--git-branch-icon-size, 16px); + flex: 0 0 var(--git-branch-icon-size, 16px); } .gitBranchIcon svg { - width: 16px; - height: 16px; + width: var(--git-branch-icon-size, 16px); + height: var(--git-branch-icon-size, 16px); } .gitBranchText { @@ -1006,17 +1006,17 @@ .gitBranchIconWrap { position: relative; display: inline-flex; - flex: 0 0 16px; + flex: 0 0 var(--git-branch-icon-size, 16px); } /* Compact (icon-only) chip: a single severity dot on the branch icon so the working-tree state is still glanceable without the text + inline indicators. */ .gitBranchBadgeDot { position: absolute; - top: -2px; - right: -3px; - width: 7px; - height: 7px; + top: var(--git-branch-badge-offset, -2px); + right: var(--git-branch-badge-offset, -3px); + width: var(--git-branch-badge-size, 7px); + height: var(--git-branch-badge-size, 7px); border-radius: 50%; background: var(--chat-editor-accent-color); box-shadow: 0 0 0 1.5px var(--chat-editor-bg-primary, transparent); diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index 199c063c367..45686dab1af 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -536,17 +536,16 @@ describe('ChatEditor context usage ring', () => { }); expect(document.body.textContent).toContain('53.6k / 1.0M tokens (5.4%)'); - // The arrow must be the Radix-positioned element: a pseudo-element pinned - // to the content center stops pointing at the trigger once collision - // avoidance shifts the content near the viewport edge. jsdom has no - // layout, so pin the positioning classes the rendered arrow depends on — - // a shadcn regeneration that drops them would detach the arrow visually - // while an existence check stayed green. - const arrowClass = document - .querySelector('[data-slot="tooltip-arrow"]') - ?.getAttribute('class'); - expect(arrowClass).toContain('rotate-45'); - expect(arrowClass).toContain('translate-y-[calc(-50%_-_2px)]'); + const arrow = document.querySelector( + '[data-slot="tooltip-arrow"]', + ); + expect(arrow?.querySelectorAll('path')).toHaveLength(2); + expect(arrow?.style.transform).toBe( + 'translateY(var(--floating-arrow-offset))', + ); + expect( + arrow?.closest('[data-slot="tooltip-content"]')?.getAttribute('class'), + ).toContain('[--floating-arrow-offset:-1px]'); }); it('escalates the arc color at the /context panel thresholds', () => { diff --git a/packages/web-shell/client/components/sidebar/SessionDetailsSubmenu.keyboard.test.tsx b/packages/web-shell/client/components/sidebar/SessionDetailsSubmenu.keyboard.test.tsx deleted file mode 100644 index d76b7cb3936..00000000000 --- a/packages/web-shell/client/components/sidebar/SessionDetailsSubmenu.keyboard.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuTrigger, -} from '../ui/dropdown-menu'; - -const { I18nProvider } = await import('../../i18n'); -const { SessionDetailsSubmenu } = await import('./SessionDetailsSubmenu'); - -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -if (!globalThis.PointerEvent) { - globalThis.PointerEvent = MouseEvent as typeof PointerEvent; -} -if (!Element.prototype.hasPointerCapture) { - Element.prototype.hasPointerCapture = () => false; -} -if (!Element.prototype.setPointerCapture) { - Element.prototype.setPointerCapture = () => {}; -} -if (!Element.prototype.releasePointerCapture) { - Element.prototype.releasePointerCapture = () => {}; -} - -const session = { - sessionId: 'session-id-copied-by-real-radix-keyboard-interaction', - displayName: 'Keyboard session', - clientCount: 1, - hasActivePrompt: false, -} as DaemonSessionSummary; - -let root: Root; -let container: HTMLDivElement; -let webShellRoot: HTMLDivElement; -let clipboardDescriptor: PropertyDescriptor | undefined; - -function render(onError: ReturnType): void { - act(() => { - root.render( - -
{ - if (element) webShellRoot = element; - }} - data-web-shell-root - > - - - - - - - webShellRoot} - /> - - - -
-
, - ); - }); -} - -async function click(element: HTMLElement): Promise { - await act(async () => { - element.dispatchEvent( - new PointerEvent('pointerdown', { - bubbles: true, - button: 0, - pointerType: 'mouse', - }), - ); - element.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); -} - -async function pressKey(element: HTMLElement, key: string): Promise { - await act(async () => { - element.dispatchEvent( - new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }), - ); - await Promise.resolve(); - }); -} - -async function focus(element: HTMLElement): Promise { - await act(async () => { - element.focus(); - await Promise.resolve(); - }); -} - -function getMenuItem(label: string): HTMLElement { - const item = document.body.querySelector( - `[data-slot="dropdown-menu-item"][aria-label="${label}"]`, - ); - expect(item).not.toBeNull(); - return item!; -} - -beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); -}); - -afterEach(() => { - act(() => root.unmount()); - container.remove(); - if (clipboardDescriptor) { - Object.defineProperty(navigator, 'clipboard', clipboardDescriptor); - } else { - Reflect.deleteProperty(navigator, 'clipboard'); - } - vi.restoreAllMocks(); -}); - -describe('SessionDetailsSubmenu keyboard behavior', () => { - it('copies through Enter and Space on the real Radix menu item', async () => { - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); - render(vi.fn()); - - const actions = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'More actions', - ); - expect(actions).toBeDefined(); - await click(actions!); - - const details = document.body.querySelector( - '[data-slot="dropdown-menu-sub-trigger"]', - ); - expect(details).not.toBeNull(); - await focus(details!); - await pressKey(details!, 'ArrowRight'); - - const copy = getMenuItem('Copy session ID'); - const detailsContent = document.body.querySelector( - '[data-slot="dropdown-menu-sub-content"]', - ); - expect(detailsContent?.classList.contains('min-w-0')).toBe(true); - expect(detailsContent?.classList.contains('min-w-[96px]')).toBe(false); - expect(detailsContent?.classList.contains('p-3')).toBe(true); - expect(detailsContent?.classList.contains('p-1')).toBe(false); - expect(copy.classList.contains('cursor-pointer')).toBe(true); - expect(copy.classList.contains('cursor-default')).toBe(false); - await focus(copy); - expect(document.activeElement).toBe(copy); - await pressKey(copy, 'Enter'); - expect(document.activeElement).toBe(copy); - await pressKey(copy, ' '); - expect(document.activeElement).toBe(copy); - - expect(writeText).toHaveBeenNthCalledWith(1, session.sessionId); - expect(writeText).toHaveBeenNthCalledWith(2, session.sessionId); - expect(document.body.querySelector('[role="status"]')?.textContent).toBe( - 'Session ID copied', - ); - }); -}); diff --git a/packages/web-shell/client/components/sidebar/SessionDetailsSubmenu.test.tsx b/packages/web-shell/client/components/sidebar/SessionDetailsSubmenu.test.tsx deleted file mode 100644 index 76bae845f0e..00000000000 --- a/packages/web-shell/client/components/sidebar/SessionDetailsSubmenu.test.tsx +++ /dev/null @@ -1,401 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - act, - forwardRef, - type ButtonHTMLAttributes, - type PropsWithChildren, -} from 'react'; -import { createPortal } from 'react-dom'; -import { createRoot, type Root } from 'react-dom/client'; -import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; - -const { dropdownMenu } = vi.hoisted(() => ({ - dropdownMenu: { - subContentProps: null as unknown, - subContentPropsHistory: [] as unknown[], - open: false, - onOpenChange: null as ((open: boolean) => void) | null, - copyItemOnSelect: null as ((event: Event) => void) | null, - portalRoot: null as HTMLElement | null, - }, -})); - -vi.mock('../ui/dropdown-menu', () => { - function DropdownMenuSub({ - children, - open = false, - onOpenChange = () => {}, - }: PropsWithChildren<{ - open?: boolean; - onOpenChange?: (open: boolean) => void; - }>) { - dropdownMenu.open = open; - dropdownMenu.onOpenChange = onOpenChange; - return dropdownMenu.portalRoot - ? createPortal(children, dropdownMenu.portalRoot) - : children; - } - - const DropdownMenuSubTrigger = forwardRef< - HTMLButtonElement, - ButtonHTMLAttributes - >(function DropdownMenuSubTrigger({ onClick, ...props }, ref) { - return ( - + + , + ); + }); + + await openDetails(container); + + const details = document.querySelector('[role="dialog"]'); + expect(details?.textContent).toContain('Improve sidebar'); + expect(details?.textContent).toContain('2 weeks ago'); + expect(details?.textContent).toContain('qwen-code'); + expect(details?.querySelector('[title="/work/qwen-code"]')).not.toBeNull(); + expect(details?.textContent).toContain('codex/sidebar'); + expect(details?.textContent).toContain('2 client(s)'); + expect(details?.querySelector('svg path.fill-popover')).not.toBeNull(); + + act(() => root.unmount()); + }); + + it('does not reopen after a row action opens its menu', async () => { + vi.useFakeTimers(); + const container = document.createElement('div'); + const portalRoot = document.createElement('div'); + document.body.appendChild(container); + document.body.appendChild(portalRoot); + const root = createRoot(container); + + act(() => { + root.render( + + +
+ + {createPortal(, portalRoot)} +
+
+
, + ); + }); + + const row = container.firstElementChild; + const action = container.querySelector('button'); + const menuItem = portalRoot.querySelector('button'); + act(() => { + row?.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + }); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + + act(() => { + action?.dispatchEvent(new Event('pointerdown', { bubbles: true })); + action?.click(); + vi.advanceTimersByTime(100); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + + act(() => { + menuItem?.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + }); + + expect(document.querySelector('[role="dialog"]')).toBeNull(); + act(() => root.unmount()); + }); + + it('copies the complete session ID from the pointer-only panel', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + + + , + ); + }); + + const trigger = container.querySelector('button'); + await act(async () => { + trigger?.focus(); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + + await openDetails(container); + const copy = document.querySelector( + 'button[aria-label="Copy session ID"]', + ); + expect(copy?.tabIndex).toBe(-1); + expect(document.activeElement).toBe(trigger); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + + await act(async () => { + copy?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith('complete-session-id'); + expect(copy?.querySelector('.lucide-check')).not.toBeNull(); + expect(document.querySelector('[aria-live="polite"]')?.className).toBe( + 'sr-only', + ); + act(() => vi.advanceTimersByTime(2000)); + expect(copy?.querySelector('.lucide-copy')).not.toBeNull(); + act(() => root.unmount()); + }); + + it('keeps only the latest copy result', async () => { + vi.useFakeTimers(); + const first = deferred(); + const second = deferred(); + const writeText = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + + + , + ); + }); + await openDetails(container); + const copy = document.querySelector( + 'button[aria-label="Copy session ID"]', + ); + await act(async () => { + copy?.click(); + copy?.click(); + second.resolve(undefined); + await second.promise; + }); + expect(copy?.querySelector('.lucide-check')).not.toBeNull(); + + await act(async () => { + first.reject(new Error('stale failure')); + await first.promise.catch(() => undefined); + }); + expect(copy?.querySelector('.lucide-check')).not.toBeNull(); + act(() => root.unmount()); + }); + + it('ignores a pending copy result after the details close', async () => { + vi.useFakeTimers(); + const pending = deferred(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockReturnValue(pending.promise) }, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + + + , + ); + }); + await openDetails(container); + const trigger = container.querySelector('button'); + const copy = document.querySelector( + 'button[aria-label="Copy session ID"]', + ); + await act(async () => { + copy?.click(); + trigger?.click(); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + + await act(async () => { + pending.resolve(undefined); + await pending.promise; + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + act(() => root.unmount()); + }); + + it('reports clipboard failures', async () => { + vi.useFakeTimers(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + + + , + ); + }); + + await openDetails(container); + await act(async () => { + document + .querySelector( + 'button[aria-label="Copy session ID"]', + ) + ?.click(); + }); + + expect(document.body.textContent).toContain('Failed to copy session ID'); + act(() => root.unmount()); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx new file mode 100644 index 00000000000..8d944ec6a94 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState, type ReactElement } from 'react'; +import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; +import { + CheckIcon, + CopyIcon, + FolderClosedIcon, + GitBranchIcon, + RadioTowerIcon, +} from 'lucide-react'; +import { useI18n } from '../../i18n'; +import { workspaceBasename } from '../../utils/workspace'; +import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover'; +import styles from './WebShellSidebar.module.css'; +import { resolveSessionDetailsCollisionBoundary } from './sessionDetailsCollisionBoundary'; + +interface SessionDetailsTooltipProps { + session: DaemonSessionSummary; + label: string; + time: string; + completedUnread: boolean; + children: ReactElement; +} + +export function SessionDetailsTooltip({ + session, + label, + time, + completedUnread, + children, +}: SessionDetailsTooltipProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const copyAttemptRef = useRef(0); + const copyResetTimerRef = useRef(undefined); + const openTimerRef = useRef(undefined); + const closeTimerRef = useRef(undefined); + const anchorRef = useRef(null); + const collisionBoundary = open + ? resolveSessionDetailsCollisionBoundary( + anchorRef.current?.closest('aside') ?? null, + ) + : null; + const folderPath = session.workspaceCwd; + const folderName = workspaceBasename(folderPath); + const branch = session.worktree?.branch ?? session.branch?.name; + const status = session.hasActivePrompt + ? t('sidebar.running') + : completedUnread + ? t('sidebar.completedUnread') + : t('sidebar.clients', { count: session.clientCount ?? 0 }); + + useEffect(() => { + return () => { + window.clearTimeout(openTimerRef.current); + window.clearTimeout(closeTimerRef.current); + window.clearTimeout(copyResetTimerRef.current); + copyAttemptRef.current += 1; + }; + }, []); + + useEffect(() => { + copyAttemptRef.current += 1; + window.clearTimeout(copyResetTimerRef.current); + setCopyStatus('idle'); + }, [session.sessionId]); + + 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); + copyAttemptRef.current += 1; + window.clearTimeout(copyResetTimerRef.current); + setCopyStatus('idle'); + }; + const closeAfterDelay = () => { + window.clearTimeout(openTimerRef.current); + cancelClose(); + closeTimerRef.current = window.setTimeout(close, 100); + }; + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) setOpen(true); + else close(); + }; + + return ( + + { + if (event.currentTarget.contains(event.target as Node)) { + openAfterDelay(); + } + }} + onPointerLeave={closeAfterDelay} + onPointerDownCapture={close} + onClick={() => handleOpenChange(false)} + > + {children} + + event.preventDefault()} + onPointerEnter={cancelClose} + onPointerLeave={closeAfterDelay} + className={styles.sessionDetailsTooltip} + > +
+ + {label} + + {time && {time}} +
+
+
+ {branch && ( +
+
+ )} +
+
+
+ {session.sessionId} + + + {copyStatus === 'copied' + ? t('sidebar.sessionIdCopied') + : copyStatus === 'failed' + ? t('sidebar.copySessionIdFailed') + : ''} + +
+
+
+ ); +} diff --git a/packages/web-shell/client/components/sidebar/SessionGroupSection.test.tsx b/packages/web-shell/client/components/sidebar/SessionGroupSection.test.tsx new file mode 100644 index 00000000000..4e87ce9c785 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/SessionGroupSection.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it } from 'vitest'; +import { I18nProvider } from '../../i18n'; +import { SessionGroupSection } from './SessionGroupSection'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('SessionGroupSection', () => { + it('shows five sessions and resets Show all after collapsing', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const render = (expanded: boolean) => { + act(() => { + root.render( + + {}} + > + {Array.from({ length: 6 }, (_, index) => ( +
Session {index + 1}
+ ))} +
+
, + ); + }); + }; + + render(true); + expect(container.textContent).toContain('Session 5'); + expect(container.textContent).not.toContain('Session 6'); + const showAll = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Show all', + ); + act(() => showAll?.click()); + expect(container.textContent).toContain('Session 6'); + + render(false); + render(true); + expect(container.textContent).not.toContain('Session 6'); + + act(() => root.unmount()); + container.remove(); + }); + + it('shows every session when preview limiting is disabled', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + {}} + > + {Array.from({ length: 6 }, (_, index) => ( +
Session {index + 1}
+ ))} +
+
, + ); + }); + + expect(container.textContent).toContain('Session 6'); + expect(container.textContent).not.toContain('Show all'); + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/SessionGroupSection.tsx b/packages/web-shell/client/components/sidebar/SessionGroupSection.tsx index 019823d8010..9d2c49d9a2a 100644 --- a/packages/web-shell/client/components/sidebar/SessionGroupSection.tsx +++ b/packages/web-shell/client/components/sidebar/SessionGroupSection.tsx @@ -1,4 +1,10 @@ -import type { CSSProperties, ReactNode } from 'react'; +import { + Children, + useEffect, + useState, + type CSSProperties, + type ReactNode, +} from 'react'; import type { DaemonSessionGroupColor } from '@qwen-code/sdk/daemon'; import { ChevronDownIcon, @@ -6,6 +12,8 @@ import { PencilIcon, Trash2Icon, } from 'lucide-react'; +import { SIDEBAR_SESSION_PREVIEW_LIMIT } from '../../constants/sessions'; +import { useI18n } from '../../i18n'; import styles from './WebShellSidebar.module.css'; export interface SessionGroupSectionProps { @@ -21,6 +29,7 @@ export interface SessionGroupSectionProps { renameLabel?: string; deleteLabel?: string; actionsDisabled?: boolean; + limitSessions?: boolean; } export function SessionGroupSection({ @@ -35,7 +44,14 @@ export function SessionGroupSection({ renameLabel, deleteLabel, actionsDisabled, + limitSessions = true, }: SessionGroupSectionProps) { + const { t } = useI18n(); + const [showAll, setShowAll] = useState(false); + const items = Children.toArray(children); + useEffect(() => { + if (!expanded) setShowAll(false); + }, [expanded]); const colorClass = color?.startsWith('#') ? styles.groupColorCustom : color @@ -95,7 +111,24 @@ export function SessionGroupSection({ )} - {expanded &&
{children}
} + {expanded && ( +
+ {!limitSessions || showAll + ? items + : items.slice(0, SIDEBAR_SESSION_PREVIEW_LIMIT)} + {limitSessions && + !showAll && + items.length > SIDEBAR_SESSION_PREVIEW_LIMIT && ( + + )} +
+ )} ); } diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx index 3ec777b0c85..820f92ba9e2 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx @@ -1,9 +1,11 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as React from 'react'; -import { act } from 'react'; +import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; +import type { WebShellSidebarSessionActionsOptions } from './WebShellSidebar'; +import sidebarStyles from './WebShellSidebar.module.css'; const { connection, workspace, workspaceActions, active, pinned, archived } = vi.hoisted(() => { @@ -79,6 +81,7 @@ const { connection, workspace, workspaceActions, active, pinned, archived } = }; }); const refreshSessionCatalogQueries = vi.hoisted(() => vi.fn()); +const loadSession = vi.hoisted(() => vi.fn()); vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useConnection: () => connection, @@ -213,23 +216,35 @@ const namedGroup = { let root: Root; let container: HTMLDivElement; -function renderSidebar() { +function renderSidebar( + collapsed = false, + props: { + onSelectCurrentSession?: () => void; + sessionActions?: WebShellSidebarSessionActionsOptions; + strict?: boolean; + } = {}, +) { + const sidebar = ( + {}} + onOpenSettings={() => {}} + onOpenDaemonStatus={() => {}} + onOpenScheduledTasks={() => {}} + onOpenGoals={() => {}} + onOpenSessions={() => {}} + onOpenSplitView={() => {}} + onNewSession={() => false} + onLoadSession={loadSession} + onSelectCurrentSession={props.onSelectCurrentSession} + onError={() => {}} + sessionActions={props.sessionActions} + /> + ); act(() => { root.render( - {}} - onOpenSettings={() => {}} - onOpenDaemonStatus={() => {}} - onOpenScheduledTasks={() => {}} - onOpenGoals={() => {}} - onOpenSessions={() => {}} - onOpenSplitView={() => {}} - onNewSession={() => false} - onLoadSession={() => {}} - onError={() => {}} - /> + {props.strict ? {sidebar} : sidebar} , ); }); @@ -288,6 +303,7 @@ beforeEach(() => { archived.sessions = []; archived.data = archived.sessions; refreshSessionCatalogQueries.mockReset(); + loadSession.mockReset(); }); afterEach(() => { @@ -298,17 +314,164 @@ afterEach(() => { }); describe('WebShellSidebar collapsed session group persistence', () => { - it('shows the complete session name in a native tooltip', async () => { + it('keeps project sessions available from the collapsed sidebar', async () => { + connection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization', 'session_archive'], + }; + workspace.capabilities = connection.capabilities; + pinned.sessions = [ + makeSession('session-pinned', { + displayName: 'Pinned task', + isPinned: true, + }), + ]; + pinned.data = pinned.sessions; + archived.sessions = [ + makeSession('session-archived', { + displayName: 'Archived task', + }), + ]; + archived.data = archived.sessions; + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + expect(trigger).not.toBeNull(); + + act(() => { + trigger?.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(switcher?.textContent).toContain('API review'); + expect(switcher?.textContent).toContain('Pinned task'); + expect(switcher?.textContent).toContain('Archived'); + expect( + switcher?.querySelector('button[aria-label="Search sessions"]'), + ).not.toBeNull(); + + const archivedHeader = Array.from( + switcher?.querySelectorAll('button') ?? [], + ).find((button) => button.textContent?.includes('Archived')); + expect(archivedHeader).not.toBeNull(); + act(() => click(archivedHeader!)); + await flushSidebar(); + expect(switcher?.textContent).toContain('Archived task'); + + act(() => click(trigger!)); + await flushSidebar(); + const clickedSwitcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(clickedSwitcher).not.toBeNull(); + + const session = Array.from( + clickedSwitcher?.querySelectorAll('[role="button"]') ?? [], + ).find((row) => row.textContent?.includes('API review')); + expect(session).not.toBeNull(); + + act(() => click(session!)); + await flushSidebar(); + + expect(loadSession).toHaveBeenCalledWith('session-a', '/tmp/project'); + const closingSwitcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(closingSwitcher?.dataset.state ?? 'closed').toBe('closed'); + }); + + it('closes the switcher without reloading the current session', async () => { + connection.sessionId = 'session-a'; + const onSelectCurrentSession = vi.fn(); + renderSidebar(true, { onSelectCurrentSession }); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + expect(trigger).not.toBeNull(); + act(() => { + trigger?.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + const session = Array.from( + switcher?.querySelectorAll('[role="button"]') ?? [], + ).find((row) => row.textContent?.includes('API review')); + expect(session).not.toBeNull(); + + act(() => click(session!)); + await flushSidebar(); + + expect(onSelectCurrentSession).toHaveBeenCalledTimes(1); + expect(loadSession).not.toHaveBeenCalled(); + const closingSwitcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(closingSwitcher?.dataset.state ?? 'closed').toBe('closed'); + }); + + it('keeps the collapsed session switcher open for session actions', async () => { + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + act(() => { + trigger?.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + const moreActions = switcher?.querySelector( + 'button[aria-label="More actions"]', + ); + expect(moreActions).not.toBeNull(); + + act(() => { + moreActions!.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + await flushSidebar(); + + const menu = document.querySelector('[role="menu"]'); + expect(menu).not.toBeNull(); + expect(menu?.style.zIndex).toBe( + 'calc(var(--web-shell-popover-z-index, 1000) + 1)', + ); + expect(switcher?.dataset.state).toBe('open'); + }); + + it('renders the complete session name', async () => { renderSidebar(); await flushSidebar(); - const sessionName = container.querySelector( - '[title="API review"]', + const sessionName = Array.from(container.querySelectorAll('span')).find( + (element) => element.textContent === 'API review', ); expect(sessionName?.textContent).toContain('API review'); }); - it('shows the complete archived session name in a native tooltip', async () => { + it('renders the complete archived session name', async () => { connection.capabilities = { qwenCodeVersion: '1.2.3', features: ['session_organization', 'session_archive'], @@ -331,10 +494,18 @@ describe('WebShellSidebar collapsed session group persistence', () => { act(() => click(archivedHeader!)); await flushSidebar(); - const sessionName = container.querySelector( - '[title="Archived task"]', + const sessionName = Array.from(container.querySelectorAll('span')).find( + (element) => element.textContent === 'Archived task', ); expect(sessionName?.textContent).toContain('Archived task'); + + // The archived row has no keyboard interaction, so it must not be an + // inert tab stop. + const archivedRow = sessionName?.closest( + '[class*="archivedRow"]', + ); + expect(archivedRow).not.toBeNull(); + expect(archivedRow!.tabIndex).toBe(-1); }); it('refreshes archived sessions each time the section expands', async () => { @@ -567,4 +738,574 @@ describe('WebShellSidebar collapsed session group persistence', () => { ), ).toEqual(['ws:other|group:g2', 'ws:other|ungrouped']); }); + + it('does not let a stale hover-close timer close a reopened switcher', async () => { + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + expect(trigger).not.toBeNull(); + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + expect( + document.querySelector('[data-web-shell-collapsed-session-switcher]'), + ).not.toBeNull(); + + // Pointer leave arms the 150ms close timer; a reopen (tap on touch, + // Enter on the keyboard) inside that window must cancel it. + act(() => { + trigger!.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })); + }); + act(() => { + trigger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(switcher).not.toBeNull(); + expect(switcher?.dataset.state).toBe('open'); + }); + + it('lets the switcher close after a tracked menu unmounts open', async () => { + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + const moreActions = switcher?.querySelector( + 'button[aria-label="More actions"]', + ); + expect(moreActions).not.toBeNull(); + act(() => { + moreActions!.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + await flushSidebar(); + expect(document.querySelector('[role="menu"]')).not.toBeNull(); + + // A poll removes the row, unmounting the row's open menu without Radix + // ever emitting a close event. + active.sessions = active.sessions.filter( + (session) => session.sessionId !== 'session-a', + ); + active.data = active.sessions; + renderSidebar(true); + await flushSidebar(); + + const reopened = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(reopened).not.toBeNull(); + act(() => { + reopened!.dispatchEvent( + new PointerEvent('pointerout', { bubbles: true }), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + + const after = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(after === null || after.dataset.state === 'closed').toBe(true); + }); + + it('keeps the switcher open while the group picker is open', async () => { + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + const moreActions = switcher?.querySelector( + 'button[aria-label="More actions"]', + ); + expect(moreActions).not.toBeNull(); + act(() => { + moreActions!.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + await flushSidebar(); + + const groupItem = Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.includes('Group')); + expect(groupItem).not.toBeNull(); + act(() => { + groupItem!.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + groupItem!.dispatchEvent( + new PointerEvent('pointerup', { bubbles: true }), + ); + groupItem!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + + // The bespoke group picker (plain role="menu", no Radix data-slot) must + // keep the switcher underneath it open. + expect(switcher?.dataset.state).toBe('open'); + act(() => { + switcher!.dispatchEvent( + new PointerEvent('pointerout', { bubbles: true }), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + expect( + document + .querySelector('[data-web-shell-collapsed-session-switcher]') + ?.getAttribute('data-state'), + ).toBe('open'); + }); + + it('keeps the switcher open while keyboard focus is inside it', async () => { + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + const search = switcher?.querySelector( + 'button[aria-label="Search sessions"]', + ); + expect(search).not.toBeNull(); + act(() => { + search!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + + const input = switcher?.querySelector('input'); + expect(input).not.toBeNull(); + act(() => { + input!.focus(); + }); + expect(document.activeElement).toBe(input); + + act(() => { + switcher!.dispatchEvent( + new PointerEvent('pointerout', { bubbles: true }), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + + const after = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(after).not.toBeNull(); + expect(after?.dataset.state).toBe('open'); + }); + + it('resets the search when the collapsed switcher closes on session selection', async () => { + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + expect(trigger).not.toBeNull(); + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(switcher).not.toBeNull(); + const searchButton = switcher!.querySelector( + 'button[aria-label="Search sessions"]', + ); + expect(searchButton).not.toBeNull(); + act(() => { + searchButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + expect(switcher!.querySelector('input')).not.toBeNull(); + + // Selecting a session closes the switcher; the stale search state must + // not survive to the next hover-open (it would remount the autofocused + // input and steal focus from the composer). + const row = Array.from( + switcher!.querySelectorAll('[role="button"]'), + ).find((element) => element.textContent?.includes('API review')); + expect(row).not.toBeUndefined(); + act(() => { + row!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + expect(loadSession).toHaveBeenCalledWith('session-a', '/tmp/project'); + expect( + document.querySelector('[data-web-shell-collapsed-session-switcher]'), + ).toBeNull(); + + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + const reopened = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(reopened).not.toBeNull(); + expect(reopened!.querySelector('input')).toBeNull(); + }); + + it('resets collapsed search state when the sidebar expands', async () => { + renderSidebar(true); + await flushSidebar(); + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + const search = switcher!.querySelector( + 'button[aria-label="Search sessions"]', + ); + act(() => + search!.dispatchEvent(new MouseEvent('click', { bubbles: true })), + ); + await flushSidebar(); + const input = switcher!.querySelector('input'); + act(() => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(input, 'no-match'); + input!.dispatchEvent(new Event('input', { bubbles: true })); + }); + + renderSidebar(false); + await flushSidebar(); + + expect(container.querySelector('[class*="projectSearch"]')).toBeNull(); + expect(container.textContent).toContain('API review'); + expect(container.textContent).not.toContain('No matching sessions.'); + }); + + it('keeps keyboard semantics when a pointer grazes the open switcher', async () => { + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + expect(trigger).not.toBeNull(); + + // Keyboard open: Enter activates the focused trigger; the keydown marks + // the open as keyboard-initiated before the click opens the popover. + act(() => { + trigger!.focus(); + }); + act(() => { + trigger!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + }); + act(() => { + trigger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(switcher?.dataset.state).toBe('open'); + + // A mouse graze over the content must not convert the keyboard-opened + // switcher to pointer semantics. + act(() => { + switcher!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + + const searchButton = switcher!.querySelector( + 'button[aria-label="Search sessions"]', + ); + expect(searchButton).not.toBeNull(); + act(() => { + searchButton!.focus(); + }); + expect(document.activeElement).toBe(searchButton); + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + }); + await flushSidebar(); + // Radix dispatches the close-time focus restoration from a 0ms timer. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + const after = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(after === null || after.dataset.state === 'closed').toBe(true); + // Focus must return to the trigger, not drop to the body. + expect(document.activeElement).toBe(trigger); + }); + + it('restores focus to the trigger when a pointer-opened switcher closes with focus inside', async () => { + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + expect(trigger).not.toBeNull(); + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(switcher?.dataset.state).toBe('open'); + const searchButton = switcher!.querySelector( + 'button[aria-label="Search sessions"]', + ); + expect(searchButton).not.toBeNull(); + act(() => { + searchButton!.focus(); + }); + expect(document.activeElement).toBe(searchButton); + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + }); + await flushSidebar(); + // Radix dispatches the close-time focus restoration from a 0ms timer. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + const closed = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(closed === null || closed.dataset.state === 'closed').toBe(true); + expect(document.activeElement).toBe(trigger); + }); + + it('cancels an in-flight rename when the collapsed switcher is dismissed', async () => { + connection.sessionId = 'session-a'; + renderSidebar(true); + await flushSidebar(); + + const trigger = container.querySelector( + '[data-web-shell-collapsed-session-trigger]', + ); + expect(trigger).not.toBeNull(); + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + + const switcher = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(switcher).not.toBeNull(); + const moreActions = Array.from( + switcher!.querySelectorAll( + 'button[aria-label="More actions"]', + ), + ).find((button) => + button + .closest('[role="button"]') + ?.textContent?.includes('API review'), + ); + expect(moreActions).not.toBeNull(); + act(() => { + moreActions!.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + await flushSidebar(); + + const renameItem = Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.includes('Rename')); + expect(renameItem).toBeDefined(); + act(() => { + renameItem!.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + renameItem!.dispatchEvent( + new PointerEvent('pointerup', { bubbles: true }), + ); + renameItem!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushSidebar(); + + const input = switcher!.querySelector('input'); + expect(input).not.toBeNull(); + act(() => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(input, 'xy'); + input!.dispatchEvent(new Event('input', { bubbles: true })); + }); + + // An outside pointer press dismisses the popover; the focused input + // unmounts without a blur event, so the dismissal itself must cancel + // the rename. + // Radix re-registers its document pointerdown listener through a 0ms + // timer whenever the dismissable-layer stack changes (the session menu + // above just unmounted); let it settle before the outside interaction. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + act(() => { + document.body.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + document.body.dispatchEvent( + new PointerEvent('pointerup', { bubbles: true }), + ); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await flushSidebar(); + expect( + document.querySelector('[data-web-shell-collapsed-session-switcher]'), + ).toBeNull(); + + act(() => { + trigger!.dispatchEvent( + new PointerEvent('pointerover', { bubbles: true }), + ); + }); + await flushSidebar(); + const reopened = document.querySelector( + '[data-web-shell-collapsed-session-switcher]', + ); + expect(reopened).not.toBeNull(); + expect(reopened!.querySelector('input')).toBeNull(); + }); + + it('does not render the actions overlay when a row has no available actions', async () => { + renderSidebar(false, { sessionActions: { items: ['details'] } }); + await flushSidebar(); + + const row = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((element) => element.textContent?.includes('API review')); + expect(row).not.toBeUndefined(); + expect(row!.querySelector(`.${sidebarStyles.sessionActions}`)).toBeNull(); + }); + + it('opens the rename editor on double-click', async () => { + connection.sessionId = 'session-a'; + renderSidebar(false); + await flushSidebar(); + + const row = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((element) => element.textContent?.includes('API review')); + expect(row).not.toBeUndefined(); + act(() => { + row!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + await flushSidebar(); + + expect( + container.querySelector( + 'input[aria-label="Rename: API review"]', + ), + ).not.toBeNull(); + }); + + it('keeps the rename editor mounted under the StrictMode effect replay', async () => { + connection.sessionId = 'session-a'; + renderSidebar(false, { strict: true }); + await flushSidebar(); + + const row = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((element) => element.textContent?.includes('API review')); + expect(row).not.toBeUndefined(); + act(() => { + row!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect( + container.querySelector( + 'input[aria-label="Rename: API review"]', + ), + ).not.toBeNull(); + }); }); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 365c888c753..e29fb8eaac9 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -83,7 +83,23 @@ flex: 0 0 auto; flex-direction: column; gap: 2px; - padding: 12px 0; + padding-bottom: 4px; +} + +.newTaskNav { + display: flex; + width: 100%; + flex: 0 0 auto; + padding: 12px 0 2px; + border-bottom: 1px solid transparent; +} + +.newTaskNavScrolled { + border-bottom-color: color-mix( + in srgb, + var(--sidebar-border) 55%, + transparent + ); } .newChatButton:disabled { @@ -386,7 +402,17 @@ flex-direction: column; gap: 8px; margin-right: -12px; - overflow: hidden; + padding-right: 12px; + /* End the scroll port above the footer overlay so rows can never sit + under it (padding inside the scroll port would not: the port itself + still reaches the footer and rows parked at its bottom edge stay + unhoverable). */ + margin-bottom: 40px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + touch-action: pan-y; } .sectionTitle { @@ -435,6 +461,7 @@ flex: 0 0 auto; align-items: center; gap: 2px; + margin-left: auto; margin-right: 8px; } @@ -477,20 +504,12 @@ .sessionText { min-width: 0; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } -.sessionBadgeIcon { - display: inline; - vertical-align: -1px; - margin-right: 3px; - color: var(--color-accent-fg, #8b5cf6); - flex-shrink: 0; -} - .projectName { flex: 0 1 auto; + text-overflow: ellipsis; } .projectIconButton:last-child { @@ -507,18 +526,11 @@ } .sessionList { - flex: 1 1 auto; - min-height: 0; + flex: 0 0 auto; display: flex; flex-direction: column; gap: 4px; - padding-right: 12px; - padding-bottom: 44px; - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; - touch-action: pan-y; + overflow: visible; } .sessionGroupSection { @@ -643,18 +655,56 @@ gap: 2px; } +.showAllSessions { + align-self: flex-start; + margin-left: 26px; + padding: 2px 0; + border: 0; + background: transparent; + color: var(--muted-foreground); + font-size: 14px; + line-height: 22px; + cursor: pointer; +} + +.showAllSessions:hover { + color: var(--sidebar-foreground); +} + +.showAllSessions:focus-visible { + color: var(--sidebar-foreground); + outline: 2px solid var(--sidebar-ring); + outline-offset: 2px; +} + .sessionRow { position: relative; flex: 0 0 auto; display: flex; align-items: center; - gap: 6px; + gap: 2px; padding: 2px 0 2px 26px; border-radius: 8px; color: color-mix(in srgb, var(--sidebar-foreground) 68%, transparent); cursor: pointer; } +.collapsedSessionPopover { + overscroll-behavior: contain; + width: min( + var(--collapsed-session-popover-width, 260px), + var(--radix-popover-content-available-width, 420px) + ); + max-width: var(--radix-popover-content-available-width, 420px); + max-height: min(70vh, var(--radix-popover-content-available-height, 70vh)); + display: flex; + flex-direction: column; + gap: 0; + padding: 8px; + overflow-y: auto; + touch-action: pan-y; +} + .currentSession { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); @@ -665,121 +715,96 @@ pointer-events: none; } -.floatingTooltip { - position: fixed; - z-index: 1000; - max-width: 320px; - min-width: 220px; - padding: 10px 12px; +.sessionDetailsTooltip { + width: min(360px, var(--radix-popover-content-available-width, 360px)); + min-width: min(280px, var(--radix-popover-content-available-width, 280px)); + max-width: var(--radix-popover-content-available-width, 360px); + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 12px 14px; border: 1px solid var(--border); - border-radius: 8px; - background: var(--popover); - color: var(--popover-foreground); - box-shadow: - 0 2px 4px -2px rgb(0 0 0 / 10%), - 0 4px 6px -1px rgb(0 0 0 / 10%); + box-shadow: none; font-size: 12px; - font-weight: 400; - line-height: 18px; - overflow-wrap: anywhere; - pointer-events: auto; - transform: translateY(-50%); + line-height: 20px; } -.floatingTooltip::before { - position: absolute; - top: 0; - bottom: 0; - left: -12px; - width: 12px; - content: ''; +.sessionDetailsHeader, +.sessionDetailsRow { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; } -.tooltipContent { - display: flex; - flex-direction: column; - gap: 8px; +.sessionDetailsHeader { + justify-content: space-between; + gap: 16px; } -.tooltipTitle { - display: -webkit-box; +.sessionDetailsTitle { + min-width: 0; overflow: hidden; color: var(--popover-foreground); - font-size: 13px; - font-weight: 500; - line-height: 20px; - overflow-wrap: anywhere; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - line-clamp: 3; -} - -.tooltipTags { - display: flex; - flex-wrap: wrap; - gap: 6px; + font-size: 14px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; } -.tooltipTag { - min-width: 0; - height: 20px; - display: inline-flex; - align-items: center; - padding: 0 6px; - border-radius: 999px; - background: var(--secondary); - color: var(--secondary-foreground); - font-size: 11px; - font-weight: 500; - line-height: 18px; +.sessionDetailsTime { + flex: 0 0 auto; + color: var(--muted-foreground); } -.tooltipTagRunning { - background: color-mix(in srgb, var(--agent-blue-500) 16%, transparent); - color: var(--agent-blue-500); +.sessionDetailsRow { + color: var(--popover-foreground); } -.tooltipTagNew { - background: color-mix(in srgb, var(--agent-blue-500) 12%, transparent); - color: var(--agent-blue-500); +.sessionDetailsRow svg { + width: 14px; + height: 14px; + flex: 0 0 14px; + color: var(--muted-foreground); + stroke-width: 1.6; } -.sessionDetailsContent { - width: min(16rem, var(--radix-dropdown-menu-content-available-width, 16rem)); - max-width: var(--radix-dropdown-menu-content-available-width, 16rem); - max-height: var(--radix-dropdown-menu-content-available-height); - overflow: hidden auto; +.sessionDetailsRow span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .sessionDetailsIdRow { - display: flex; min-width: 0; + display: flex; align-items: center; gap: 6px; } -.sessionDetailsId { +.sessionDetailsIdRow > span:first-child { min-width: 0; overflow: hidden; color: var(--muted-foreground); text-overflow: ellipsis; white-space: nowrap; - font-size: 12px; - font-weight: 400; - line-height: 18px; } .sessionDetailsCopyButton { + width: 22px; + height: 22px; + flex: 0 0 22px; display: inline-flex; - flex: 0 0 auto; align-items: center; justify-content: center; - width: 24px; - height: 24px; + padding: 0; border: 0; border-radius: 4px; background: transparent; color: var(--muted-foreground); + cursor: pointer; } .sessionDetailsCopyButton:hover, @@ -790,24 +815,53 @@ } .sessionDetailsCopyButton svg { - width: 14px; - height: 14px; + width: 13px; + height: 13px; } .sessionDetailsCopied { + flex: 0 0 auto; color: var(--muted-foreground); font-size: 12px; - line-height: 18px; } .sessionText { + position: relative; + min-width: 0; flex: 1 1 auto; - margin-right: 18px; + margin-right: 4px; + overflow: hidden; color: currentColor; font-size: 14px; line-height: 22px; } +/* The fade hints at a clipped tail; only overflowing titles clip, and the + hover measurement marks them via the overflow attribute. */ +.sessionText[data-web-shell-title-overflow] { + mask-image: linear-gradient(to right, #000 calc(100% - 10px), transparent); + -webkit-mask-image: linear-gradient( + to right, + #000 calc(100% - 10px), + transparent + ); +} + +.sessionTextInner { + display: inline-block; + min-width: max-content; +} + +.sessionRow:hover .sessionTextInner { + animation: sessionTitleScroll var(--session-title-scroll-duration, 0s) 1s + linear forwards; +} + +.sessionRow:focus-within .sessionTextInner { + animation: sessionTitleScroll var(--session-title-scroll-duration, 0s) 0.3s + linear forwards; +} + .sessionStatusSlot { position: absolute; top: 50%; @@ -896,39 +950,48 @@ } .sessionRow:hover .sessionMetaSlot, +.sessionRow:focus-within .sessionMetaSlot, .sessionMetaSlot:has(.sessionActionButton:focus-visible), .sessionMetaSlot:has([data-state='open']) { - min-width: 86px; + min-width: var(--session-actions-width, 78px); } -.sessionTime { - color: var(--muted-foreground); - font-size: 12px; - line-height: 18px; - white-space: nowrap; - padding-right: 10px; +.sessionGitIcon { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-accent-fg, #8b5cf6); } -.sessionLoading { - margin-right: 10px; +.sessionGitIcon svg { width: 14px; height: 14px; + stroke-width: 1.6; +} + +.sessionLoading { + margin-right: 10px; + width: 12px; + height: 12px; display: inline-flex; - border: 2px solid color-mix(in srgb, var(--agent-blue-300) 24%, transparent); - border-top-color: var(--agent-blue-300); + border: 1.5px solid + color-mix(in srgb, var(--muted-foreground) 28%, transparent); + border-top-color: var(--muted-foreground); border-radius: 999px; animation: sidebarSpin 0.8s linear infinite; } -.sessionRow:hover:not(.runningSession) .sessionTime, -.sessionRow:focus-within:not(.runningSession) .sessionTime, +.sessionRow:hover:not(.runningSession) .sessionGitIcon, +.sessionRow:focus-within:not(.runningSession) .sessionGitIcon, .sessionRow:hover:not(.runningSession) .sessionAttention, .sessionRow:focus-within:not(.runningSession) .sessionAttention, -.sessionMetaSlot:hover .sessionTime, +.sessionMetaSlot:hover .sessionGitIcon, .sessionMetaSlot:hover .sessionLoading, .sessionMetaSlot:hover .sessionAttention, -.sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionTime, -.sessionMetaSlot:has([data-state='open']) .sessionTime, +.sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionGitIcon, +.sessionMetaSlot:has([data-state='open']) .sessionGitIcon, .sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionLoading, .sessionMetaSlot:has([data-state='open']) .sessionLoading, .sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionAttention, @@ -942,7 +1005,7 @@ display: inline-flex; align-items: center; justify-content: flex-end; - gap: 2px; + gap: 0; opacity: 0; } @@ -960,6 +1023,19 @@ } } +@keyframes sessionTitleScroll { + to { + transform: translateX(calc(var(--session-title-scroll-distance, 0px) * -1)); + } +} + +@media (prefers-reduced-motion: reduce) { + .sessionRow:hover .sessionTextInner, + .sessionRow:focus-within .sessionTextInner { + animation: none; + } +} + @keyframes sidebarPulse { 0%, 100% { @@ -993,6 +1069,7 @@ .sessionActionButton { width: 26px; height: 26px; + flex: 0 0 26px; border-radius: 7px; } @@ -1209,6 +1286,10 @@ text-align: left; } +.notice { + padding: 4px 8px 4px 26px; +} + .retry { width: 100%; border-radius: 8px; @@ -1447,7 +1528,19 @@ .collapsed .body { align-items: center; width: 100%; + gap: 2px; margin-right: 0; + padding-right: 0; + margin-bottom: 220px; +} + +.collapsed .newTaskNav { + justify-content: center; +} + +.collapsed .primaryNav { + align-items: center; + padding-bottom: 0; } .collapsed .sessionList { @@ -1455,7 +1548,6 @@ width: 100%; margin-right: 0; padding-right: 0; - padding-bottom: 220px; } .collapsed .footer { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 26a4cfcc552..93764bc0441 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -8,6 +8,7 @@ import { type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, + type ReactElement, type ReactNode, } from 'react'; import { @@ -26,6 +27,7 @@ import type { DaemonSessionSummary, DaemonWorkspaceCapability, DaemonWorkspaceRemovalActivity, + SessionMetadataResult, } from '@qwen-code/sdk/daemon'; import { ActivityIcon, @@ -41,6 +43,7 @@ import { ArchiveIcon, ArchiveRestoreIcon, DownloadIcon, + FolderClosedIcon, FolderInputIcon, GitBranchIcon, GitForkIcon, @@ -79,21 +82,29 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '../ui/dropdown-menu'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { formatRelativeTime } from '../../utils/formatRelativeTime'; import { DialogShell } from '../dialogs/DialogShell'; import { WorkspaceSection } from './WorkspaceSection'; +import { + hasWorkspaceExpansionPreference, + migrateWorkspaceExpansionPreference, + readWorkspaceExpanded, + writeWorkspaceExpanded, +} from './workspaceExpansion'; import { SessionGroupSection } from './SessionGroupSection'; -import { SessionDetailsSubmenu } from './SessionDetailsSubmenu'; +import { SessionDetailsTooltip } from './SessionDetailsTooltip'; import { groupSessionsByChannelType } from './channelSessionGroups'; -import { resolveSessionDetailsCollisionBoundary } from './sessionDetailsCollisionBoundary'; import { isPrimaryCollapsedSectionId, readCollapsedSessionSectionIds, replaceOwnedCollapsedSessionSectionIds, } from './collapsedSessionSections'; +import { measureSessionTitleScroll } from './sessionTitleScroll'; import { SESSION_LIST_PAGE_SIZE, SESSION_ORGANIZATION_FEATURE, + SIDEBAR_SESSION_PREVIEW_LIMIT, } from '../../constants/sessions'; import styles from './WebShellSidebar.module.css'; import { @@ -120,6 +131,9 @@ const IDLE_SESSION_POLL_INTERVAL_MS = 30_000; const DIALOG_SESSION_LABEL_MAX_LENGTH = 96; const RECENT_SESSION_SECTION_ID = 'recent'; const GROUP_MENU_WIDTH = 240; +const SESSION_MENU_PORTAL_STYLE: CSSProperties = { + zIndex: 'calc(var(--web-shell-popover-z-index, 1000) + 1)', +}; const GROUP_MENU_MARGIN = 8; const CUSTOM_GROUP_COLOR_OPTION = '__custom__'; const DEFAULT_CUSTOM_GROUP_COLOR: DaemonSessionGroupHexColor = '#416ef5'; @@ -524,6 +538,212 @@ function IconChevron({ expanded }: { expanded: boolean }) { ); } +function SessionMenu({ + onOpenChange, + children, +}: { + onOpenChange: (open: boolean) => void; + children: ReactNode; +}) { + const openRef = useRef(false); + useEffect( + () => () => { + // Radix emits no onOpenChange(false) when an open menu unmounts (its + // row removed by a poll or preview slice); without the close signal the + // collapsed surface's dismissal guards stay blocked forever. + if (openRef.current) onOpenChange(false); + }, + [onOpenChange], + ); + return ( + { + openRef.current = open; + onOpenChange(open); + }} + > + {children} + + ); +} + +function SidebarSessionSurface({ + collapsed, + label, + width, + open, + onOpenChange, + isCloseBlocked, + children, +}: { + collapsed: boolean; + label: string; + width: number; + open: boolean; + onOpenChange: (open: boolean) => void; + isCloseBlocked: () => boolean; + children: ReactNode; +}) { + const closeTimerRef = useRef(undefined); + const pointerOpenRef = useRef(false); + const focusInsideSurfaceRef = useRef(false); + const triggerRef = useRef(null); + const contentRef = useRef(null); + const changeOpen = useCallback( + (nextOpen: boolean) => { + if (!nextOpen && isCloseBlocked()) return; + if (nextOpen && !open) focusInsideSurfaceRef.current = false; + onOpenChange(nextOpen); + }, + [isCloseBlocked, onOpenChange, open], + ); + const cancelClose = useCallback( + () => window.clearTimeout(closeTimerRef.current), + [], + ); + const closeAfterDelay = useCallback(() => { + cancelClose(); + closeTimerRef.current = window.setTimeout(() => { + // A hover-out must not unmount the surface while it holds keyboard + // focus (search or rename inputs): the close would drop focus to body. + // document.activeElement retargets to the shadow host in shadow-DOM + // portal mode, so also probe the surface's own tree for :focus. + if ( + contentRef.current?.contains(document.activeElement) || + contentRef.current?.querySelector(':focus') + ) { + return; + } + changeOpen(false); + }, 150); + }, [cancelClose, changeOpen]); + + useEffect(() => () => window.clearTimeout(closeTimerRef.current), []); + + useEffect(() => { + if (!collapsed || !open) return; + const handlePointerMove = (event: PointerEvent) => { + if (!pointerOpenRef.current) return; + // Shadow-DOM portal mode retargets event.target to the shadow host; + // composedPath keeps the real node (same approach as App.tsx). + const target = + (event.composedPath()[0] as Node | undefined) ?? event.target; + const insideSurface = + target instanceof Node && + (triggerRef.current?.contains(target) || + contentRef.current?.contains(target)); + const insideNestedOverlay = + target instanceof Element && + target.closest( + '[data-slot="dropdown-menu-content"], [data-slot="popover-content"]', + ); + if (!insideSurface && !insideNestedOverlay) { + closeAfterDelay(); + } else { + cancelClose(); + } + }; + document.addEventListener('pointermove', handlePointerMove); + return () => { + document.removeEventListener('pointermove', handlePointerMove); + window.clearTimeout(closeTimerRef.current); + }; + }, [cancelClose, closeAfterDelay, collapsed, open]); + + if (!collapsed) { + return
{children}
; + } + + return ( +
+ + + + + { + focusInsideSurfaceRef.current = true; + }} + className={styles.collapsedSessionPopover} + style={ + { + '--collapsed-session-popover-width': `${width}px`, + } as CSSProperties + } + data-web-shell-collapsed-session-switcher + onOpenAutoFocus={(event) => { + if (pointerOpenRef.current) event.preventDefault(); + }} + onCloseAutoFocus={(event) => { + // Suppress Radix's focus restoration only while focus stayed + // outside a pointer-opened surface; once focus has moved into + // the content (search, rename), closing must return it to the + // trigger like a keyboard-opened surface. Radix fires this + // after the content unmounts, so the flag is tracked while the + // surface is open instead of probed from document.activeElement. + if (pointerOpenRef.current && !focusInsideSurfaceRef.current) { + event.preventDefault(); + } + pointerOpenRef.current = false; + focusInsideSurfaceRef.current = false; + }} + onPointerEnter={cancelClose} + onPointerLeave={closeAfterDelay} + onInteractOutside={(event) => { + const originalTarget = event.detail.originalEvent.composedPath()[0]; + const target = (originalTarget as Node | undefined) ?? event.target; + if ( + target instanceof Element && + target.closest( + '[data-slot="dropdown-menu-content"], [data-slot="popover-content"]', + ) + ) { + event.preventDefault(); + } + }} + > + {children} + + +
+ ); +} + export function WebShellSidebar({ collapsed, onCollapsedChange, @@ -576,6 +796,12 @@ export function WebShellSidebar({ () => new Set(primaryNavOptions?.items ?? DEFAULT_PRIMARY_NAV_ITEMS), [primaryNavOptions?.items], ); + const hasScrollingPrimaryNav = + primaryNavItems.has('plugins') || + primaryNavItems.has('channels') || + primaryNavItems.has('scheduledTasks') || + primaryNavItems.has('goals') || + Boolean(primaryNavOptions?.render); const sessionActionItems = useMemo( () => new Set(sessionActionsOptions?.items ?? DEFAULT_SESSION_ACTION_ITEMS), [sessionActionsOptions?.items], @@ -622,6 +848,9 @@ export function WebShellSidebar({ 'workspace_qualified_rest_core', ), ); + const workspaceSessionMetadataEnabled = Boolean( + connection.capabilities?.features?.includes('workspace_session_metadata'), + ); // Phase 4: registered workspaces on a multi-workspace daemon (absent or a // single entry otherwise). Drives the new-session workspace picker. const workspaces = useMemo( @@ -636,6 +865,9 @@ export function WebShellSidebar({ workspaces.find((entry) => entry.primary)?.cwd ?? workspace.capabilities?.workspaceCwd ?? connection.workspaceCwd; + const primaryWorkspaceExpansionId = `primary:${ + primaryWorkspaceCwd ?? 'default' + }`; const lockedWorkspace = lockedWorkspaceCwd ? workspaces.find((entry) => entry.cwd === lockedWorkspaceCwd) : undefined; @@ -729,7 +961,12 @@ export function WebShellSidebar({ const [editingSessionIdentity, setEditingSessionIdentity] = useState< string | null >(null); + const [editingSession, setEditingSession] = + useState(null); const [editingName, setEditingName] = useState(''); + // Mirrors editingSessionIdentity for promise callbacks that outlive the + // render where the rename started. + const editingSessionIdentityRef = useRef(null); const [busySessionIds, setBusySessionIds] = useState>( () => new Set(), ); @@ -786,9 +1023,16 @@ export function WebShellSidebar({ setGroupsCatalogReady(!organizationEnabled); } const [sidebarWidth, setSidebarWidth] = useState(readSidebarWidth); - const [projectExpanded, setProjectExpanded] = useState(false); - const [projectsExpanded, setProjectsExpanded] = useState(true); + const [projectExpanded, setProjectExpanded] = useState(() => + readWorkspaceExpanded(primaryWorkspaceExpansionId), + ); + const [showAllProjectSessions, setShowAllProjectSessions] = useState(false); + const [projectsExpanded, setProjectsExpanded] = useState( + () => hideProjectHeader || readWorkspaceExpanded('projects'), + ); + const [collapsedSessionsOpen, setCollapsedSessionsOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); + const [bodyScrolled, setBodyScrolled] = useState(false); const [workspaceRemovalCandidate, setWorkspaceRemovalCandidate] = useState(null); const [workspaceRemovalActivity, setWorkspaceRemovalActivity] = @@ -813,6 +1057,25 @@ export function WebShellSidebar({ setWorkspaceSessionsReloadToken((v) => v + 1); }, []); + useEffect(() => { + // The five-row preview is scoped per source and per primary workspace; + // reset the one-shot show-all when either changes, not only on collapse. + setShowAllProjectSessions(false); + }, [projectExpanded, primaryWorkspaceExpansionId, selectedSessionSource]); + + const previousPrimaryExpansionIdRef = useRef(primaryWorkspaceExpansionId); + useEffect(() => { + const previousId = previousPrimaryExpansionIdRef.current; + previousPrimaryExpansionIdRef.current = primaryWorkspaceExpansionId; + if (previousId !== primaryWorkspaceExpansionId) { + migrateWorkspaceExpansionPreference( + previousId, + primaryWorkspaceExpansionId, + ); + } + setProjectExpanded(readWorkspaceExpanded(primaryWorkspaceExpansionId)); + }, [primaryWorkspaceExpansionId]); + useEffect(() => { workspaceRemovalMountedRef.current = true; return () => { @@ -828,6 +1091,29 @@ export function WebShellSidebar({ const sidebarRef = useRef(null); const groupMenuRef = useRef(null); const sessionMenuPointerDismissRef = useRef(false); + const sessionMenuOpenRef = useRef(false); + const renameFocusSuppressRef = useRef(false); + const handleSessionMenuOpenChange = useCallback((open: boolean) => { + sessionMenuOpenRef.current = open; + if (open) renameFocusSuppressRef.current = false; + }, []); + const handleSessionMenuPointerDownOutside = useCallback(() => { + sessionMenuPointerDismissRef.current = true; + }, []); + const handleSessionMenuCloseAutoFocus = useCallback((event: Event) => { + if (renameFocusSuppressRef.current) { + renameFocusSuppressRef.current = false; + event.preventDefault(); + return; + } + if (!sessionMenuPointerDismissRef.current) return; + sessionMenuPointerDismissRef.current = false; + event.preventDefault(); + }, []); + const isCollapsedCloseBlocked = useCallback( + () => sessionMenuOpenRef.current || groupMenu !== null, + [groupMenu], + ); const previousRunningBySourceRef = useRef< Record | null> >({ default: null, channel: null }); @@ -1051,7 +1337,8 @@ export function WebShellSidebar({ const canUseWorkspaceQualifiedActions = useCallback( (scope: SessionWorkspaceScope) => scope.kind === 'primary' || - (scope.kind === 'locked' && workspaceQualifiedRestCoreEnabled), + ((scope.kind === 'locked' || scope.kind === 'restricted') && + workspaceQualifiedRestCoreEnabled), [workspaceQualifiedRestCoreEnabled], ); // Organization (pin/group) is safe for any trusted workspace — not just @@ -1064,11 +1351,6 @@ export function WebShellSidebar({ }, [workspaceQualifiedRestCoreEnabled], ); - const isActiveSessionReadOnly = useCallback( - (session: DaemonSessionSummary) => - !isMutableSessionScope(resolveSessionWorkspaceScope(session)), - [isMutableSessionScope, resolveSessionWorkspaceScope], - ); const getSessionWorkspaceActions = useCallback( (session: DaemonSessionSummary) => { const scope = resolveSessionWorkspaceScope(session); @@ -1091,15 +1373,24 @@ export function WebShellSidebar({ [currentSessionIdentity, getIdentityForSession], ); const canRenameSession = useCallback( - (session: DaemonSessionSummary) => - sessionActionItems.has('rename') && - isCurrentSession(session) && - isMutableSessionScope(resolveSessionWorkspaceScope(session)), + (session: DaemonSessionSummary) => { + if (!sessionActionItems.has('rename')) return false; + const scope = resolveSessionWorkspaceScope(session); + if (isCurrentSession(session) && isMutableSessionScope(scope)) { + return true; + } + return ( + workspaceSessionMetadataEnabled && + canUseWorkspaceQualifiedActions(scope) + ); + }, [ + canUseWorkspaceQualifiedActions, isCurrentSession, isMutableSessionScope, resolveSessionWorkspaceScope, sessionActionItems, + workspaceSessionMetadataEnabled, ], ); const canShowDeleteSession = useCallback( @@ -1268,13 +1559,22 @@ export function WebShellSidebar({ const contextKey = `session:${currentSessionId}:${activeWorkspace.id}`; if (autoOpenedContextRef.current === contextKey) return; autoOpenedContextRef.current = contextKey; - setProjectsExpanded(true); + if (!hasWorkspaceExpansionPreference('projects')) { + setProjectsExpanded(true); + } if (activeWorkspace.primary) { - setProjectExpanded(true); - } else { + if (!hasWorkspaceExpansionPreference(primaryWorkspaceExpansionId)) { + setProjectExpanded(true); + } + } else if (!hasWorkspaceExpansionPreference(activeWorkspace.id)) { setAutoExpandWorkspace({ id: activeWorkspace.id, key: contextKey }); } - }, [connection.workspaceCwd, currentSessionId, displayedWorkspaces]); + }, [ + connection.workspaceCwd, + currentSessionId, + displayedWorkspaces, + primaryWorkspaceExpansionId, + ]); useEffect(() => { if (currentSessionId || selectedWorkspaceCwd !== undefined) { @@ -1287,21 +1587,27 @@ export function WebShellSidebar({ const contextKey = `new:${connectedWorkspace?.id ?? 'primary'}`; if (autoOpenedContextRef.current === contextKey) return; autoOpenedContextRef.current = contextKey; - setProjectsExpanded(true); + if (!hasWorkspaceExpansionPreference('projects')) { + setProjectsExpanded(true); + } if (connectedWorkspace && !connectedWorkspace.primary) { - setProjectExpanded(false); - setAutoExpandWorkspace({ - id: connectedWorkspace.id, - key: contextKey, - }); + if (!hasWorkspaceExpansionPreference(connectedWorkspace.id)) { + setAutoExpandWorkspace({ + id: connectedWorkspace.id, + key: contextKey, + }); + } onSelectWorkspace?.(connectedWorkspace.cwd); return; } - setProjectExpanded(true); + if (!hasWorkspaceExpansionPreference(primaryWorkspaceExpansionId)) { + setProjectExpanded(true); + } }, [ connection.workspaceCwd, currentSessionId, onSelectWorkspace, + primaryWorkspaceExpansionId, selectedWorkspaceCwd, workspace.capabilities, workspaces, @@ -1442,14 +1748,6 @@ export function WebShellSidebar({ return () => window.removeEventListener('resize', handleWindowResize); }, []); - useEffect(() => { - if (collapsed) { - setProjectExpanded(false); - setSearchOpen(false); - setSearchQuery(''); - } - }, [collapsed]); - const hasRunningSession = useMemo( () => sessions.some((session) => session.hasActivePrompt), [sessions], @@ -1770,6 +2068,7 @@ export function WebShellSidebar({ const handleLoadSession = useCallback( (sessionId: string, workspaceCwd?: string) => { + setCollapsedSessionsOpen(false); const sessionIdentity = getSessionIdentity( sessionId, workspaceCwd || primaryWorkspaceCwd, @@ -1812,80 +2111,112 @@ export function WebShellSidebar({ const startRename = useCallback( (session: DaemonSessionSummary) => { if (!canRenameSession(session)) return; - setEditingSessionIdentity(getIdentityForSession(session)); + const identity = getIdentityForSession(session); + if (busySessionIdsRef.current.has(identity)) return; + setEditingSession(session); + setEditingSessionIdentity(identity); + editingSessionIdentityRef.current = identity; setEditingName(getSessionLabel(session)); }, [canRenameSession, getIdentityForSession], ); const cancelRename = useCallback(() => { + setEditingSession(null); setEditingSessionIdentity(null); + editingSessionIdentityRef.current = null; setEditingName(''); }, []); + useEffect(() => { + if (editingSession && !canRenameSession(editingSession)) { + cancelRename(); + } + }, [canRenameSession, cancelRename, editingSession]); useEffect(() => { - const currentSession = currentSessionId - ? ({ - sessionId: currentSessionId, - workspaceCwd: connection.workspaceCwd, - } as DaemonSessionSummary) - : undefined; - if ( - editingSessionIdentity !== null && - (!currentSession || - editingSessionIdentity !== currentSessionIdentity || - !canRenameSession(currentSession)) - ) { + if (!collapsed) { + setCollapsedSessionsOpen(false); + setSearchOpen(false); + setSearchQuery(''); + cancelRename(); + setGroupMenu(null); + } else if (!collapsedSessionsOpen) { + // A stale open search or rename editor would otherwise mount its + // autofocused input inside the collapsed hover popover and steal + // focus from the composer on every hover-open, so reset it whenever + // the collapsed surface is not showing (sidebar collapse, session + // click, hover-out, or dismissal). Radix's outside-interaction + // dismissal unmounts a focused input without firing blur. A stale + // group picker would likewise block dismissal of the next + // hover-opened switcher. + setSearchOpen(false); + setSearchQuery(''); cancelRename(); + setGroupMenu(null); } - }, [ - canRenameSession, - cancelRename, - connection.workspaceCwd, - currentSessionId, - currentSessionIdentity, - editingSessionIdentity, - ]); + }, [cancelRename, collapsed, collapsedSessionsOpen]); const saveRename = useCallback(() => { const nextName = editingName.trim(); if ( !nextName || - !currentSessionId || - editingSessionIdentity !== currentSessionIdentity || - !canRenameSession({ - sessionId: currentSessionId, - workspaceCwd: connection.workspaceCwd, - } as DaemonSessionSummary) + !editingSession || + editingSessionIdentity !== getIdentityForSession(editingSession) || + !canRenameSession(editingSession) ) { cancelRename(); return; } - const sessionId = currentSessionId; - const workspaceCwd = connection.workspaceCwd; - const sessionIdentity = currentSessionIdentity; - if (!sessionIdentity || busySessionIdsRef.current.has(sessionIdentity)) { + const sessionId = editingSession.sessionId; + const workspaceCwd = getSessionWorkspaceCwd(editingSession); + const sessionIdentity = getIdentityForSession(editingSession); + if (busySessionIdsRef.current.has(sessionIdentity)) { return; } - setSessionBusy(sessionId, true, connection.workspaceCwd); + setSessionBusy(sessionId, true, workspaceCwd); let renamed = false; - actions - .renameSession(nextName) - .then(() => { + const rename = isCurrentSession(editingSession) + ? actions.renameSession(nextName) + : workspaceCwd + ? workspace.client + .workspaceByCwd(workspaceCwd) + .updateSessionMetadata(sessionId, { displayName: nextName }) + : workspace.client.updateSessionMetadata(sessionId, { + displayName: nextName, + }); + rename + .then((result: SessionMetadataResult | void) => { renamed = true; + // The daemon clamps displayName to 256 chars and reports the stored + // value; propagate that instead of the locally typed string so the + // catalog cache never disagrees with the daemon. + const effectiveName = + typeof result?.displayName === 'string' && result.displayName + ? result.displayName + : nextName; if (workspaceCwd) { if (onSessionRenameConfirmed) { - onSessionRenameConfirmed(workspaceCwd, sessionId, nextName); + onSessionRenameConfirmed(workspaceCwd, sessionId, effectiveName); } else { - sessionCatalogController.renamed(workspaceCwd, sessionId, nextName); + sessionCatalogController.renamed( + workspaceCwd, + sessionId, + effectiveName, + ); } } - cancelRename(); + // A late settle must not close an editor the user moved to another + // session with while this request was in flight. + if (editingSessionIdentityRef.current === sessionIdentity) { + cancelRename(); + } bumpWorkspaceReload(); }) .catch((err: unknown) => { onError(err, t('sidebar.renameFailed')); - cancelRename(); + if (editingSessionIdentityRef.current === sessionIdentity) { + cancelRename(); + } }) .finally(() => { if (!renamed && workspaceCwd) { @@ -1898,16 +2229,18 @@ export function WebShellSidebar({ bumpWorkspaceReload, canRenameSession, cancelRename, - connection.workspaceCwd, - currentSessionIdentity, - currentSessionId, editingName, + editingSession, editingSessionIdentity, + getIdentityForSession, + getSessionWorkspaceCwd, + isCurrentSession, onSessionRenameConfirmed, onError, sessionCatalogController, setSessionBusy, t, + workspace.client, ]); const handleDeleteSession = useCallback( @@ -2015,7 +2348,7 @@ export function WebShellSidebar({ const scope = resolveSessionWorkspaceScope(deleteCandidate); const isArchived = Boolean(deleteCandidate.isArchived); const removeSession = - scope.kind === 'locked' + scope.kind === 'locked' || scope.kind === 'restricted' ? async (id: string) => { const result = await workspace.client .workspaceByCwd(scope.cwd) @@ -2065,10 +2398,10 @@ export function WebShellSidebar({ const handleRenameFromMenu = useCallback( (session: DaemonSessionSummary) => { - if (!isCurrentSession(session)) return; + renameFocusSuppressRef.current = true; startRename(session); }, - [isCurrentSession, startRename], + [startRename], ); const handleCreateGroup = useCallback(() => { @@ -2856,11 +3189,6 @@ export function WebShellSidebar({ }); }, []); - const getSessionDetailsCollisionBoundary = useCallback( - () => resolveSessionDetailsCollisionBoundary(sidebarRef.current), - [], - ); - const handleResizePointerDown = useCallback( (event: ReactPointerEvent) => { if (collapsed) return; @@ -2982,13 +3310,9 @@ export function WebShellSidebar({ session: DaemonSessionSummary, options: { isArchived?: boolean; - // Keep unlocked secondary rows conservative while allowing a trusted - // locked workspace to use normal session controls. - readOnly?: boolean; } = {}, ) => { const { isArchived = false } = options; - const readOnly = options.readOnly ?? isActiveSessionReadOnly(session); const sessionIdentity = getIdentityForSession(session); const label = getSessionLabel(session); const stamp = session.updatedAt || session.createdAt; @@ -2997,41 +3321,102 @@ export function WebShellSidebar({ const exporting = exportingSessionIds.has(sessionIdentity); const completedUnread = !isCurrentSession(session) && completedUnreadIds.has(sessionIdentity); + const isEditing = editingSessionIdentity === sessionIdentity; + const gitIcon = session.worktree ? ( + + ) : session.branch ? ( + + ) : null; + const withDetails = (row: ReactElement) => ( + + {sessionActionItems.has('details') ? ( + + {row} + + ) : ( + row + )} + + ); if (isArchived) { const archivedExportWorkspaceCwd = getArchivedExportWorkspaceCwd(session); - const showArchivedDetails = sessionActionItems.has('details'); const showArchivedExport = sessionActionItems.has('export') && Boolean(archivedExportWorkspaceCwd); const showArchivedUnarchive = canUnarchiveSession(session); const showArchivedDelete = canDeleteSession(session); + const showArchivedRename = canRenameSession(session); const hasArchivedActions = - showArchivedDetails || showArchivedExport || showArchivedUnarchive || - showArchivedDelete; - return ( + showArchivedDelete || + showArchivedRename; + return withDetails(
+ measureSessionTitleScroll(event.currentTarget) + } > - - {label} - -
- {time} + {isEditing ? ( +
event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + saveRename(); + }} + > + setEditingName(event.target.value)} + onBlur={cancelRename} + onKeyDown={(event) => { + if (event.key === 'Escape') cancelRename(); + }} + /> +
+ ) : ( + + {label} + + )} +
+ {gitIcon && ( + {gitIcon} + )} {hasArchivedActions && (
event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()} > - +
)}
-
+
, ); } const isCurrent = isCurrentSession(session); - const isEditing = isCurrent && editingSessionIdentity === sessionIdentity; const needsUserInput = !session.isWaitingForPermission && session.isWaitingForUserQuestion; const attentionLabel = session.isWaitingForPermission @@ -3111,20 +3488,30 @@ export function WebShellSidebar({ : needsUserInput ? t('sidebar.userInputNeeded') : null; - const mutableScope = !isActiveSessionReadOnly(session); const showPin = canOrganizeSession(session, 'pin'); const showArchive = sessionActionItems.has('archive') && canMutateSessionArchive(session); - const showReadOnlyArchive = readOnly && showArchive && !showPin; - const showSharedArchive = showArchive && !showReadOnlyArchive; - const showRename = sessionActionItems.has('rename') && mutableScope; + const showRename = canRenameSession(session); const activeExportScope = getActiveExportScope(session); const showExport = sessionActionItems.has('export') && Boolean(activeExportScope); const showDelete = canShowDeleteSession(session); - return ( + const inlineActionCount = + Number(showPin && inlineActionItems.has('pin')) + + Number(showArchive && inlineActionItems.has('archive')) + + Number(showRename && inlineActionItems.has('rename')) + + Number(showExport && inlineActionItems.has('export')) + + Number(showDelete && inlineActionItems.has('delete')); + const showMoreActions = + (showPin && !inlineActionItems.has('pin')) || + (showArchive && !inlineActionItems.has('archive')) || + (showRename && !inlineActionItems.has('rename')) || + canOrganizeSession(session, 'group') || + (showExport && !inlineActionItems.has('export')) || + (showDelete && !inlineActionItems.has('delete')); + const sessionActionCount = inlineActionCount + Number(showMoreActions); + return withDetails(
+ measureSessionTitleScroll(event.currentTarget) + } + onFocus={(event) => measureSessionTitleScroll(event.currentTarget)} role="button" tabIndex={0} aria-current={isCurrent ? 'page' : undefined} @@ -3139,7 +3530,7 @@ export function WebShellSidebar({ handleLoadSession(session.sessionId, session.workspaceCwd) } onDoubleClick={() => { - if (!collapsed && canRenameSession(session)) startRename(session); + if (canRenameSession(session)) startRename(session); }} onKeyDown={(event) => { if (event.key === 'Enter') { @@ -3147,127 +3538,215 @@ export function WebShellSidebar({ } }} > - {!collapsed && ( + + {completedUnread ? ( + + {isEditing && canRenameSession(session) ? ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + saveRename(); + }} + > + setEditingName(event.target.value)} + onBlur={cancelRename} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelRename(); + } + }} + /> +
+ ) : ( <> - - {completedUnread ? ( -
, ); }, [ @@ -3550,7 +3828,6 @@ export function WebShellSidebar({ canUnarchiveSession, canMutateSessionArchive, cancelRename, - collapsed, completedUnreadIds, editingName, editingSessionIdentity, @@ -3558,13 +3835,15 @@ export function WebShellSidebar({ getArchivedExportWorkspaceCwd, getActiveExportScope, getIdentityForSession, - getSessionDetailsCollisionBoundary, - onError, + getSessionWorkspaceCwd, handleArchive, handleDeleteSession, handleExportSession, handleLoadSession, handleRenameFromMenu, + handleSessionMenuCloseAutoFocus, + handleSessionMenuOpenChange, + handleSessionMenuPointerDownOutside, handleTogglePin, handleUnarchive, isCurrentSession, @@ -3572,13 +3851,36 @@ export function WebShellSidebar({ saveRename, sessionActionItems, inlineActionItems, - isActiveSessionReadOnly, startRename, t, ], ); const body = useMemo(() => { + const renderFlatSessions = () => { + const showAll = + editingSessionIdentity !== null || + showAllProjectSessions || + Boolean(searchQuery.trim()); + const displayedSessions = showAll + ? filteredSessions + : filteredSessions.slice(0, SIDEBAR_SESSION_PREVIEW_LIMIT); + return ( + <> + {displayedSessions.map((session) => renderSessionRow(session))} + {!showAll && + filteredSessions.length > SIDEBAR_SESSION_PREVIEW_LIMIT && ( + + )} + + ); + }; // Gate notices on the resource, not the filtered view: background // refreshes set loading/error while retaining the settled page, so a // filter-empty or empty-but-settled view must not flash or swap to retry. @@ -3602,7 +3904,7 @@ export function WebShellSidebar({ !organizationEnabled || sessionSections.length === 0) ) { - return
{t('sidebar.searchEmpty')}
; + return
{t('sidebar.noSessions')}
; } if (channelSessionSections) { return channelSessionSections.map((section) => ( @@ -3611,6 +3913,7 @@ export function WebShellSidebar({ id={section.id} label={section.label} count={section.sessions.length} + limitSessions={editingSessionIdentity === null && !searchQuery.trim()} expanded={!collapsedSessionSectionIds.has(section.id)} onToggle={() => toggleSessionSection(section.id)} > @@ -3619,13 +3922,13 @@ export function WebShellSidebar({ )); } if (selectedSessionSource === 'channel') { - return filteredSessions.map((session) => renderSessionRow(session)); + return renderFlatSessions(); } if (!organizationEnabled) { - return filteredSessions.map((session) => renderSessionRow(session)); + return renderFlatSessions(); } if (sessionSections.length === 0) { - return filteredSessions.map((session) => renderSessionRow(session)); + return renderFlatSessions(); } return sessionSections.map((section) => { @@ -3638,6 +3941,7 @@ export function WebShellSidebar({ label={section.label} count={section.sessions.length} color={section.color} + limitSessions={editingSessionIdentity === null && !searchQuery.trim()} expanded={expanded} onToggle={() => toggleSessionSection(section.id)} onRename={ @@ -3662,6 +3966,7 @@ export function WebShellSidebar({ collapsedSessionSectionIds, canOrganizeWorkspace, channelSessionSections, + editingSessionIdentity, error, filteredSessions, groupBusy, @@ -3675,12 +3980,13 @@ export function WebShellSidebar({ selectedSessionSource, sessionSections, sessionsPage, + showAllProjectSessions, t, toggleSessionSection, ]); const archivedSection = useMemo(() => { - if (!sessionArchiveEnabled || collapsed || searchQuery.trim()) return null; + if (!sessionArchiveEnabled || searchQuery.trim()) return null; const header = ( + + )} +
+ setBodyScrolled(event.currentTarget.scrollTop > 0) + } + > + {hasScrollingPrimaryNav && ( +
+ {primaryNavItems.has('plugins') && ( + + )} + {primaryNavItems.has('channels') && ( + + )} + {primaryNavItems.has('scheduledTasks') && ( + + )} + {primaryNavItems.has('goals') && ( + + )} + {primaryNavOptions?.render?.()} +
)} - {primaryNavItems.has('plugins') && ( - - )} - {primaryNavItems.has('channels') && ( - - )} - {primaryNavItems.has('scheduledTasks') && ( - - )} - {primaryNavItems.has('goals') && ( - - )} - {primaryNavOptions?.render?.()} -
-
-
- {!collapsed && sourceMetadataEnabled && ( + + {sourceMetadataEnabled && ( )} - {!collapsed && - selectedSessionSource !== 'channel' && + {selectedSessionSource !== 'channel' && pinnedSessions.length > 0 && ( <>
@@ -4314,81 +4638,76 @@ export function WebShellSidebar({ {pinnedExpanded && (
{pinnedSessions.map((session) => - renderSessionRow(session, { - readOnly: isActiveSessionReadOnly(session), - }), + renderSessionRow(session), )}
)} )} - {!collapsed && - liveWorkspaces.map((ws) => ( - ( - <> - ( + ( + <> +
)} - {searchOpen && !collapsed && !hideProjectHeader && ( + {searchOpen && !hideProjectHeader && (
)} - {(collapsed || projectsExpanded) && ( + {projectsExpanded && ( <> - {!collapsed && ( -
-
- {projectWorkspaces.map((ws) => ( - - +
+ {projectWorkspaces.map((ws) => ( + + + lockedWorkspaceOptions.render?.(ws, { + expanded, + }) + : undefined + } + client={workspace.client} + reloadToken={workspaceSessionsReloadToken} + untrustedLabel={t('sidebar.workspaceUntrusted')} + readOnlyLabel={t('sidebar.workspaceReadOnly')} + trustToOpenLabel={t('sidebar.workspaceTrustToOpen')} + noSessionsLabel={t('sidebar.noSessions')} + loadErrorLabel={t('sidebar.loadFailed')} + organizationEnabled={organizationEnabled} + sourceType={selectedSessionSource} + channelGroupingEnabled={channelGroupingEnabled} + ungroupedLabel={t('sidebar.groupUngrouped')} + onRenameGroup={ + canOrganizeWorkspace(ws.cwd) + ? handleRenameGroup + : undefined + } + onDeleteGroup={ + canOrganizeWorkspace(ws.cwd) + ? handleDeleteGroup + : undefined + } + renameGroupLabel={t('sidebar.groupRename')} + deleteGroupLabel={t('sidebar.groupDelete')} + groupActionsDisabled={groupBusy} + excludePinned={selectedSessionSource !== 'channel'} + limitSessions={editingSessionIdentity === null} + onOpenGitDiff={onOpenGitDiff} + onOpenCommit={onOpenCommit} + searchQuery={searchQuery} + expanded={ws.primary ? projectExpanded : undefined} + autoExpandKey={ + autoExpandWorkspace?.id === ws.id + ? autoExpandWorkspace?.key + : undefined + } + onExpandedChange={ + ws.primary + ? (expanded) => { + writeWorkspaceExpanded( + primaryWorkspaceExpansionId, + expanded, + ); + setProjectExpanded(expanded); + } + : undefined + } + renderSessions={!ws.primary} + renderSession={(session) => + renderSessionRow({ + ...session, + workspaceCwd: ws.cwd, + }) + } + showSessionDetails={sessionActionItems.has('details')} + headerActions={(visible) => { + if ( lockedWorkspaceCwd && lockedWorkspaceOptions?.render - ? (expanded) => - lockedWorkspaceOptions.render?.(ws, { - expanded, - }) - : undefined - } - client={workspace.client} - reloadToken={workspaceSessionsReloadToken} - untrustedLabel={t('sidebar.workspaceUntrusted')} - readOnlyLabel={t('sidebar.workspaceReadOnly')} - trustToOpenLabel={t('sidebar.workspaceTrustToOpen')} - noSessionsLabel={t('sidebar.noSessions')} - loadErrorLabel={t('sidebar.loadFailed')} - organizationEnabled={organizationEnabled} - sourceType={selectedSessionSource} - channelGroupingEnabled={channelGroupingEnabled} - ungroupedLabel={t('sidebar.groupUngrouped')} - onRenameGroup={ - canOrganizeWorkspace(ws.cwd) - ? handleRenameGroup - : undefined + ) { + return null; } - onDeleteGroup={ - canOrganizeWorkspace(ws.cwd) - ? handleDeleteGroup - : undefined - } - renameGroupLabel={t('sidebar.groupRename')} - deleteGroupLabel={t('sidebar.groupDelete')} - groupActionsDisabled={groupBusy} - excludePinned={selectedSessionSource !== 'channel'} - onOpenGitDiff={onOpenGitDiff} - onOpenCommit={onOpenCommit} - formatTime={(iso) => formatRelativeTime(iso, t)} - searchQuery={searchQuery} - expanded={ws.primary ? projectExpanded : undefined} - autoExpandKey={ - autoExpandWorkspace?.id === ws.id - ? autoExpandWorkspace?.key - : undefined - } - onExpandedChange={ - ws.primary ? setProjectExpanded : undefined - } - renderSessions={!ws.primary} - renderSession={(session) => - renderSessionRow( - { - ...session, - workspaceCwd: ws.cwd, - }, - { - readOnly: isActiveSessionReadOnly({ - ...session, - workspaceCwd: ws.cwd, - }), - }, - ) - } - headerActions={(visible) => { - if ( - lockedWorkspaceCwd && - lockedWorkspaceOptions?.render - ) { - return null; - } - const canRemove = - !lockedWorkspaceCwd && - workspaceRemovalEnabled && - !ws.primary && - ws.removable === true; - if (!ws.trusted && !canRemove) return null; - const wsCwd = ws.primary ? undefined : ws.cwd; - return ( -
- {ws.trusted && ( - <> - {canOrganizeWorkspace(ws.cwd) && ( - - )} + const canRemove = + !lockedWorkspaceCwd && + workspaceRemovalEnabled && + !ws.primary && + ws.removable === true; + if (!ws.trusted && !canRemove) return null; + const wsCwd = ws.primary ? undefined : ws.cwd; + return ( +
+ {ws.trusted && ( + <> + {canOrganizeWorkspace(ws.cwd) && ( + )} + + + )} + {canRemove && ( + + + - - )} - {canRemove && ( - - - - - + + + requestWorkspaceRemoval(ws) + } > - - requestWorkspaceRemoval(ws) - } - > - - {t('sidebar.removeWorkspace')} - - - - )} -
- ); - }} - /> - {ws.primary && - (projectExpanded || searchQuery.trim()) ? ( -
- {body} -
- ) : null} - - ))} -
+ + {t('sidebar.removeWorkspace')} + + + + )} +
+ ); + }} + /> + {ws.primary && + (projectExpanded || searchQuery.trim()) ? ( +
+ {body} +
+ ) : null} +
+ ))}
- )} +
)} {archivedSection} -
+
{footer !== false && ( diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx index a8a929e7e7b..31a48153249 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx @@ -23,6 +23,7 @@ const { unarchiveSessionsData, deleteSessionsData, updateSessionOrganization, + updateSessionMetadata, exportSession, exportArchivedSession, sessionActions, @@ -61,6 +62,7 @@ const { errors: [], }); const updateSessionOrganization = vi.fn().mockResolvedValue({}); + const updateSessionMetadata = vi.fn().mockResolvedValue({}); const exportSession = vi.fn(); const active = makeSessions(); const archived = makeSessions(); @@ -134,6 +136,7 @@ const { archiveSessionsData, unarchiveSessionsData, exportArchivedSession, + updateSessionMetadata, })), }, refreshCapabilities: vi.fn(), @@ -152,6 +155,7 @@ const { unarchiveSessionsData, deleteSessionsData, updateSessionOrganization, + updateSessionMetadata, exportSession, exportArchivedSession, sessionActions, @@ -339,6 +343,7 @@ const capabilities = { 'workspace_runtime_removal', 'session_archive', 'workspace_qualified_rest_core', + 'workspace_session_metadata', 'session_source_metadata', ], workspaces: [ @@ -377,6 +382,7 @@ function renderSidebar( onOpenGoals?: () => void; onOpenAddWorkspace?: () => void; onNewSession?: (workspaceCwd?: string) => boolean; + onLoadSession?: (sessionId: string, workspaceCwd?: string) => void; workspaces?: DaemonWorkspaceCapability[]; lockedWorkspaceCwd?: string; lockedWorkspace?: { @@ -415,7 +421,7 @@ function renderSidebar( onOpenSessions={() => {}} onOpenSplitView={() => {}} onNewSession={overrides.onNewSession ?? (() => false)} - onLoadSession={() => {}} + onLoadSession={overrides.onLoadSession ?? (() => {})} onError={overrides.onError ?? (() => {})} selectedWorkspaceCwd={overrides.selectedWorkspaceCwd} onSelectWorkspace={overrides.onSelectWorkspace} @@ -458,18 +464,6 @@ function setInputValue(input: HTMLInputElement, value: string): void { input.dispatchEvent(new Event('input', { bubbles: true })); } -async function expandWorkspace(name: string): Promise { - const button = Array.from( - container.querySelectorAll('button'), - ).find((candidate) => candidate.textContent?.includes(name)); - expect(button).toBeDefined(); - await act(async () => { - click(button!); - await Promise.resolve(); - await Promise.resolve(); - }); -} - async function ensureWorkspaceExpanded(name: string): Promise { const button = Array.from( container.querySelectorAll('button'), @@ -481,9 +475,16 @@ async function ensureWorkspaceExpanded(name: string): Promise { await Promise.resolve(); await Promise.resolve(); }); + } else { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); } } +const expandWorkspace = ensureWorkspaceExpanded; + function archiveButtonFor(label: string): HTMLButtonElement | undefined { return Array.from( container.querySelectorAll( @@ -663,13 +664,9 @@ function useWorkspaceSessionCatalog( options?: { archiveState?: string; group?: string }, ) => Promise, ): { - workspaceChannelTypes: ReturnType; - workspaceChannels: ReturnType; + channelCatalogCwds: string[]; } { - const workspaceChannelTypes = vi.fn().mockResolvedValue([]); - const workspaceChannels = vi - .fn() - .mockResolvedValue({ revision: '0', instances: {} }); + const channelCatalogCwds: string[] = []; workspace.client.workspaceByCwd.mockImplementation((cwd: string) => ({ listWorkspaceSessions: (options?: { archiveState?: string; @@ -679,16 +676,23 @@ function useWorkspaceSessionCatalog( return resolve(cwd, options); }, listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), - workspaceChannelTypes, - workspaceChannels, + workspaceChannelTypes: vi.fn().mockImplementation(() => { + channelCatalogCwds.push(cwd); + return Promise.resolve([]); + }), + workspaceChannels: vi.fn().mockImplementation(() => { + channelCatalogCwds.push(cwd); + return Promise.resolve({ revision: '0', instances: {} }); + }), archiveSessionsData, unarchiveSessionsData, deleteSessionsData, updateSessionOrganization, + updateSessionMetadata, exportSession, exportArchivedSession, })); - return { workspaceChannelTypes, workspaceChannels }; + return { channelCatalogCwds }; } function openRemoval(cwd: string): void { @@ -746,6 +750,8 @@ beforeEach(() => { }); updateSessionOrganization.mockReset(); updateSessionOrganization.mockResolvedValue({}); + updateSessionMetadata.mockReset(); + updateSessionMetadata.mockResolvedValue({}); exportSession.mockReset(); sessionActions.renameSession.mockReset(); sessionActions.renameSession.mockResolvedValue(undefined); @@ -761,6 +767,7 @@ beforeEach(() => { unarchiveSessionsData, deleteSessionsData, updateSessionOrganization, + updateSessionMetadata, exportSession, exportArchivedSession, })); @@ -1054,7 +1061,7 @@ describe('WebShellSidebar workspace removal', () => { expect(primaryArchive).not.toHaveBeenCalled(); }); - it('allows only the current locked-secondary session to rename', async () => { + it('shows rename for current and non-current locked-secondary sessions', async () => { connection.sessionId = 'locked-current'; connection.workspaceCwd = '/tmp/other'; connection.capabilities = { @@ -1102,33 +1109,9 @@ describe('WebShellSidebar workspace removal', () => { expect(inlineSessionAction('Locked current', 'Archive')?.disabled).toBe( true, ); - expect(inlineSessionAction('Locked other', 'Rename')?.disabled).toBe(true); - - const currentRow = Array.from( - container.querySelectorAll('[role="button"]'), - ).find((row) => row.textContent?.includes('Locked current')); - await act(async () => { - currentRow?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); - await Promise.resolve(); - }); - const input = container.querySelector('input'); - expect(input).not.toBeNull(); - await act(async () => { - setInputValue(input!, 'Renamed locked current'); - input! - .closest('form') - ?.dispatchEvent( - new Event('submit', { bubbles: true, cancelable: true }), - ); - await sessionActions.renameSession.mock.results.at(-1)?.value; - }); - expect(sessionActions.renameSession).toHaveBeenCalledWith( - 'Renamed locked current', - ); - expect(renameSessionCatalog).toHaveBeenCalledWith( - '/tmp/other', - 'locked-current', - 'Renamed locked current', + expect(inlineSessionAction('Locked other', 'Rename')?.disabled).toBe(false); + expect(inlineSessionAction('Locked current', 'Rename')?.disabled).toBe( + false, ); }); @@ -1616,12 +1599,12 @@ describe('WebShellSidebar workspace removal', () => { expect(normalItems).toEqual( expect.arrayContaining([ 'Archive', - 'Details', 'Group', 'Export conversation record', 'Delete', ]), ); + expect(normalItems).not.toContain('Details'); expect(inlineSessionAction('Configured pinned', 'Unpin')).toBeDefined(); expect(inlineSessionAction('Configured pinned', 'Archive')).toBeUndefined(); @@ -1634,7 +1617,6 @@ describe('WebShellSidebar workspace removal', () => { const archivedItems = await openSessionMenuItems('Configured archived'); expect(archivedItems).toEqual( expect.arrayContaining([ - 'Details', 'Export conversation record', 'Restore', 'Delete', @@ -1642,6 +1624,7 @@ describe('WebShellSidebar workspace removal', () => { ); expect(archivedItems).not.toContain('Pin'); expect(archivedItems).not.toContain('Group'); + expect(archivedItems).not.toContain('Details'); }); it('renders locked normal, pinned, and archived rows action-free when no items are configured', async () => { @@ -2023,19 +2006,7 @@ describe('WebShellSidebar workspace removal', () => { 'primary-archived-controlled', ); - const staleTrigger = sessionAction('Stale archived controlled'); - expect(staleTrigger).toBeDefined(); - await act(async () => { - click(staleTrigger!); - await Promise.resolve(); - }); - const staleMenuItems = Array.from( - document.body.querySelectorAll('[role="menuitem"]'), - ).map((item) => item.textContent); - expect(staleMenuItems).toContain('Details'); - expect(staleMenuItems).not.toContain('Export'); - expect(staleMenuItems).not.toContain('Restore'); - expect(staleMenuItems).not.toContain('Delete'); + expect(sessionAction('Stale archived controlled')).toBeUndefined(); expect(deleteSessionsData).not.toHaveBeenCalled(); expect(exportArchivedSession).not.toHaveBeenCalled(); }); @@ -2124,7 +2095,7 @@ describe('WebShellSidebar workspace removal', () => { }); await expandWorkspace('project'); - expect(inlineSessionAction('Legacy primary', 'Rename')).toBeDefined(); + expect(inlineSessionAction('Legacy primary', 'Rename')).toBeUndefined(); expect(inlineSessionAction('Legacy primary', 'Pin')).toBeDefined(); expect(archiveButtonFor('Legacy primary')?.disabled).toBe(false); expect( @@ -2134,6 +2105,11 @@ describe('WebShellSidebar workspace removal', () => { false, ); expect(sessionAction('Legacy primary')).toBeDefined(); + expect( + inlineSessionAction('Legacy primary', 'Pin') + ?.closest('[class*="sessionMetaSlot"]') + ?.style.getPropertyValue('--session-actions-width'), + ).toBe('130px'); }); it('fails closed for an explicit primary cwd that disappears from the catalog', async () => { @@ -2325,6 +2301,264 @@ describe('WebShellSidebar workspace removal', () => { expect(sessionActions.renameSession).toHaveBeenCalledTimes(2); }); + it('renames a non-current session through its workspace route', async () => { + const onLoadSession = vi.fn(); + connection.sessionId = 'current-session'; + active.sessions.push( + { + sessionId: 'current-session', + workspaceCwd: '/tmp/project', + displayName: 'Current session', + }, + { + sessionId: 'other-session', + workspaceCwd: '/tmp/project', + displayName: 'Other session', + }, + ); + vi.spyOn(HTMLInputElement.prototype, 'focus').mockImplementation(() => {}); + + renderSidebar({ + sessionActions: { items: ['rename'], inlineItems: ['rename'] }, + onLoadSession, + }); + await expandWorkspace('project'); + + const rename = inlineSessionAction('Other session', 'Rename'); + expect(rename?.disabled).toBe(false); + await act(async () => { + click(rename!); + await Promise.resolve(); + }); + const input = container.querySelector('input'); + expect(input).not.toBeNull(); + await act(async () => { + setInputValue(input!, 'Renamed other session'); + input!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + input! + .closest('form') + ?.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ); + await updateSessionMetadata.mock.results.at(-1)?.value; + }); + + expect(workspace.client.workspaceByCwd).toHaveBeenCalledWith( + '/tmp/project', + ); + expect(updateSessionMetadata).toHaveBeenCalledWith('other-session', { + displayName: 'Renamed other session', + }); + expect(sessionActions.renameSession).not.toHaveBeenCalled(); + expect(onLoadSession).not.toHaveBeenCalled(); + }); + + it('keeps the next rename editor when an earlier rename settles late', async () => { + connection.sessionId = 'current-session'; + active.sessions.push( + { + sessionId: 'current-session', + workspaceCwd: '/tmp/project', + displayName: 'Current session', + }, + { + sessionId: 'first-session', + workspaceCwd: '/tmp/project', + displayName: 'First session', + }, + { + sessionId: 'second-session', + workspaceCwd: '/tmp/project', + displayName: 'Second session', + }, + ); + let resolveFirstRename!: (value: unknown) => void; + updateSessionMetadata + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstRename = resolve; + }), + ) + .mockResolvedValue({}); + + renderSidebar({ + sessionActions: { items: ['rename'], inlineItems: ['rename'] }, + }); + await expandWorkspace('project'); + + await act(async () => { + click(inlineSessionAction('First session', 'Rename')!); + await Promise.resolve(); + }); + const firstInput = container.querySelector('input'); + expect(firstInput).not.toBeNull(); + await act(async () => { + setInputValue(firstInput!, 'First renamed'); + firstInput! + .closest('form') + ?.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ); + await Promise.resolve(); + }); + expect(updateSessionMetadata).toHaveBeenCalledTimes(1); + + await act(async () => { + click(inlineSessionAction('Second session', 'Rename')!); + await Promise.resolve(); + }); + const secondInput = container.querySelector('input'); + expect(secondInput).not.toBeNull(); + await act(async () => { + setInputValue(secondInput!, 'Second renamed'); + await Promise.resolve(); + }); + + await act(async () => { + resolveFirstRename({ displayName: 'First renamed' }); + await updateSessionMetadata.mock.results[0]?.value; + await Promise.resolve(); + }); + + const survivor = container.querySelector('input'); + expect(survivor).not.toBeNull(); + expect(survivor!.value).toBe('Second renamed'); + }); + + it('does not reopen a rename editor while that session is saving', async () => { + active.sessions.push({ + sessionId: 'other-session', + workspaceCwd: '/tmp/project', + displayName: 'Other session', + }); + let resolveRename!: (value: unknown) => void; + updateSessionMetadata.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRename = resolve; + }), + ); + + renderSidebar({ + sessionActions: { items: ['rename'], inlineItems: ['rename'] }, + }); + await expandWorkspace('project'); + await act(async () => { + click(inlineSessionAction('Other session', 'Rename')!); + await Promise.resolve(); + }); + const input = container.querySelector('input'); + expect(input).not.toBeNull(); + act(() => { + input! + .closest('form') + ?.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ); + input!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + }); + + act(() => click(inlineSessionAction('Other session', 'Rename')!)); + expect(container.querySelector('input')).toBeNull(); + + await act(async () => { + resolveRename({ displayName: 'Other session' }); + await updateSessionMetadata.mock.results[0]?.value; + await Promise.resolve(); + }); + act(() => click(inlineSessionAction('Other session', 'Rename')!)); + expect(container.querySelector('input')).not.toBeNull(); + }); + + it('keeps a persisted workspace collapse when sections remount', async () => { + connection.workspaceCwd = '/tmp/other'; + connection.sessionId = 'secondary-session'; + + renderSidebar(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // The current session lives in the secondary workspace, so it was + // auto-expanded once. + const secondaryHeader = () => + Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('other')); + expect(secondaryHeader()?.getAttribute('aria-expanded')).toBe('true'); + + // The user collapses it; the choice is persisted. + await act(async () => { + click(secondaryHeader()!); + await Promise.resolve(); + }); + expect(secondaryHeader()?.getAttribute('aria-expanded')).toBe('false'); + expect( + window.localStorage.getItem( + 'qwen.web-shell.sidebar.workspace-expanded:secondary', + ), + ).toBe('false'); + + // Toggle the Projects header off/on: every workspace section remounts, + // replaying the stale one-shot auto-expand. + const projectsToggle = () => + Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find( + (button) => + button.textContent?.includes('Project') && + !button.textContent?.includes('other'), + ); + expect(projectsToggle()).toBeDefined(); + await act(async () => { + click(projectsToggle()!); + await Promise.resolve(); + }); + await act(async () => { + click(projectsToggle()!); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(secondaryHeader()?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('hides non-current rename when workspace metadata is unsupported', async () => { + connection.sessionId = 'current-session'; + connection.capabilities = { + ...capabilities, + features: capabilities.features.filter( + (feature) => feature !== 'workspace_session_metadata', + ), + }; + active.sessions.push( + { + sessionId: 'current-session', + workspaceCwd: '/tmp/project', + displayName: 'Current session', + }, + { + sessionId: 'other-session', + workspaceCwd: '/tmp/project', + displayName: 'Other session', + }, + ); + + renderSidebar({ + sessionActions: { items: ['rename'], inlineItems: ['rename'] }, + }); + await expandWorkspace('project'); + + expect(inlineSessionAction('Current session', 'Rename')).toBeDefined(); + expect(inlineSessionAction('Other session', 'Rename')).toBeUndefined(); + }); + it('honors a missing rename item for double-click editing', async () => { connection.sessionId = 'current-primary'; active.sessions.push({ @@ -2347,7 +2581,7 @@ describe('WebShellSidebar workspace removal', () => { expect(sessionActions.renameSession).not.toHaveBeenCalled(); }); - it('allows pin on an unlocked secondary workspace but keeps destructive actions restricted', async () => { + it('shows trusted secondary workspace actions', async () => { connection.capabilities = { ...capabilities, features: [...capabilities.features, 'session_organization'], @@ -2373,6 +2607,8 @@ describe('WebShellSidebar workspace removal', () => { expect(inlineSessionAction('Pinned secondary', 'Unpin')).toBeDefined(); expect(inlineSessionAction('Pinned secondary', 'Delete')).toBeUndefined(); expect(inlineSessionAction('Pinned secondary', 'Rename')).toBeUndefined(); + const items = await openSessionMenuItems('Pinned secondary'); + expect(items).toEqual(expect.arrayContaining(['Rename', 'Delete'])); renderSidebar({ lockedWorkspaceCwd: '/tmp/other' }); await act(async () => { @@ -2383,7 +2619,7 @@ describe('WebShellSidebar workspace removal', () => { expect(sessionAction('Pinned secondary')).toBeDefined(); }); - it('allows organization but keeps destructive actions conservative for unlocked secondary sessions', async () => { + it('allows trusted unlocked secondary session actions', async () => { connection.capabilities = { ...capabilities, features: [ @@ -2462,15 +2698,18 @@ describe('WebShellSidebar workspace removal', () => { expect(archiveButtonFor('Unlocked normal')?.disabled).toBe(false); expect(inlineSessionAction('Unlocked normal', 'Pin')).toBeDefined(); expect(inlineSessionAction('Unlocked normal', 'Delete')).toBeUndefined(); + const activeItems = await openSessionMenuItems('Unlocked normal'); + expect(activeItems).toEqual(expect.arrayContaining(['Rename', 'Delete'])); expect(archiveButtonFor('Unlocked pinned')?.disabled).toBe(false); expect(inlineSessionAction('Unlocked pinned', 'Unpin')).toBeDefined(); const archivedItems = await openSessionMenuItems('Unlocked archived'); expect(archivedItems).toEqual([ - 'Details', + 'Rename', 'Export conversation record', 'Restore', + 'Delete', ]); expect(deleteSessionsData).not.toHaveBeenCalled(); expect(updateSessionOrganization).not.toHaveBeenCalled(); @@ -2575,7 +2814,7 @@ describe('WebShellSidebar workspace removal', () => { }); expect(render).toHaveBeenLastCalledWith( expect.objectContaining({ id: 'secondary', cwd: '/tmp/other' }), - { expanded: false }, + { expanded: true }, ); expect( container.querySelector('[data-testid="custom-workspace"]')?.textContent, @@ -2598,7 +2837,7 @@ describe('WebShellSidebar workspace removal', () => { }); expect(render).toHaveBeenLastCalledWith( expect.objectContaining({ id: 'secondary', cwd: '/tmp/other' }), - { expanded: true }, + { expanded: false }, ); }); @@ -3010,8 +3249,7 @@ describe('WebShellSidebar non-primary archive', () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(container.textContent).toContain('Untrusted primary active'); - // Active read-only rows have no action menu; archived rows retain the - // separately configured Details-only menu below. + // Active read-only rows have no action menu. expect(sessionAction('Untrusted primary active')).toBeUndefined(); expect( inlineSessionAction('Untrusted primary active', 'Pin'), @@ -4048,6 +4286,64 @@ describe('WebShellSidebar session source switch', () => { }); describe('WebShellSidebar session list notices', () => { + it('limits the flat session preview to five until Show all is selected', async () => { + active.sessions = Array.from({ length: 6 }, (_, index) => ({ + sessionId: `session-${index + 1}`, + displayName: `Preview session ${index + 1}`, + workspaceCwd: '/tmp/project', + })); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + expect(container.textContent).toContain('Preview session 5'); + expect(container.textContent).not.toContain('Preview session 6'); + + const showAll = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Show all'); + expect(showAll).toBeDefined(); + act(() => click(showAll!)); + expect(container.textContent).toContain('Preview session 6'); + }); + + it('keeps an edited session visible when the preview order changes', async () => { + active.sessions = Array.from({ length: 6 }, (_, index) => ({ + sessionId: `session-${index + 1}`, + displayName: `Preview session ${index + 1}`, + workspaceCwd: '/tmp/project', + })); + + renderSidebar({ + sessionActions: { items: ['rename'], inlineItems: ['rename'] }, + }); + await ensureWorkspaceExpanded('project'); + await act(async () => { + click(inlineSessionAction('Preview session 5', 'Rename')!); + await Promise.resolve(); + }); + const input = container.querySelector('input'); + expect(input).not.toBeNull(); + await act(async () => { + setInputValue(input!, 'Renaming five'); + active.sessions = [ + { + sessionId: 'session-fresh', + displayName: 'A fresh session', + workspaceCwd: '/tmp/project', + }, + ...active.sessions, + ]; + renderSidebar({ + sessionActions: { items: ['rename'], inlineItems: ['rename'] }, + }); + await Promise.resolve(); + }); + + expect(container.querySelector('input')?.value).toBe( + 'Renaming five', + ); + }); + it('keeps a settled filtered-empty view while a refresh is in flight', async () => { active.sessions = [ { @@ -4067,7 +4363,7 @@ describe('WebShellSidebar session list notices', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('No matching sessions.'); + expect(container.textContent).toContain('No sessions.'); expect(container.textContent).not.toContain('Loading sessions...'); }); @@ -4090,7 +4386,7 @@ describe('WebShellSidebar session list notices', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('No matching sessions.'); + expect(container.textContent).toContain('No sessions.'); expect(container.textContent).not.toContain('Failed to load sessions'); }); @@ -4115,7 +4411,7 @@ describe('WebShellSidebar session list notices', () => { renderSidebar(); await ensureWorkspaceExpanded('project'); - expect(container.textContent).toContain('No matching sessions.'); + expect(container.textContent).toContain('No sessions.'); expect(container.textContent).not.toContain('Loading sessions...'); }); @@ -4126,7 +4422,7 @@ describe('WebShellSidebar session list notices', () => { renderSidebar(); await ensureWorkspaceExpanded('project'); - expect(container.textContent).toContain('No matching sessions.'); + expect(container.textContent).toContain('No sessions.'); expect(container.textContent).not.toContain('Failed to load sessions'); }); @@ -4209,13 +4505,73 @@ describe('WebShellSidebar Live group', () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - expect(channelCatalog.workspaceChannelTypes).not.toHaveBeenCalled(); - expect(channelCatalog.workspaceChannels).not.toHaveBeenCalled(); + // Sections start expanded, so non-live workspaces legitimately load + // their own channel catalog; the live section must never join in. + expect(channelCatalog.channelCatalogCwds).not.toContain(liveWorkspace.cwd); expect(container.textContent).not.toContain('Other channels'); expect(container.textContent).toContain('Voice check'); }); }); +describe('WebShellSidebar pinned live session rows', () => { + async function settle(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + } + + it('renders a pinned session that is live in a trusted workspace once', async () => { + connection.capabilities = { + ...capabilities, + features: [...capabilities.features, 'session_organization'], + }; + workspace.capabilities = connection.capabilities; + const liveWorkspace: DaemonWorkspaceCapability = { + id: 'live', + cwd: '/tmp/live', + displayName: 'Conversations', + primary: false, + trusted: true, + kind: 'live', + }; + useWorkspaceSessionCatalog(async (cwd, options) => { + if (cwd !== liveWorkspace.cwd) return []; + const session = { + sessionId: 'live-pinned', + workspaceCwd: cwd, + displayName: 'Live pinned', + isPinned: true, + }; + if (options?.group === 'pinned') return [session]; + if (options?.archiveState === 'active') return [session]; + return []; + }); + + renderSidebar({ + workspaces: [...capabilities.workspaces, liveWorkspace], + sessionActions: { items: ['rename'], inlineItems: ['rename'] }, + }); + await settle(); + await expandWorkspace('Live'); + + const rows = Array.from( + container.querySelectorAll('[class*="sessionRow"]'), + ).filter((row) => row.textContent?.includes('Live pinned')); + expect(rows).toHaveLength(1); + + const rename = inlineSessionAction('Live pinned', 'Rename'); + expect(rename?.disabled).toBe(false); + await act(async () => { + click(rename!); + await Promise.resolve(); + }); + expect( + container.querySelectorAll('form[class*="renameForm"]'), + ).toHaveLength(1); + }); +}); + describe('WebShellSidebar archived session export', () => { const exportResult = { content: '

exported

', @@ -4272,22 +4628,12 @@ describe('WebShellSidebar archived session export', () => { renderSidebar(); await expandArchived(); - const trigger = sessionAction('Archived untrusted'); - expect(trigger).toBeDefined(); - await act(async () => { - click(trigger!); - await Promise.resolve(); - }); - - const archivedItems = Array.from( - document.body.querySelectorAll('[role="menuitem"]'), - ).map((item) => item.textContent); - expect(archivedItems).toEqual(['Details']); + expect(sessionAction('Archived untrusted')).toBeUndefined(); expect(deleteSessionsData).not.toHaveBeenCalled(); expect(unarchiveSessionsData).not.toHaveBeenCalled(); }); - it('keeps an untrusted primary archived row action-free except configured Details', async () => { + it('keeps an untrusted primary archived row action-free', async () => { connection.capabilities = { ...capabilities, features: [ @@ -4325,8 +4671,7 @@ describe('WebShellSidebar archived session export', () => { }); await expandArchived(); - const items = await openSessionMenuItems('Untrusted primary archived'); - expect(items).toEqual(['Details']); + expect(sessionAction('Untrusted primary archived')).toBeUndefined(); expect(archived.unarchiveSession).not.toHaveBeenCalled(); expect(archived.deleteSession).not.toHaveBeenCalled(); expect(exportArchivedSession).not.toHaveBeenCalled(); diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css b/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css index 695dae66275..8e26c9af1c3 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css @@ -8,6 +8,7 @@ align-items: center; width: 100%; border-radius: 6px; + cursor: pointer; transition: background 0.1s; } @@ -33,6 +34,7 @@ .headerDisabled { opacity: 0.5; + cursor: default; } .headerDisabled .header { @@ -76,16 +78,15 @@ border-radius: 4px; } -/* Live git chip in the folder header — icon-only (the chip's `compact` form): a - status dot on the branch icon conveys dirty / conflict / in-progress at a - glance, while the branch name + ahead/behind live in the hover tooltip. - Rendered as a sibling of the header button (buttons can't nest), kept snug - after the folder name. The chip is a fixed 28px box, so the pill grows only to - carry the spare width to the hover actions on the right. */ +/* Live git chip in the folder header. */ .gitPill { + --git-branch-icon-size: 12px; + --git-branch-badge-size: 5px; + --git-branch-badge-offset: -1px; + display: inline-flex; - flex: 1 1 auto; - min-width: 0; + flex: 0 0 24px; + width: 24px; padding: 0; border: none; background: none; @@ -95,9 +96,14 @@ cursor: pointer; } -/* With a chip present, let the pill — not the folder name — own the spare width, - so the icon sits right after the name and the hover actions stay pinned to the - right edge. Without a chip the header keeps growing as before. */ +.gitPill [data-web-shell-git-branch] { + width: 24px; + max-width: 24px; + flex-basis: 24px; + padding: 0 5px; +} + +/* Keep the icon directly after the folder name. */ .headerRow:has(.gitPill) .header { flex-grow: 0; } @@ -113,7 +119,7 @@ .empty { padding: 4px 8px 4px 26px; - font-size: 13px; + font-size: 12px; color: var(--muted-foreground); } @@ -135,14 +141,40 @@ } .sessionName { + position: relative; + min-width: 0; flex: 1; overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } -.sessionTime { - flex: 0 0 auto; - font-size: 10px; - color: var(--muted-foreground); +.sessionName[data-web-shell-title-overflow] { + mask-image: linear-gradient(to right, #000 calc(100% - 10px), transparent); + -webkit-mask-image: linear-gradient( + to right, + #000 calc(100% - 10px), + transparent + ); +} + +.sessionNameInner { + display: inline-block; + min-width: max-content; +} + +.sessionItemReadOnly:hover .sessionNameInner { + animation: sessionTitleScroll var(--session-title-scroll-duration, 0s) 1s + linear forwards; +} + +@keyframes sessionTitleScroll { + to { + transform: translateX(calc(var(--session-title-scroll-distance, 0px) * -1)); + } +} + +@media (prefers-reduced-motion: reduce) { + .sessionItemReadOnly:hover .sessionNameInner { + animation: none; + } } diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx index 75d3915025b..bbc95ed63e8 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx @@ -82,6 +82,9 @@ function makeClient(): DaemonClient { const { I18nProvider } = await import('../../i18n'); const { WorkspaceSection } = await import('./WorkspaceSection'); +const { readWorkspaceExpanded, writeWorkspaceExpanded } = await import( + './workspaceExpansion' +); globalThis.IS_REACT_ACT_ENVIRONMENT = true; if (!globalThis.PointerEvent) { @@ -145,7 +148,6 @@ function renderSection( sourceType={overrides.sourceType} channelGroupingEnabled={overrides.channelGroupingEnabled} ungroupedLabel="Ungrouped" - formatTime={() => ''} renderSession={(session: DaemonSessionSummary): ReactNode => (
{session.displayName}
)} @@ -167,6 +169,7 @@ function gitChip(): HTMLElement | null { } beforeEach(() => { + window.localStorage.clear(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -190,6 +193,7 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()); container.remove(); + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -206,7 +210,7 @@ describe('WorkspaceSection label', () => { expect(container.textContent).not.toContain('project'); }); - it('shows the complete read-only session name in a native tooltip', async () => { + it('shows read-only session details from row hover', async () => { const listWorkspaceSessionsPage = vi.fn().mockResolvedValue({ sessions: [ { @@ -233,7 +237,33 @@ describe('WorkspaceSection label', () => { expect( container.querySelector('[title="A very long session name"]'), - ).not.toBeNull(); + ).toBeNull(); + const row = container.querySelector('[role="note"]'); + if (!row) throw new Error('read-only row was not rendered'); + expect(row.tabIndex).toBe(-1); + vi.useFakeTimers(); + act(() => { + row.dispatchEvent(new Event('pointerover', { bubbles: true })); + vi.advanceTimersByTime(300); + }); + const tooltip = document.querySelector('[role="dialog"]'); + expect(tooltip?.textContent).toContain('A very long session name'); + expect(tooltip?.textContent).toContain('danger'); + expect(tooltip?.querySelector('[title="/tmp/danger"]')).not.toBeNull(); + vi.useRealTimers(); + }); + + it('restores and writes the workspace expansion preference', () => { + writeWorkspaceExpanded(trustedWorkspace.id, false); + renderSection(); + + const toggle = container.querySelector( + 'button[aria-expanded]', + ); + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + act(() => toggle?.click()); + expect(toggle?.getAttribute('aria-expanded')).toBe('true'); + expect(readWorkspaceExpanded(trustedWorkspace.id)).toBe(true); }); it('does not render sessions loaded for the previous source', async () => { @@ -671,6 +701,39 @@ describe('WorkspaceSection label', () => { }); describe('WorkspaceSection session loading', () => { + it('shows five sessions and resets Show all after the workspace closes', async () => { + const sessions = Array.from({ length: 6 }, (_, index) => ({ + sessionId: `session-${index + 1}`, + displayName: `Session ${index + 1}`, + workspaceCwd: trustedWorkspace.cwd, + })); + const listWorkspaceSessionsPage = vi.fn().mockResolvedValue({ sessions }); + const client = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage, + listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + })), + } as unknown as DaemonClient; + + renderSection({ client, expanded: true }); + await flush(); + expect(container.textContent).toContain('Session 5'); + expect(container.textContent).not.toContain('Session 6'); + + const showAll = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Show all', + ); + act(() => showAll?.click()); + expect(container.textContent).toContain('Session 6'); + + renderSection({ client, expanded: false }); + await flush(); + renderSection({ client, expanded: true }); + await flush(); + expect(container.textContent).not.toContain('Session 6'); + }); + it('refreshes the catalog when an expanded workspace loses trust', async () => { const listWorkspaceSessionsPage = vi .fn() diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index cd55e3a27e1..edc5d24edd3 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -19,15 +19,27 @@ import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react'; import { GitBranchIndicator } from '../GitBranchIndicator'; import { BranchPickerPopover } from '../BranchPickerPopover'; import { useI18n } from '../../i18n'; -import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions'; +import { formatRelativeTime } from '../../utils/formatRelativeTime'; +import { + SESSION_LIST_PAGE_SIZE, + SIDEBAR_SESSION_PREVIEW_LIMIT, +} from '../../constants/sessions'; import { readWorkspaceCollapsedGroupIds, writeWorkspaceCollapsedGroupIds, } from './collapsedSessionSections'; +import { + hasWorkspaceExpansionPreference, + readWorkspaceExpanded, + writeWorkspaceExpanded, +} from './workspaceExpansion'; import { workspaceLabel } from '../../utils/workspace'; import { SessionGroupSection } from './SessionGroupSection'; +import { SessionDetailsTooltip } from './SessionDetailsTooltip'; +import { measureSessionTitleScroll } from './sessionTitleScroll'; import { groupSessionsByChannelType } from './channelSessionGroups'; import styles from './WorkspaceSection.module.css'; +import sidebarStyles from './WebShellSidebar.module.css'; import { useSessionCatalogQuery } from '../../session-catalog/session-catalog-hooks'; import type { SessionCatalogQuery } from '../../session-catalog/session-catalog-store'; @@ -76,7 +88,6 @@ interface WorkspaceSectionProps { sourceType?: string; channelGroupingEnabled?: boolean; ungroupedLabel: string; - formatTime: (iso: string) => string; searchQuery?: string; expanded?: boolean; autoExpandKey?: string; @@ -89,6 +100,7 @@ interface WorkspaceSectionProps { * instead of a bespoke, feature-poor row. */ renderSession: (session: DaemonSessionSummary) => ReactNode; + showSessionDetails?: boolean; headerActions?: (visible: boolean) => ReactNode; onRenameGroup?: (group: DaemonSessionGroup, workspaceCwd: string) => void; onDeleteGroup?: (group: DaemonSessionGroup, workspaceCwd: string) => void; @@ -96,6 +108,7 @@ interface WorkspaceSectionProps { deleteGroupLabel?: string; groupActionsDisabled?: boolean; excludePinned?: boolean; + limitSessions?: boolean; /** * Open the working-tree Changes dialog for this workspace. When provided, the * folder header shows a live git chip (branch + dirty/ahead-behind state) that @@ -119,13 +132,13 @@ export function WorkspaceSection({ sourceType, channelGroupingEnabled = false, ungroupedLabel, - formatTime, searchQuery = '', expanded: controlledExpanded, autoExpandKey, onExpandedChange, renderSessions = true, renderSession, + showSessionDetails = true, headerActions, onRenameGroup, onDeleteGroup, @@ -133,6 +146,7 @@ export function WorkspaceSection({ deleteGroupLabel, groupActionsDisabled, excludePinned = false, + limitSessions = true, onOpenGitDiff, onOpenCommit, }: WorkspaceSectionProps) { @@ -141,11 +155,14 @@ export function WorkspaceSection({ catalog: DaemonChannelTypeCatalog; snapshot: DaemonChannelsSnapshot; }>(); - const [internalExpanded, setInternalExpanded] = useState(false); + const [internalExpanded, setInternalExpanded] = useState(() => + readWorkspaceExpanded(workspace.id), + ); const [collapsedGroupIds, setCollapsedGroupIds] = useState>(() => readWorkspaceCollapsedGroupIds(workspace.id), ); const [actionsVisible, setActionsVisible] = useState(false); + const [showAllSessions, setShowAllSessions] = useState(false); const [gitStatus, setGitStatus] = useState(); const [branchPickerOpen, setBranchPickerOpen] = useState(false); const channelCatalogLoadRequestId = useRef(0); @@ -155,11 +172,19 @@ export function WorkspaceSection({ const disabled = workspace.primary && !workspace.trusted; const searchActive = searchQuery.trim().length > 0; - // A workspace always starts collapsed, including the primary workspace. + // Uncontrolled workspace rows restore the user's last choice. useEffect(() => { - if (controlledExpanded === undefined) setInternalExpanded(false); + if (controlledExpanded === undefined) { + setInternalExpanded(readWorkspaceExpanded(workspace.id)); + } }, [controlledExpanded, workspace.id]); + useEffect(() => { + // The five-row preview is scoped per source; reset the one-shot + // show-all when the section collapses or the source changes. + setShowAllSessions(false); + }, [expanded, sourceType]); + // The render site keys this component by workspace id, so an id change // always remounts and the lazy useState initializer re-reads storage. useEffect(() => { @@ -167,10 +192,14 @@ export function WorkspaceSection({ }, [collapsedGroupIds, workspace.id]); useEffect(() => { - if (controlledExpanded === undefined && autoExpandKey) { + if ( + controlledExpanded === undefined && + autoExpandKey && + !hasWorkspaceExpansionPreference(workspace.id) + ) { setInternalExpanded(true); } - }, [autoExpandKey, controlledExpanded]); + }, [autoExpandKey, controlledExpanded, workspace.id]); const sessionsEnabled = renderSessions && !disabled; const sessionsVisible = expanded || Boolean(searchQuery.trim()); @@ -374,6 +403,10 @@ export function WorkspaceSection({ ); }); }, [excludePinned, searchQuery, sessions]); + const directSessions = + searchActive || showAllSessions || !limitSessions + ? visibleSessions + : visibleSessions.slice(0, SIDEBAR_SESSION_PREVIEW_LIMIT); const groupedSessions = useMemo(() => { if (!organizationEnabled || channelGroupingEnabled || groups.length === 0) @@ -407,10 +440,23 @@ export function WorkspaceSection({ [channelCatalog, channelGroupingEnabled, t, visibleSessions], ); + const toggleExpanded = () => { + if (disabled) return; + const nextExpanded = !expanded; + setInternalExpanded(nextExpanded); + if (controlledExpanded === undefined) { + writeWorkspaceExpanded(workspace.id, nextExpanded); + } + onExpandedChange?.(nextExpanded); + }; + return (
{ + if (event.target === event.currentTarget) toggleExpanded(); + }} onMouseEnter={() => setActionsVisible(true)} onMouseLeave={() => setActionsVisible(false)} onFocus={() => setActionsVisible(true)} @@ -425,11 +471,7 @@ export function WorkspaceSection({ type="button" disabled={disabled} aria-expanded={expanded} - onClick={() => { - const nextExpanded = !expanded; - setInternalExpanded(nextExpanded); - onExpandedChange?.(nextExpanded); - }} + onClick={toggleExpanded} > {renderHeader ? ( renderHeader(expanded) @@ -501,6 +543,7 @@ export function WorkspaceSection({ key={group.id} label={group.label} count={group.sessions.length} + limitSessions={limitSessions && !searchActive} expanded={!collapsedGroupIds.has(group.id)} onToggle={() => { setCollapsedGroupIds((current) => { @@ -520,9 +563,10 @@ export function WorkspaceSection({ {groupedSessions.sections.map(({ group, sessions }) => ( { @@ -552,9 +596,11 @@ export function WorkspaceSection({ ))} {groupedSessions.ungrouped.length > 0 && ( { setCollapsedGroupIds((current) => { @@ -572,26 +618,56 @@ export function WorkspaceSection({ )} ) : ( - visibleSessions.map((session) => { - if (!readOnly) return renderSession(session); - const label = getSessionLabel(session); - const time = session.createdAt - ? formatTime(session.createdAt) - : ''; - return ( -
- - {label} - - {time && {time}} -
- ); - }) + <> + {directSessions.map((session) => { + if (!readOnly) return renderSession(session); + const label = getSessionLabel(session); + const stamp = session.updatedAt || session.createdAt; + const row = ( +
+ measureSessionTitleScroll(event.currentTarget) + } + > + + {label} + +
+ ); + return showSessionDetails ? ( + + {row} + + ) : ( + row + ); + })} + {limitSessions && + !searchActive && + !showAllSessions && + visibleSessions.length > SIDEBAR_SESSION_PREVIEW_LIMIT && ( + + )} + )}
)} diff --git a/packages/web-shell/client/components/sidebar/session-action-visibility.test.ts b/packages/web-shell/client/components/sidebar/session-action-visibility.test.ts index 29f2d61f57d..d36242f64f9 100644 --- a/packages/web-shell/client/components/sidebar/session-action-visibility.test.ts +++ b/packages/web-shell/client/components/sidebar/session-action-visibility.test.ts @@ -19,18 +19,15 @@ const DEFAULT_ITEMS: readonly WebShellSidebarSessionActionItem[] = ALL_ITEMS; const DEFAULT_INLINE_ITEMS: readonly WebShellSidebarSessionInlineActionItem[] = ['pin', 'archive']; -/** - * Items that can never appear as inline buttons (no working handler). - * These always fall to the dropdown when present in `items`. - */ +/** Items that can never appear as inline buttons. */ const DROPDOWN_ONLY_ITEMS: readonly WebShellSidebarSessionActionItem[] = [ - 'details', 'group', ]; interface VisibilityResult { inline: Set; dropdown: Set; + hover: Set; showDropdownTrigger: boolean; } @@ -48,12 +45,14 @@ function computeVisibility( const inline = new Set(); const dropdown = new Set(); + const hover = new Set(); for (const item of ALL_ITEMS) { if (!itemSet.has(item)) continue; - if (DROPDOWN_ONLY_ITEMS.includes(item)) { - // details/group can never be inline — always dropdown + if (item === 'details') { + hover.add(item); + } else if (DROPDOWN_ONLY_ITEMS.includes(item)) { dropdown.add(item); } else if (inlineSet.has(item as WebShellSidebarSessionInlineActionItem)) { inline.add(item); @@ -65,26 +64,25 @@ function computeVisibility( return { inline, dropdown, + hover, showDropdownTrigger: dropdown.size > 0, }; } describe('session action visibility matrix', () => { describe('defaults (no consumer config)', () => { - it('pin+archive inline, remaining items in dropdown', () => { - const { inline, dropdown, showDropdownTrigger } = computeVisibility( - DEFAULT_ITEMS, - DEFAULT_INLINE_ITEMS, - ); + it('shows details on hover, pin+archive inline, and mutations in the dropdown', () => { + const { inline, dropdown, hover, showDropdownTrigger } = + computeVisibility(DEFAULT_ITEMS, DEFAULT_INLINE_ITEMS); expect([...inline].sort()).toEqual(['archive', 'pin']); expect([...dropdown].sort()).toEqual([ 'delete', - 'details', 'export', 'group', 'rename', ]); + expect([...hover]).toEqual(['details']); expect(showDropdownTrigger).toBe(true); }); }); @@ -97,7 +95,9 @@ describe('session action visibility matrix', () => { ); expect(inline.size).toBe(0); - expect([...dropdown].sort()).toEqual([...ALL_ITEMS].sort()); + expect([...dropdown].sort()).toEqual( + ALL_ITEMS.filter((item) => item !== 'details').sort(), + ); expect(showDropdownTrigger).toBe(true); }); @@ -120,7 +120,6 @@ describe('session action visibility matrix', () => { expect([...inline].sort()).toEqual(['delete']); expect([...dropdown].sort()).toEqual([ 'archive', - 'details', 'export', 'group', 'pin', @@ -138,14 +137,13 @@ describe('session action visibility matrix', () => { expect([...dropdown].sort()).toEqual(['pin']); }); - it('items: ["details", "group"] — both in dropdown, nothing inline', () => { - const { inline, dropdown, showDropdownTrigger } = computeVisibility( - ['details', 'group'], - DEFAULT_INLINE_ITEMS, - ); + it('items: ["details", "group"] — details stays on hover', () => { + const { inline, dropdown, hover, showDropdownTrigger } = + computeVisibility(['details', 'group'], DEFAULT_INLINE_ITEMS); expect(inline.size).toBe(0); - expect([...dropdown].sort()).toEqual(['details', 'group']); + expect([...dropdown]).toEqual(['group']); + expect([...hover]).toEqual(['details']); expect(showDropdownTrigger).toBe(true); }); diff --git a/packages/web-shell/client/components/sidebar/sessionTitleScroll.ts b/packages/web-shell/client/components/sidebar/sessionTitleScroll.ts new file mode 100644 index 00000000000..a0a6b29dba1 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/sessionTitleScroll.ts @@ -0,0 +1,22 @@ +const TITLE_SCROLL_SPEED_PX_PER_S = 38; + +/** + * Measures the title inside `row` and refreshes the hover-scroll CSS + * variables plus the overflow flag that gates the right-edge fade mask. + * Rows call this on every pointer entry so a renamed or resized label never + * animates with a stale distance. + */ +export function measureSessionTitleScroll(row: HTMLElement): void { + const title = row.querySelector( + '[data-web-shell-session-title]', + ); + if (!title) return; + const label = title.firstElementChild; + const distance = Math.max(0, (label?.scrollWidth ?? 0) - title.clientWidth); + title.style.setProperty('--session-title-scroll-distance', `${distance}px`); + title.style.setProperty( + '--session-title-scroll-duration', + `${distance / TITLE_SCROLL_SPEED_PX_PER_S}s`, + ); + title.toggleAttribute('data-web-shell-title-overflow', distance > 0); +} diff --git a/packages/web-shell/client/components/sidebar/workspaceExpansion.test.ts b/packages/web-shell/client/components/sidebar/workspaceExpansion.test.ts new file mode 100644 index 00000000000..dd6a6c3da71 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/workspaceExpansion.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + hasWorkspaceExpansionPreference, + migrateWorkspaceExpansionPreference, + readWorkspaceExpanded, + writeWorkspaceExpanded, +} from './workspaceExpansion'; + +describe('workspace expansion persistence', () => { + beforeEach(() => window.localStorage.clear()); + + it('defaults to expanded and restores the user choice', () => { + expect(readWorkspaceExpanded('workspace')).toBe(true); + expect(hasWorkspaceExpansionPreference('workspace')).toBe(false); + + writeWorkspaceExpanded('workspace', false); + + expect(readWorkspaceExpanded('workspace')).toBe(false); + expect(hasWorkspaceExpansionPreference('workspace')).toBe(true); + }); + + it('migrates a preference written under a provisional id', () => { + writeWorkspaceExpanded('primary:/tmp/connection', false); + + migrateWorkspaceExpansionPreference( + 'primary:/tmp/connection', + 'primary:/tmp/primary', + ); + + expect(readWorkspaceExpanded('primary:/tmp/primary')).toBe(false); + expect(hasWorkspaceExpansionPreference('primary:/tmp/connection')).toBe( + false, + ); + }); + + it('keeps an existing preference when ids converge', () => { + writeWorkspaceExpanded('primary:/tmp/connection', false); + writeWorkspaceExpanded('primary:/tmp/primary', true); + + migrateWorkspaceExpansionPreference( + 'primary:/tmp/connection', + 'primary:/tmp/primary', + ); + + expect(readWorkspaceExpanded('primary:/tmp/primary')).toBe(true); + expect(hasWorkspaceExpansionPreference('primary:/tmp/connection')).toBe( + false, + ); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/workspaceExpansion.ts b/packages/web-shell/client/components/sidebar/workspaceExpansion.ts new file mode 100644 index 00000000000..a8dd141d7d9 --- /dev/null +++ b/packages/web-shell/client/components/sidebar/workspaceExpansion.ts @@ -0,0 +1,54 @@ +const STORAGE_PREFIX = 'qwen.web-shell.sidebar.workspace-expanded:'; + +export function hasWorkspaceExpansionPreference(id: string): boolean { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(`${STORAGE_PREFIX}${id}`) !== null; + } catch { + return false; + } +} + +export function readWorkspaceExpanded(id: string): boolean { + if (typeof window === 'undefined') return true; + try { + return window.localStorage.getItem(`${STORAGE_PREFIX}${id}`) !== 'false'; + } catch { + return true; + } +} + +export function writeWorkspaceExpanded(id: string, expanded: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(`${STORAGE_PREFIX}${id}`, String(expanded)); + } catch { + // localStorage can be unavailable in private or embedded contexts. + } +} + +// The primary expansion id is keyed by the primary workspace cwd, which is +// only provisional until the registered workspace list lands. Move a +// preference written under the provisional id so the choice survives. +export function migrateWorkspaceExpansionPreference( + previousId: string, + nextId: string, +): void { + if (typeof window === 'undefined' || previousId === nextId) return; + try { + const previousKey = `${STORAGE_PREFIX}${previousId}`; + const nextKey = `${STORAGE_PREFIX}${nextId}`; + const stored = window.localStorage.getItem(previousKey); + if (stored === null) return; + if (window.localStorage.getItem(nextKey) !== null) { + // The registered preference already won; drop the superseded + // provisional entry so it stops reading as a live preference. + window.localStorage.removeItem(previousKey); + return; + } + window.localStorage.setItem(nextKey, stored); + window.localStorage.removeItem(previousKey); + } catch { + // localStorage can be unavailable in private or embedded contexts. + } +} diff --git a/packages/web-shell/client/components/ui/popover.tsx b/packages/web-shell/client/components/ui/popover.tsx index 75f108108b9..66faa345c5e 100644 --- a/packages/web-shell/client/components/ui/popover.tsx +++ b/packages/web-shell/client/components/ui/popover.tsx @@ -23,11 +23,24 @@ const PopoverTrigger = React.forwardRef< ); }); +type PopoverContentProps = React.ComponentProps< + typeof PopoverPrimitive.Content +> & { + showArrow?: boolean; +}; + const PopoverContent = React.forwardRef< React.ComponentRef, - React.ComponentProps + PopoverContentProps >(function PopoverContent( - { className, align = 'center', sideOffset = 4, ...props }, + { + className, + align = 'center', + sideOffset = 4, + showArrow = false, + children, + ...props + }, ref, ) { const portalRoot = useWebShellPortalRoot(); @@ -39,11 +52,30 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - 'z-[var(--web-shell-popover-z-index,1000)] flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', + 'z-[var(--web-shell-popover-z-index,1000)] flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 [--floating-arrow-offset:-1px] outline-hidden duration-100 data-[side=top]:[--floating-arrow-offset:0px] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', className, )} {...props} - /> + > + {children} + {showArrow && ( + + + + )} + ); }); diff --git a/packages/web-shell/client/components/ui/tooltip.tsx b/packages/web-shell/client/components/ui/tooltip.tsx index f896a63d337..643b8cd60ac 100644 --- a/packages/web-shell/client/components/ui/tooltip.tsx +++ b/packages/web-shell/client/components/ui/tooltip.tsx @@ -29,11 +29,6 @@ function TooltipTrigger({ return ; } -// With TooltipPrimitive.Arrow present, Radix's offset middleware computes -// `mainAxis: sideOffset + arrowHeight`, so the arrow's 10px box already -// pushes the content out. The previous pseudo-element arrow took no layout -// space and was tuned against sideOffset 8; keeping 8 here would move every -// tooltip ~10px farther from its trigger. function TooltipContent({ className, sideOffset = 0, @@ -47,20 +42,32 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - 'relative z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=instant-open]:animate-in data-[state=instant-open]:fade-in-0 data-[state=instant-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', + 'relative z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground [--floating-arrow-offset:-1px] data-[side=top]:[--floating-arrow-offset:0px] has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=instant-open]:animate-in data-[state=instant-open]:fade-in-0 data-[state=instant-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', className, )} {...props} > {children} - {/* Radix computes the arrow offset from the trigger position, so the - tip keeps pointing at the trigger even after collision avoidance - shifts the content near a viewport edge — a pseudo-element pinned - at the content's center cannot. */} + asChild + width={12} + height={6} + > + + ); diff --git a/packages/web-shell/client/constants/sessions.ts b/packages/web-shell/client/constants/sessions.ts index 0e5bc7d8ec9..50efec588bd 100644 --- a/packages/web-shell/client/constants/sessions.ts +++ b/packages/web-shell/client/constants/sessions.ts @@ -9,6 +9,7 @@ * retention limits cannot drift between the main and split views. */ export const SESSION_LIST_PAGE_SIZE = 1000; +export const SIDEBAR_SESSION_PREVIEW_LIMIT = 5; export const SESSION_ORGANIZATION_FEATURE = 'session_organization'; export const SESSION_TRANSCRIPT_PAGINATION_FEATURE = 'session_transcript_pagination'; diff --git a/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts b/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts index 8653c4437d1..425b8ce4a9c 100644 --- a/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.collapsed-groups-persist.spec.ts @@ -76,17 +76,13 @@ test('keeps long session details inside a constrained WebShell @smoke', async ({ const webShellRoot = page.locator('[data-web-shell-root]'); await page.getByRole('button', { name: 'Toggle menu' }).click(); - const sessionTitle = page.getByTitle(longTitle); + const sessionTitle = webShellRoot.getByText(longTitle, { exact: true }); await expect(sessionTitle).toBeVisible(); - const sessionRow = sessionTitle.locator('..'); - await sessionRow.hover(); - await sessionRow.getByRole('button', { name: 'More actions' }).click(); - await page.getByRole('menuitem', { name: 'Details' }).hover(); - - const details = page.locator('[data-slot="dropdown-menu-sub-content"]'); + await sessionTitle.hover(); + const details = page.getByRole('dialog', { name: longTitle }); const title = details.getByTitle(longTitle); - const copyAction = details.getByRole('menuitem', { + const copyAction = details.getByRole('button', { name: 'Copy session ID', }); await expect(details).toBeVisible(); @@ -103,6 +99,11 @@ test('keeps long session details inside a constrained WebShell @smoke', async ({ { width: 520, height: 320 }, ]) { await page.setViewportSize(size); + // Close the details popover before re-hovering: at constrained sizes it + // can flip to cover its own anchor row and intercept the hover. + await page.mouse.move(0, 0); + await expect(details).toBeHidden(); + await sessionTitle.hover(); await expect(details).toBeVisible(); await expectDetailsInsideRoot(webShellRoot, details); await expect(copyAction).toBeVisible(); @@ -113,16 +114,14 @@ test('keeps long session details inside a constrained WebShell @smoke', async ({ return { clientHeight: element.clientHeight, lineHeight: Number.parseFloat(style.lineHeight), - scrollHeight: element.scrollHeight, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, }; }); expect(titleMetrics.clientHeight).toBeLessThanOrEqual( - titleMetrics.lineHeight * 3 + 1, - ); - expect(titleMetrics.clientHeight).toBeGreaterThanOrEqual( - titleMetrics.lineHeight * 3 - 1, + titleMetrics.lineHeight + 1, ); - expect(titleMetrics.scrollHeight).toBeGreaterThan(titleMetrics.clientHeight); + expect(titleMetrics.scrollWidth).toBeGreaterThan(titleMetrics.clientWidth); }); async function expectDetailsInsideRoot( diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index d7a69ebdb41..b0e5d3d4a7e 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1240,7 +1240,6 @@ const EN: Messages = { 'sidebar.project': 'Project', 'sidebar.pinnedSessions': 'Pinned', 'sidebar.workspaceSelectLabel': 'Workspace', - 'sidebar.details': 'Details', 'sidebar.copySessionId': 'Copy session ID', 'sidebar.copySessionIdFailed': 'Failed to copy session ID', 'sidebar.sessionIdCopied': 'Session ID copied', @@ -1314,13 +1313,12 @@ const EN: Messages = { 'sidebar.themeDark': 'Switch to dark theme', 'sidebar.collapse': 'Collapse', 'sidebar.expand': 'Expand', + 'sidebar.showAllSessions': 'Show all', 'sidebar.collapseProject': 'Collapse project', 'sidebar.expandProject': 'Expand project', 'sidebar.search': 'Search sessions', 'sidebar.searchPlaceholder': 'Search sessions', - 'sidebar.searchEmpty': 'No matching sessions.', 'sidebar.rename': 'Rename', - 'sidebar.renameCurrentOnly': 'Only the current session can be renamed', 'sidebar.export': 'Export conversation record', 'sidebar.exportFailed': 'Failed to export session', 'sidebar.delete': 'Delete', @@ -4183,7 +4181,6 @@ const ZH: Messages = { 'sidebar.project': '项目', 'sidebar.pinnedSessions': '置顶', 'sidebar.workspaceSelectLabel': '工作区', - 'sidebar.details': '详情', 'sidebar.copySessionId': '复制会话 ID', 'sidebar.copySessionIdFailed': '复制会话 ID 失败', 'sidebar.sessionIdCopied': '会话 ID 已复制', @@ -4251,13 +4248,12 @@ const ZH: Messages = { 'sidebar.themeDark': '切换到深色主题', 'sidebar.collapse': '收起', 'sidebar.expand': '展开', + 'sidebar.showAllSessions': '展开显示', 'sidebar.collapseProject': '收起项目', 'sidebar.expandProject': '展开项目', 'sidebar.search': '搜索会话', 'sidebar.searchPlaceholder': '搜索会话', - 'sidebar.searchEmpty': '没有匹配的会话。', 'sidebar.rename': '重命名', - 'sidebar.renameCurrentOnly': '暂仅支持重命名当前会话', 'sidebar.export': '导出对话记录', 'sidebar.exportFailed': '导出会话失败', 'sidebar.delete': '删除',