From 7a528d078a89e6bf44686d645175f066363c3158 Mon Sep 17 00:00:00 2001 From: jinye Date: Sun, 5 Jul 2026 15:52:56 +0800 Subject: [PATCH] feat(daemon): Add session organization (#6305) * feat(daemon): add session organization Co-authored-by: Qwen-Coder * fix(daemon): address session organization review feedback Co-authored-by: Qwen-Coder * test(daemon): cover session organization review cases Co-authored-by: Qwen-Coder * codex: address PR review feedback (#6305) Co-authored-by: Qwen-Coder * codex: address PR review feedback (#6305) Co-authored-by: Qwen-Coder * codex: address PR review feedback (#6305) Co-authored-by: Qwen-Coder * codex: address PR review feedback (#6305) Co-authored-by: Qwen-Coder * codex: address PR review feedback (#6305) Co-authored-by: Qwen-Coder * codex: address PR review feedback (#6305) Co-authored-by: Qwen-Coder * fix(web-shell): Address session organization review feedback Co-authored-by: Qwen-Coder * fix(core): Harden session organization review edge cases Co-authored-by: Qwen-Coder * fix(core): Address session organization review feedback Co-authored-by: Qwen-Coder --------- Co-authored-by: Qwen-Coder --- docs/developers/qwen-serve-protocol.md | 121 +- .../cli/qwen-serve-routes.test.ts | 1 + packages/acp-bridge/src/bridgeTypes.ts | 3 + packages/cli/src/serve/acp-http/dispatch.ts | 181 ++- packages/cli/src/serve/acp-http/index.ts | 1 + .../cli/src/serve/acp-http/transport.test.ts | 192 +++ packages/cli/src/serve/capabilities.ts | 1 + packages/cli/src/serve/routes/session.ts | 231 +++- packages/cli/src/serve/server.test.ts | 500 +++++++ packages/cli/src/serve/server/session-list.ts | 333 ++++- .../src/serve/session-organization-helpers.ts | 16 + packages/core/src/index.ts | 1 + .../session-organization-service.test.ts | 508 ++++++++ .../services/session-organization-service.ts | 627 +++++++++ .../core/src/services/sessionService.test.ts | 31 + packages/core/src/services/sessionService.ts | 42 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 210 ++- .../src/daemon/acpRouteTable.ts | 134 +- packages/sdk-typescript/src/daemon/index.ts | 11 + packages/sdk-typescript/src/daemon/types.ts | 72 ++ packages/sdk-typescript/src/index.ts | 11 + .../test/unit/DaemonClient.test.ts | 149 ++- .../test/unit/acpRouteTable.test.ts | 92 ++ .../messages/EchartsFullDataBlock.test.tsx | 3 +- .../messages/EchartsFullDataBlock.tsx | 3 +- .../sidebar/WebShellSidebar.module.css | 337 ++++- .../sidebar/WebShellSidebar.test.tsx | 542 +++++++- .../components/sidebar/WebShellSidebar.tsx | 1147 ++++++++++++++--- packages/web-shell/client/customization.tsx | 13 +- packages/web-shell/client/i18n.tsx | 57 + .../DaemonWorkspaceProvider.test.tsx | 72 +- .../webui/src/daemon/workspace/actions.ts | 72 +- .../workspace/hooks/useDaemonSessions.ts | 35 +- packages/webui/src/daemon/workspace/types.ts | 32 +- 34 files changed, 5448 insertions(+), 333 deletions(-) create mode 100644 packages/cli/src/serve/session-organization-helpers.ts create mode 100644 packages/core/src/services/session-organization-service.test.ts create mode 100644 packages/core/src/services/session-organization-service.ts diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 769dbfe95b..b8c05452b9 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -154,7 +154,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'workspace_preflight', 'session_context', 'session_context_usage', 'session_supported_commands', 'session_tasks', 'session_stats', 'session_lsp', 'session_status', - 'session_close', 'session_metadata', 'session_archive', 'mcp_guardrails', + 'session_close', 'session_metadata', 'session_organization', + 'session_archive', 'mcp_guardrails', 'workspace_mcp_manage', 'mcp_guardrail_events', 'mcp_server_runtime_mutation', 'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write', @@ -183,6 +184,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `session_close` and `session_metadata` advertise `DELETE /session/:id` and `PATCH /session/:id/metadata`. Older daemons return `404`; pre-flight these tags before exposing close or rename affordances. +`session_organization` advertises custom session groups and pinning. It adds `GET/POST/PATCH/DELETE /workspace/:id/session-groups`, `PATCH /session/:id/organization`, and the opt-in organized list view `GET /workspace/:id/sessions?view=organized`. Older daemons return `404` for the mutation/group routes and ignore the organized view contract, so WebShell/SDK clients must pre-flight this tag before showing grouping or pinning UI. + `session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived. `session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status. @@ -1259,7 +1262,7 @@ Use `/load` when the client has no history rendered (cold reconnect, picker → ### `GET /workspace/:id/sessions` -List persisted sessions whose canonical workspace matches `:id` (URL-encoded absolute cwd). The default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. `archiveState=all` is not supported in v1. +List persisted sessions whose canonical workspace matches `:id` (URL-encoded absolute cwd). The default list is active sessions from `chats/`; pass `archiveState=archived` to list archived sessions from `chats/archive/`. `archiveState=all` is not supported in v1. The default response and numeric `cursor` semantics are unchanged by `session_organization`. ```bash curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions @@ -1268,11 +1271,13 @@ curl http://127.0.0.1:4170/workspace/$(jq -rn --arg c "$PWD" '$c|@uri')/sessions Query parameters: -| Field | Required | Notes | -| -------------- | -------- | ------------------------------------------------------------------------------------------------------- | -| `archiveState` | no | `active` (default) or `archived`. Any other value returns `400 { code: "invalid_archive_state" }`. | -| `cursor` | no | Pagination cursor from the previous response. | -| `size` | no | Page size. Invalid values return `400 { code: "invalid_cursor" }` or the existing page-size validation. | +| Field | Required | Notes | +| -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `archiveState` | no | `active` (default) or `archived`. Any other value returns `400 { code: "invalid_archive_state" }`. | +| `cursor` | no | Pagination cursor from the previous response. | +| `size` | no | Page size. Invalid values return `400 { code: "invalid_cursor" }` or the existing page-size validation. | +| `view` | no | Omit for the legacy recent list. `organized` opts into server-side pinned/group ordering and adds optional organization fields. Any other value returns `400 { code: "invalid_session_view" }`. | +| `group` | no | Only meaningful with `view=organized`. `all` (default), `pinned`, `ungrouped`, or a custom group id. Unknown group ids return `404 { code: "group_not_found" }`. | Response: @@ -1293,8 +1298,79 @@ Response: } ``` +With `view=organized`, the daemon reads `/session-organization.v1.json`, returns pinned sessions first, then activity time descending, and then `sessionId` for stable ties. The organized cursor is opaque base64url JSON and must not be reused with the legacy recent list. `pinned` is a virtual filter, not a group. `groupId: null` means ungrouped. Archived sessions keep their organization metadata, but `archiveState=archived&view=organized` still returns only archived sessions. + +Additional fields may appear on each session when `view=organized`: + +```json +{ + "isPinned": true, + "pinnedAt": "2026-07-04T12:00:00.000Z", + "groupId": "018f..." +} +``` + Active lists include live daemon overlay fields such as `clientCount` and `hasActivePrompt`. Archived lists are storage-only: `isArchived` is `true`, and live overlay fields remain absent or false. Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle. +### `GET /workspace/:id/session-groups` + +List user-defined session groups for a workspace. Pre-flight `caps.features.includes('session_organization')`. + +Response: + +```json +{ + "groups": [ + { + "id": "018f...", + "name": "Frontend", + "color": "blue", + "order": 0, + "createdAt": "2026-07-04T12:00:00.000Z", + "updatedAt": "2026-07-04T12:00:00.000Z" + } + ], + "colorOptions": ["red", "orange", "yellow", "green", "blue", "purple"] +} +``` + +Colors are protocol tokens only; clients localize display names. No default color-named groups are created. + +### `POST /workspace/:id/session-groups` + +Create a custom session group. Strict mutation gate. Pre-flight `caps.features.includes('session_organization')`. + +Request: + +```json +{ "name": "Frontend", "color": "blue" } +``` + +`name` is trimmed, must be 1-64 characters, cannot contain control characters, and is unique within the workspace by case-insensitive trimmed comparison. Duplicate names return `409 { code: "group_name_conflict" }`. `color` must be one of the returned `colorOptions`. + +Response: + +```json +{ + "group": { + "id": "018f...", + "name": "Frontend", + "color": "blue", + "order": 0, + "createdAt": "...", + "updatedAt": "..." + } +} +``` + +### `PATCH /workspace/:id/session-groups/:groupId` + +Update a custom session group. Strict mutation gate. Pre-flight `caps.features.includes('session_organization')`. Body fields are optional: `{ "name"?: string, "color"?: string, "order"?: number }`. Unknown group ids return `404 { code: "group_not_found" }`; duplicate/invalid names and colors use the same errors as create. + +### `DELETE /workspace/:id/session-groups/:groupId` + +Delete a custom session group. Strict mutation gate. Pre-flight `caps.features.includes('session_organization')`. Sessions referencing the group are cleared to `groupId: null`; pinned state is preserved. Response is `{ "deleted": true }` when a group was removed and `{ "deleted": false }` when the id did not exist. + ### `POST /sessions/delete` Hard-delete one or more persisted session JSONL files. The daemon first best-effort closes live sessions, then removes the active or archived JSONL. If both active and archived copies exist for the same id, both are removed. Worktree sidecars on both sides are cleaned; file history, subagent transcripts, and runtime sidecars are intentionally preserved. @@ -1427,7 +1503,7 @@ Idempotent: returns `404` for unknown sessions (same `SessionNotFoundError` shap ### `PATCH /session/:id/metadata` -Update mutable session metadata. Currently supports `displayName` only. Pre-flight `caps.features.session_metadata`. +Update mutable session metadata. Currently supports `displayName` only. Pre-flight `caps.features.session_metadata`. Grouping and pinning are intentionally not part of this route; use `PATCH /session/:id/organization` under `session_organization`. Request: @@ -1447,6 +1523,35 @@ Response: Publishes a `session_metadata_updated` event on the session's SSE stream with `{ sessionId, displayName }`. +### `PATCH /session/:id/organization` + +Update local session organization state. Strict mutation gate. Pre-flight `caps.features.includes('session_organization')`. + +Request: + +```json +{ "isPinned": true, "groupId": "018f..." } +``` + +| Field | Required | Notes | +| ---------- | -------- | ---------------------------------------------------------------------------------------------------- | +| `isPinned` | no | Boolean. `true` sets `pinnedAt` if it was not already pinned; `false` clears `pinnedAt`. | +| `groupId` | no | Custom group id or `null` for ungrouped. Unknown group ids return `404 { code: "group_not_found" }`. | + +Response: + +```json +{ + "sessionId": "", + "groupId": "018f...", + "isPinned": true, + "pinnedAt": "2026-07-04T12:00:00.000Z", + "updatedAt": "2026-07-04T12:00:00.000Z" +} +``` + +This state is stored in the project-level session organization sidecar under the daemon runtime storage directory. It is not transcript content, does not update transcript `mtime`, is not exported with transcripts, and is preserved across archive/unarchive. + ### `POST /session/:id/heartbeat` Bump the daemon's last-seen bookkeeping for this session. Long-lived adapters (TUI/IDE/web) ping this on an interval so future revocation policy (Wave 5 PR 24) can distinguish dead clients from quiet ones. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index bed25c036d..1b21764121 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -277,6 +277,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_close', 'session_archive', 'session_metadata', + 'session_organization', 'session_export', 'mcp_guardrails', 'workspace_mcp_manage', diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 4d23c3a5f8..09a83abeea 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -213,6 +213,9 @@ export interface BridgeSessionSummary { clientCount: number; hasActivePrompt: boolean; isArchived?: boolean; + isPinned?: boolean; + pinnedAt?: string; + groupId?: string | null; } export interface SessionMetadataUpdate { diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index d8302f7fcc..5c0cbdcc84 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -11,6 +11,8 @@ import { BTW_MAX_INPUT_LENGTH, createDebugLogger, SessionService, + SessionOrganizationError, + type SessionGroupColor, BuiltinAgentRegistry, SubagentError, WorkspaceMemoryFileTooLargeError, @@ -87,6 +89,7 @@ import { InvalidCursorError, listWorkspaceSessionsForResponse, } from '../server.js'; +import { createSessionOrganizationService } from '../session-organization-helpers.js'; import { archiveDaemonSessions, assertSessionLoadable, @@ -149,6 +152,11 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}session/context`, `${QWEN_METHOD_NS}session/supported_commands`, `${QWEN_METHOD_NS}session/update_metadata`, + `${QWEN_METHOD_NS}session/update_organization`, + `${QWEN_METHOD_NS}workspace/session_groups/list`, + `${QWEN_METHOD_NS}workspace/session_groups/create`, + `${QWEN_METHOD_NS}workspace/session_groups/update`, + `${QWEN_METHOD_NS}workspace/session_groups/delete`, `${QWEN_METHOD_NS}workspace/mcp`, `${QWEN_METHOD_NS}workspace/skills`, `${QWEN_METHOD_NS}workspace/providers`, @@ -434,6 +442,17 @@ function toRpcError(err: unknown): { if (err instanceof AcpParamError || err instanceof InvalidCursorError) { return { code: RPC.INVALID_PARAMS, message: err.message }; } + if (err instanceof SessionOrganizationError) { + const isServerSide = err.code === 'session_organization_store_unreadable'; + return { + code: isServerSide ? RPC.INTERNAL_ERROR : RPC.INVALID_PARAMS, + message: err.message, + data: { + errorKind: err.code, + ...(err.field ? { field: err.field } : {}), + }, + }; + } if (err instanceof SubagentError) { return { code: RPC.INVALID_PARAMS, message: err.message }; } @@ -641,6 +660,23 @@ export class AcpDispatcher { }; } + private parseBoundWorkspaceParam(params: Record): string { + const rawWorkspace = + typeof params['workspaceCwd'] === 'string' + ? params['workspaceCwd'] + : undefined; + if (rawWorkspace === undefined) { + return this.boundWorkspace; + } + const requestedWorkspace = canonicalizeWorkspace( + parseOptionalWorkspaceCwd({ cwd: rawWorkspace }, this.boundWorkspace), + ); + if (requestedWorkspace !== this.boundWorkspace) { + throw new WorkspaceMismatchError(this.boundWorkspace, requestedWorkspace); + } + return requestedWorkspace; + } + private parseSessionIds(params: Record): string[] { const sessionIds = params['sessionIds']; if ( @@ -1169,29 +1205,25 @@ export class AcpDispatcher { } case 'session/list': { - const rawWorkspace = - typeof params['workspaceCwd'] === 'string' - ? params['workspaceCwd'] - : undefined; - let workspaceCwd = - rawWorkspace === undefined - ? this.boundWorkspace - : parseOptionalWorkspaceCwd( - { cwd: rawWorkspace }, - this.boundWorkspace, - ); - if (rawWorkspace !== undefined) { - const requestedWorkspace = canonicalizeWorkspace(workspaceCwd); - if (requestedWorkspace !== this.boundWorkspace) { - throw new WorkspaceMismatchError( - this.boundWorkspace, - requestedWorkspace, - ); - } - workspaceCwd = requestedWorkspace; - } + const workspaceCwd = this.parseBoundWorkspaceParam(params); const cursor = typeof params['cursor'] === 'string' ? params['cursor'] : undefined; + const rawView = + typeof params['view'] === 'string' ? params['view'] : undefined; + let view: 'organized' | undefined; + if (rawView !== undefined) { + if (rawView !== 'organized') { + throw new AcpParamError('`view` must be "organized"'); + } + view = rawView; + } + const group = + typeof params['group'] === 'string' ? params['group'] : undefined; + if (group !== undefined && view !== 'organized') { + throw new AcpParamError( + '`group` requires `view` to be "organized"', + ); + } const meta = isObject(params['_meta']) ? params['_meta'] : undefined; const metaSize = typeof meta?.['size'] === 'number' @@ -1218,7 +1250,7 @@ export class AcpDispatcher { const result = await listWorkspaceSessionsForResponse( this.bridge, workspaceCwd, - { cursor, size: metaSize, archiveState }, + { cursor, size: metaSize, archiveState, view, group }, ); this.replyConn(conn, id, { sessions: result.sessions.map((s) => ({ @@ -1232,10 +1264,15 @@ export class AcpDispatcher { clientCount: s.clientCount, hasActivePrompt: s.hasActivePrompt, isArchived: s.isArchived === true, + ...(s.isPinned !== undefined ? { isPinned: s.isPinned } : {}), + ...(s.pinnedAt !== undefined ? { pinnedAt: s.pinnedAt } : {}), + ...(s.groupId !== undefined ? { groupId: s.groupId } : {}), })), ...(result.nextCursor != null ? { nextCursor: result.nextCursor } : {}), + ...(result.liveMergeFailed ? { liveMergeFailed: true } : {}), + ...(result.truncated ? { truncated: true } : {}), }); return; } @@ -1878,6 +1915,106 @@ export class AcpDispatcher { return; } + case `${QWEN_METHOD_NS}session/update_organization`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!sessionId) { + throw new AcpParamError('`sessionId` is required'); + } + // Organization is workspace-scoped UI state. It can target persisted or + // archived sessions without a live ACP owner, matching the REST route. + if ('isPinned' in params && typeof params['isPinned'] !== 'boolean') { + throw new AcpParamError('`isPinned` must be a boolean'); + } + if ( + 'groupId' in params && + params['groupId'] !== null && + typeof params['groupId'] !== 'string' + ) { + throw new AcpParamError('`groupId` must be a string or null'); + } + await this.archiveCoordinator.runSharedMany([sessionId], async () => { + const sessionService = new SessionService(this.boundWorkspace); + let exists = + await sessionService.sessionExistsInAnyState(sessionId); + if (!exists) { + try { + const liveSummary = this.bridge.getSessionSummary(sessionId); + exists = liveSummary.workspaceCwd === this.boundWorkspace; + } catch { + exists = false; + } + } + if (!exists) { + throw new AcpParamError(`Session not found: ${sessionId}`); + } + const organization = await createSessionOrganizationService( + this.boundWorkspace, + ).updateSessionOrganization(sessionId, { + ...(typeof params['isPinned'] === 'boolean' + ? { isPinned: params['isPinned'] } + : {}), + ...('groupId' in params + ? { groupId: params['groupId'] as string | null } + : {}), + }); + this.replyConn(conn, id, { sessionId, ...organization }); + }); + return; + } + + case `${QWEN_METHOD_NS}workspace/session_groups/list`: { + const workspaceCwd = this.parseBoundWorkspaceParam(params); + const groups = + await createSessionOrganizationService(workspaceCwd).listGroups(); + this.replyConn(conn, id, groups); + return; + } + + case `${QWEN_METHOD_NS}workspace/session_groups/create`: { + const workspaceCwd = this.parseBoundWorkspaceParam(params); + const group = await createSessionOrganizationService( + workspaceCwd, + ).createGroup({ + name: params['name'] as string, + color: params['color'] as SessionGroupColor, + }); + this.replyConn(conn, id, { group }); + return; + } + + case `${QWEN_METHOD_NS}workspace/session_groups/update`: { + const workspaceCwd = this.parseBoundWorkspaceParam(params); + const groupId = String(params['groupId'] ?? ''); + if (!groupId) { + throw new AcpParamError('`groupId` is required'); + } + const group = await createSessionOrganizationService( + workspaceCwd, + ).updateGroup(groupId, { + ...('name' in params ? { name: params['name'] as string } : {}), + ...('color' in params + ? { color: params['color'] as SessionGroupColor } + : {}), + ...('order' in params ? { order: params['order'] as number } : {}), + }); + this.replyConn(conn, id, { group }); + return; + } + + case `${QWEN_METHOD_NS}workspace/session_groups/delete`: { + const workspaceCwd = this.parseBoundWorkspaceParam(params); + const groupId = String(params['groupId'] ?? ''); + if (!groupId) { + throw new AcpParamError('`groupId` is required'); + } + const deleted = + await createSessionOrganizationService(workspaceCwd).deleteGroup( + groupId, + ); + this.replyConn(conn, id, { deleted }); + return; + } + case `${QWEN_METHOD_NS}workspace/mcp`: this.replyConn( conn, diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index 43b0e3fa7a..1d81c1d526 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -194,6 +194,7 @@ const WS_READ_METHODS = new Set([ '_qwen/workspace/providers', '_qwen/workspace/env', '_qwen/workspace/preflight', + '_qwen/workspace/session_groups/list', '_qwen/workspace/trust', '_qwen/workspace/permissions', '_qwen/workspace/voice', diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index e2de57360a..3135556b6d 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -311,6 +311,17 @@ class FakeBridge { return this.workspaceSessions; } + getSessionSummary(sessionId: string) { + const summary = this.workspaceSessions.find( + (candidate) => candidate.sessionId === sessionId, + ); + if (summary) return summary; + if (sessionId === 'sess-1') { + return { sessionId, workspaceCwd: '/ws' }; + } + throw new Error(`Session not found: ${sessionId}`); + } + detached: Array<{ sessionId: string; clientId?: string }> = []; async cancelSession(sessionId: string) { @@ -1046,6 +1057,30 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { ); }); + it('initialize advertises session organization methods', async () => { + const { body } = await initializeRaw(); + const result = body['result'] as { + agentCapabilities: { + _meta: { qwen: { methods: string[] } }; + }; + }; + expect(result.agentCapabilities._meta.qwen.methods).toContain( + '_qwen/session/update_organization', + ); + expect(result.agentCapabilities._meta.qwen.methods).toContain( + '_qwen/workspace/session_groups/list', + ); + expect(result.agentCapabilities._meta.qwen.methods).toContain( + '_qwen/workspace/session_groups/create', + ); + expect(result.agentCapabilities._meta.qwen.methods).toContain( + '_qwen/workspace/session_groups/update', + ); + expect(result.agentCapabilities._meta.qwen.methods).toContain( + '_qwen/workspace/session_groups/delete', + ); + }); + it('initialize advertises _qwen/workspace/setup-github', async () => { const { body } = await initializeRaw(); const result = body['result'] as { @@ -6251,6 +6286,163 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(frames[0]).toMatchObject({ result: { v: 1, tools: [] } }); }); + it('session organization methods persist groups and organized list state', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440010'; + await writeStoredSession(sessionId); + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + const reader = frameReader(await streamRes); + + await post(connId, { + jsonrpc: '2.0', + id: 70, + method: '_qwen/workspace/session_groups/create', + params: { workspaceCwd: '/ws', name: 'Frontend', color: 'blue' }, + }); + const createFrame = (await reader.next()) as { + result: { group: { id: string; name: string; color: string } }; + }; + const group = createFrame.result.group; + expect(group).toMatchObject({ name: 'Frontend', color: 'blue' }); + + await post(connId, { + jsonrpc: '2.0', + id: 71, + method: '_qwen/session/update_organization', + params: { + sessionId, + isPinned: true, + groupId: group.id, + }, + }); + expect(await reader.next()).toMatchObject({ + result: { + sessionId, + isPinned: true, + groupId: group.id, + }, + }); + + await post(connId, { + jsonrpc: '2.0', + id: 72, + method: 'session/list', + params: { + workspaceCwd: '/ws', + view: 'organized', + group: group.id, + _meta: { size: 20 }, + }, + }); + expect(await reader.next()).toMatchObject({ + result: { + sessions: [ + { + sessionId, + isPinned: true, + groupId: group.id, + }, + ], + }, + }); + + await post(connId, { + jsonrpc: '2.0', + id: 73, + method: '_qwen/workspace/session_groups/delete', + params: { workspaceCwd: '/ws', groupId: group.id }, + }); + expect(await reader.next()).toMatchObject({ + result: { deleted: true }, + }); + + await post(connId, { + jsonrpc: '2.0', + id: 74, + method: 'session/list', + params: { + workspaceCwd: '/ws', + view: 'organized', + group: 'ungrouped', + _meta: { size: 20 }, + }, + }); + expect(await reader.next()).toMatchObject({ + result: { + sessions: [ + { + sessionId, + isPinned: true, + groupId: null, + }, + ], + }, + }); + reader.close(); + }); + }); + + it('session/list rejects group filter without organized view', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 75, + method: 'session/list', + params: { + workspaceCwd: '/ws', + group: 'pinned', + }, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ + id: 75, + error: { + code: -32602, + message: '`group` requires `view` to be "organized"', + }, + }); + }); + + it.each([ + { + params: { isPinned: true }, + message: '`sessionId` is required', + }, + { + params: { sessionId: 'session-1', isPinned: 'yes' }, + message: '`isPinned` must be a boolean', + }, + { + params: { sessionId: 'session-1', groupId: 1 }, + message: '`groupId` must be a string or null', + }, + ])( + '_qwen/session/update_organization rejects invalid params: $message', + async ({ params, message }) => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 76, + method: '_qwen/session/update_organization', + params, + }); + const frames = await takeFrames(await streamRes, 1); + expect(frames[0]).toMatchObject({ + id: 76, + error: { + code: -32602, + message, + }, + }); + }, + ); + it('_qwen/workspace/mcp/tools rejects missing serverName', async () => { const connId = await initialize(); const streamRes = openStream(connId); diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 3a064f064d..ea9b449ff0 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -93,6 +93,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_close: { since: 'v1' }, session_archive: { since: 'v1' }, session_metadata: { since: 'v1' }, + session_organization: { since: 'v1' }, session_export: { since: 'v1' }, // Daemon supports the MCP client guardrail surface: an in-process // counter exposed on `GET /workspace/mcp`, a `--mcp-client-budget=N` diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 432b4b0382..c9636e8777 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -10,8 +10,10 @@ import { APPROVAL_MODES, BTW_MAX_INPUT_LENGTH, SessionService, + SessionOrganizationError, addDaemonRequestAttribute, type ApprovalMode, + type SessionGroupColor, type SessionArchiveState, } from '@qwen-code/qwen-code-core'; import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts'; @@ -57,6 +59,7 @@ import { parseSessionExportFormat, sessionExportFormatValues, } from '../server/session-export.js'; +import { createSessionOrganizationService } from '../session-organization-helpers.js'; interface RegisterSessionRoutesDeps { boundWorkspace: string; @@ -85,6 +88,26 @@ function sendArtifactValidationError(res: Response, err: unknown): boolean { return true; } +function sendSessionOrganizationError(res: Response, err: unknown): boolean { + if (!(err instanceof SessionOrganizationError)) { + return false; + } + const status = + err.code === 'group_name_conflict' + ? 409 + : err.code === 'group_not_found' + ? 404 + : err.code === 'session_organization_store_unreadable' + ? 500 + : 400; + res.status(status).json({ + error: err.message, + code: err.code, + ...(err.field !== undefined ? { field: err.field } : {}), + }); + return true; +} + export function registerSessionRoutes( app: Application, deps: RegisterSessionRoutesDeps, @@ -130,6 +153,30 @@ export function registerSessionRoutes( error: e.error instanceof Error ? e.error.message : String(e.error), })); + const resolveWorkspaceParam = ( + req: Request, + res: Response, + ): string | null => { + const workspaceCwd = req.params['id'] ?? ''; + if (!path.isAbsolute(workspaceCwd)) { + res + .status(400) + .json({ error: '`:id` must decode to an absolute workspace path' }); + return null; + } + const key = canonicalizeWorkspace(workspaceCwd); + if (key !== boundWorkspace) { + res.status(400).json({ + error: `Workspace mismatch: daemon is bound to "${boundWorkspace}"`, + code: 'workspace_mismatch', + boundWorkspace, + requestedWorkspace: key, + }); + return null; + } + return key; + }; + const withMutableSession = ( route: string, @@ -1061,6 +1108,158 @@ export function registerSessionRoutes( }), ); + app.patch('/session/:id/organization', mutate(), async (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + try { + await archiveCoordinator.runSharedMany([sessionId], async () => { + // Organization is workspace-scoped sidecar state, not live-session + // metadata. It intentionally applies to persisted and archived sessions. + const sessionService = new SessionService(boundWorkspace); + let exists = await sessionService.sessionExistsInAnyState(sessionId); + if (!exists) { + try { + const summary = bridge.getSessionSummary(sessionId); + exists = summary.workspaceCwd === boundWorkspace; + } catch { + exists = false; + } + } + if (!exists) { + res.status(404).json({ + error: `No session with id "${sessionId}"`, + sessionId, + }); + return; + } + + const body = safeBody(req); + const rawIsPinned = body['isPinned']; + if (rawIsPinned !== undefined && typeof rawIsPinned !== 'boolean') { + res.status(400).json({ + error: '`isPinned` must be a boolean', + code: 'invalid_session_organization', + field: 'isPinned', + }); + return; + } + const rawGroupId = body['groupId']; + if ( + rawGroupId !== undefined && + rawGroupId !== null && + typeof rawGroupId !== 'string' + ) { + res.status(400).json({ + error: '`groupId` must be a string or null', + code: 'invalid_session_organization', + field: 'groupId', + }); + return; + } + + const organization = await createSessionOrganizationService( + boundWorkspace, + ).updateSessionOrganization(sessionId, { + ...(rawIsPinned !== undefined ? { isPinned: rawIsPinned } : {}), + ...(rawGroupId !== undefined + ? { groupId: rawGroupId as string | null } + : {}), + }); + res.status(200).json({ sessionId, ...organization }); + }); + } catch (err) { + if (sendSessionOrganizationError(res, err)) return; + sendBridgeError(res, err, { + route: 'PATCH /session/:id/organization', + sessionId, + }); + } + }); + + app.get('/workspace/:id/session-groups', async (req, res) => { + const key = resolveWorkspaceParam(req, res); + if (key === null) return; + try { + res + .status(200) + .json(await createSessionOrganizationService(key).listGroups()); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /workspace/:id/session-groups', + }); + } + }); + + app.post('/workspace/:id/session-groups', mutate(), async (req, res) => { + const key = resolveWorkspaceParam(req, res); + if (key === null) return; + const body = safeBody(req); + try { + const group = await createSessionOrganizationService(key).createGroup({ + name: body['name'] as string, + color: body['color'] as SessionGroupColor, + }); + res.status(201).json({ group }); + } catch (err) { + if (sendSessionOrganizationError(res, err)) return; + sendBridgeError(res, err, { + route: 'POST /workspace/:id/session-groups', + }); + } + }); + + app.patch( + '/workspace/:id/session-groups/:groupId', + mutate(), + async (req, res) => { + const key = resolveWorkspaceParam(req, res); + if (key === null) return; + const body = safeBody(req); + try { + const group = await createSessionOrganizationService(key).updateGroup( + req.params['groupId'] ?? '', + { + ...(Object.prototype.hasOwnProperty.call(body, 'name') + ? { name: body['name'] as string } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, 'color') + ? { color: body['color'] as SessionGroupColor } + : {}), + ...(Object.prototype.hasOwnProperty.call(body, 'order') + ? { order: body['order'] as number } + : {}), + }, + ); + res.status(200).json({ group }); + } catch (err) { + if (sendSessionOrganizationError(res, err)) return; + sendBridgeError(res, err, { + route: 'PATCH /workspace/:id/session-groups/:groupId', + }); + } + }, + ); + + app.delete( + '/workspace/:id/session-groups/:groupId', + mutate(), + async (req, res) => { + const key = resolveWorkspaceParam(req, res); + if (key === null) return; + try { + const deleted = await createSessionOrganizationService(key).deleteGroup( + req.params['groupId'] ?? '', + ); + res.status(200).json({ deleted }); + } catch (err) { + if (sendSessionOrganizationError(res, err)) return; + sendBridgeError(res, err, { + route: 'DELETE /workspace/:id/session-groups/:groupId', + }); + } + }, + ); + app.get('/workspace/:id/sessions', async (req, res) => { // Express decodes URL-encoded path params automatically; clients pass // the absolute workspace cwd encoded (e.g. @@ -1090,6 +1289,27 @@ export function registerSessionRoutes( ? req.query['cursor'] : undefined; const size = parseSessionPageSizeQuery(req.query['size']); + const rawView = req.query['view']; + let view: 'organized' | undefined; + if (rawView !== undefined) { + if (rawView !== 'organized') { + res.status(400).json({ + error: '`view` must be "organized"', + code: 'invalid_session_view', + }); + return; + } + view = 'organized'; + } + const group = + typeof req.query['group'] === 'string' ? req.query['group'] : undefined; + if (group !== undefined && view !== 'organized') { + res.status(400).json({ + error: '`group` requires `view=organized`', + code: 'invalid_session_group_filter', + }); + return; + } const rawArchiveState = req.query['archiveState']; let archiveState: SessionArchiveState | undefined; if (rawArchiveState !== undefined) { @@ -1106,13 +1326,17 @@ export function registerSessionRoutes( archiveState = rawArchiveState; } const result = await listWorkspaceSessionsForResponse(bridge, key, { - cursor, - size, - archiveState, + ...(cursor !== undefined ? { cursor } : {}), + ...(size !== undefined ? { size } : {}), + ...(archiveState !== undefined ? { archiveState } : {}), + ...(view !== undefined ? { view } : {}), + ...(group !== undefined ? { group } : {}), }); res.status(200).json({ sessions: result.sessions, ...(result.nextCursor != null ? { nextCursor: result.nextCursor } : {}), + ...(result.liveMergeFailed ? { liveMergeFailed: true } : {}), + ...(result.truncated ? { truncated: true } : {}), }); } catch (err) { if (err instanceof InvalidCursorError) { @@ -1122,6 +1346,7 @@ export function registerSessionRoutes( }); return; } + if (sendSessionOrganizationError(res, err)) return; writeStderrLine( `qwen serve: failed to list sessions for workspace ${safeLogValue( key, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 1403437cab..ee3c16713a 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -49,6 +49,7 @@ import { Storage, TrustGateError, type Extension, + type SessionListItem, } from '@qwen-code/qwen-code-core'; import * as qwenCore from '@qwen-code/qwen-code-core'; import type { DaemonStatusProvider } from '@qwen-code/acp-bridge'; @@ -216,6 +217,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_close', 'session_archive', 'session_metadata', + 'session_organization', 'session_export', // Issue #4175 PR 14. Always-on. Daemon supports the MCP client // guardrail surface (`--mcp-client-budget`, `clientCount` / @@ -6844,6 +6846,504 @@ describe('createServeApp', () => { } }); + it('lists organized sessions pinned first and filters by custom group', async () => { + const olderPinnedId = '550e8400-e29b-41d4-a716-446655440000'; + const newerId = '550e8400-e29b-41d4-a716-446655440001'; + await writeStoredSession({ + sessionId: olderPinnedId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:00:00.000Z', + prompt: 'older pinned', + mtime: new Date('2026-05-17T12:00:00.000Z'), + }); + await writeStoredSession({ + sessionId: newerId, + cwd: WS_BOUND, + timestamp: '2026-05-17T13:00:00.000Z', + prompt: 'newer unpinned', + mtime: new Date('2026-05-17T13:00:00.000Z'), + }); + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND, token: 'secret' }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const auth = (req: request.Test): request.Test => + req + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + + const groupRes = await auth( + request(app).post( + `/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`, + ), + ).send({ name: 'Frontend', color: 'blue' }); + expect(groupRes.status).toBe(201); + const groupId = groupRes.body.group.id as string; + + const organizationRes = await auth( + request(app).patch(`/session/${olderPinnedId}/organization`), + ).send({ isPinned: true, groupId }); + expect(organizationRes.status).toBe(200); + + const organized = await auth( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=all`, + ), + ); + expect(organized.status).toBe(200); + expect( + organized.body.sessions.map((s: { sessionId: string }) => s.sessionId), + ).toEqual([olderPinnedId, newerId]); + expect(organized.body.sessions[0]).toMatchObject({ + sessionId: olderPinnedId, + isPinned: true, + groupId, + }); + + const filtered = await auth( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=${encodeURIComponent(groupId)}`, + ), + ); + expect(filtered.status).toBe(200); + expect(filtered.body.sessions).toEqual([ + expect.objectContaining({ sessionId: olderPinnedId, groupId }), + ]); + }); + + it('updates and deletes session groups through REST', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:00:00.000Z', + prompt: 'stored session', + mtime: new Date('2026-05-17T12:00:00.000Z'), + }); + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const host = (req: request.Test): request.Test => + req.set('Host', `127.0.0.1:${baseOpts.port}`); + + const groupRes = await host( + request(app).post( + `/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`, + ), + ).send({ name: 'Frontend', color: 'blue' }); + expect(groupRes.status).toBe(201); + const groupId = groupRes.body.group.id as string; + + const updateRes = await host( + request(app).patch( + `/workspace/${encodeURIComponent( + WS_BOUND, + )}/session-groups/${encodeURIComponent(groupId)}`, + ), + ).send({ name: 'UI', color: 'purple', order: 4 }); + expect(updateRes.status).toBe(200); + expect(updateRes.body.group).toMatchObject({ + id: groupId, + name: 'UI', + color: 'purple', + order: 4, + }); + + const organizationRes = await host( + request(app).patch(`/session/${sessionId}/organization`), + ).send({ isPinned: true, groupId }); + expect(organizationRes.status).toBe(200); + + const deleteRes = await host( + request(app).delete( + `/workspace/${encodeURIComponent( + WS_BOUND, + )}/session-groups/${encodeURIComponent(groupId)}`, + ), + ); + expect(deleteRes.status).toBe(200); + expect(deleteRes.body).toEqual({ deleted: true }); + + const groupsRes = await host( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`, + ), + ); + expect(groupsRes.status).toBe(200); + expect(groupsRes.body.groups).toEqual([]); + + const organized = await host( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=ungrouped`, + ), + ); + expect(organized.status).toBe(200); + expect(organized.body.sessions).toEqual([ + expect.objectContaining({ + sessionId, + groupId: null, + isPinned: true, + }), + ]); + }); + + it('returns session organization errors for invalid REST inputs', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:00:00.000Z', + prompt: 'stored session', + mtime: new Date('2026-05-17T12:00:00.000Z'), + }); + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const host = (req: request.Test): request.Test => + req.set('Host', `127.0.0.1:${baseOpts.port}`); + + const invalidView = await host( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=recent`, + ), + ); + expect(invalidView.status).toBe(400); + expect(invalidView.body.code).toBe('invalid_session_view'); + + const groupWithoutOrganizedView = await host( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?group=pinned`, + ), + ); + expect(groupWithoutOrganizedView.status).toBe(400); + expect(groupWithoutOrganizedView.body.code).toBe( + 'invalid_session_group_filter', + ); + + const groupRes = await host( + request(app).post( + `/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`, + ), + ).send({ name: 'Backend', color: 'blue' }); + expect(groupRes.status).toBe(201); + + const duplicateGroup = await host( + request(app).post( + `/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`, + ), + ).send({ name: 'backend', color: 'green' }); + expect(duplicateGroup.status).toBe(409); + expect(duplicateGroup.body.code).toBe('group_name_conflict'); + + const invalidOrganizationBody = await host( + request(app).patch(`/session/${sessionId}/organization`), + ).send({ isPinned: 'yes' }); + expect(invalidOrganizationBody.status).toBe(400); + expect(invalidOrganizationBody.body.code).toBe( + 'invalid_session_organization', + ); + + const unknownGroupFilter = await host( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=missing-group`, + ), + ); + expect(unknownGroupFilter.status).toBe(404); + expect(unknownGroupFilter.body.code).toBe('group_not_found'); + + const unknownGroupAssignment = await host( + request(app).patch(`/session/${sessionId}/organization`), + ).send({ groupId: 'missing-group' }); + expect(unknownGroupAssignment.status).toBe(404); + expect(unknownGroupAssignment.body.code).toBe('group_not_found'); + + const missingSession = await host( + request(app).patch( + '/session/550e8400-e29b-41d4-a716-446655440099/organization', + ), + ).send({ isPinned: true }); + expect(missingSession.status).toBe(404); + }); + + it('paginates organized sessions with opaque cursors', async () => { + for (let i = 0; i < 4; i++) { + await writeStoredSession({ + sessionId: `550e8400-e29b-41d4-a716-44665544${String(i).padStart(4, '0')}`, + cwd: WS_BOUND, + timestamp: `2026-05-17T12:0${i}:00.000Z`, + prompt: `organized ${i}`, + mtime: new Date(`2026-05-17T12:1${i}:00.000Z`), + }); + } + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const host = (req: request.Test): request.Test => + req.set('Host', `127.0.0.1:${baseOpts.port}`); + + const page1 = await host( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&size=2`, + ), + ); + expect(page1.status).toBe(200); + expect(page1.body.sessions).toHaveLength(2); + expect(page1.body.nextCursor).toEqual(expect.any(String)); + + const insertedSessionId = '550e8400-e29b-41d4-a716-446655449999'; + await writeStoredSession({ + sessionId: insertedSessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:09:00.000Z', + prompt: 'organized inserted', + mtime: new Date('2026-05-17T12:19:00.000Z'), + }); + + const page2 = await host( + request(app).get( + `/workspace/${encodeURIComponent( + WS_BOUND, + )}/sessions?view=organized&size=2&cursor=${encodeURIComponent( + page1.body.nextCursor as string, + )}`, + ), + ); + expect(page2.status).toBe(200); + expect(page2.body.sessions).toHaveLength(2); + expect(page2.body.nextCursor).toBeUndefined(); + + const allIds = [...page1.body.sessions, ...page2.body.sessions].map( + (session: { sessionId: string }) => session.sessionId, + ); + expect(new Set(allIds).size).toBe(4); + expect(allIds).not.toContain(insertedSessionId); + + const mismatchedCursor = await host( + request(app).get( + `/workspace/${encodeURIComponent( + WS_BOUND, + )}/sessions?view=organized&group=pinned&cursor=${encodeURIComponent( + page1.body.nextCursor as string, + )}`, + ), + ); + expect(mismatchedCursor.status).toBe(400); + expect(mismatchedCursor.body.code).toBe('invalid_cursor'); + expect(mismatchedCursor.body.error).toContain( + 'not a valid organized cursor', + ); + + const invalidCursor = await host( + request(app).get( + `/workspace/${encodeURIComponent( + WS_BOUND, + )}/sessions?view=organized&cursor=not-a-cursor`, + ), + ); + expect(invalidCursor.status).toBe(400); + expect(invalidCursor.body.code).toBe('invalid_cursor'); + expect(invalidCursor.body.error).toContain( + 'not a valid organized cursor', + ); + }); + + it('reports organized session truncation in the response', async () => { + const items: SessionListItem[] = Array.from( + { length: 50_001 }, + (_, i) => { + const timestamp = new Date( + Date.UTC(2026, 4, 17, 12, 0, i), + ).toISOString(); + return { + sessionId: `session-${i}`, + cwd: WS_BOUND, + startTime: timestamp, + mtime: Date.parse(timestamp), + prompt: `prompt ${i}`, + filePath: `/tmp/session-${i}.jsonl`, + }; + }, + ); + const listSessionsSpy = vi + .spyOn(SessionService.prototype, 'listSessions') + .mockResolvedValue({ + items, + nextCursor: 1, + hasMore: true, + }); + + try { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const res = await request(app) + .get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body.sessions).toHaveLength(20); + expect(res.body.truncated).toBe(true); + } finally { + listSessionsSpy.mockRestore(); + } + }); + + it('stops organized session scans when a cursor page is empty', async () => { + const listSessionsSpy = vi + .spyOn(SessionService.prototype, 'listSessions') + .mockResolvedValue({ + items: [], + nextCursor: 1, + hasMore: true, + }); + + try { + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const res = await request(app) + .get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized`, + ) + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body.sessions).toEqual([]); + expect(listSessionsSpy).toHaveBeenCalledTimes(1); + } finally { + listSessionsSpy.mockRestore(); + } + }); + + it('allows session organization mutations on loopback without a token', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + await writeStoredSession({ + sessionId, + cwd: WS_BOUND, + timestamp: '2026-05-17T12:00:00.000Z', + prompt: 'stored session', + mtime: new Date('2026-05-17T12:00:00.000Z'), + }); + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const host = (req: request.Test): request.Test => + req.set('Host', `127.0.0.1:${baseOpts.port}`); + + const groupRes = await host( + request(app).post( + `/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`, + ), + ).send({ name: 'Local', color: 'green' }); + expect(groupRes.status).toBe(201); + + const organizationRes = await host( + request(app).patch(`/session/${sessionId}/organization`), + ).send({ isPinned: true, groupId: groupRes.body.group.id }); + expect(organizationRes.status).toBe(200); + + const organized = await host( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=pinned`, + ), + ); + expect(organized.status).toBe(200); + expect(organized.body.sessions).toEqual([ + expect.objectContaining({ + sessionId, + isPinned: true, + groupId: groupRes.body.group.id, + }), + ]); + }); + + it('applies organization metadata to live-only sessions in organized lists', async () => { + const liveId = '550e8400-e29b-41d4-a716-446655440099'; + const liveSummary = { + sessionId: liveId, + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + updatedAt: '2026-05-17T12:00:00.000Z', + clientCount: 1, + hasActivePrompt: false, + }; + const bridge = fakeBridge({ + listImpl: () => [liveSummary], + summaryImpl: () => liveSummary, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND, token: 'secret' }, + undefined, + { bridge, boundWorkspace: WS_BOUND }, + ); + const auth = (req: request.Test): request.Test => + req + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('Authorization', 'Bearer secret'); + + const groupRes = await auth( + request(app).post( + `/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`, + ), + ).send({ name: 'Frontend', color: 'blue' }); + expect(groupRes.status).toBe(201); + + const organizationRes = await auth( + request(app).patch(`/session/${liveId}/organization`), + ).send({ isPinned: true, groupId: groupRes.body.group.id }); + expect(organizationRes.status).toBe(200); + + const organized = await auth( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=all`, + ), + ); + expect(organized.status).toBe(200); + expect(organized.body.sessions).toEqual([ + expect.objectContaining({ + sessionId: liveId, + isPinned: true, + groupId: groupRes.body.group.id, + }), + ]); + + const pinned = await auth( + request(app).get( + `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=pinned`, + ), + ); + expect(pinned.status).toBe(200); + expect(pinned.body.sessions).toEqual([ + expect.objectContaining({ + sessionId: liveId, + isPinned: true, + groupId: groupRes.body.group.id, + }), + ]); + }); + it('excludes live sessions from subsequent pages to prevent cross-page duplicates', async () => { const liveId = '550e8400-e29b-41d4-a716-446655440099'; for (let i = 0; i < 5; i++) { diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 46d3ae0398..57ecf81a7a 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -6,30 +6,38 @@ import { SessionService, + SessionOrganizationError, type SessionArchiveState, } from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge, BridgeSessionSummary, } from '../acp-session-bridge.js'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { createSessionOrganizationService } from '../session-organization-helpers.js'; const DEFAULT_SESSION_PAGE_SIZE = 20; const MAX_SESSION_PAGE_SIZE = 100; +const MAX_ORGANIZED_SESSIONS = 50_000; export interface ListWorkspaceSessionsOptions { cursor?: string; size?: number; archiveState?: SessionArchiveState; + view?: 'organized'; + group?: string; } export interface ListWorkspaceSessionsResult { sessions: BridgeSessionSummary[]; nextCursor?: string; + liveMergeFailed?: boolean; + truncated?: boolean; } export class InvalidCursorError extends Error { - constructor(cursor: string) { - super(`Invalid cursor: "${cursor}" is not a valid numeric cursor`); + constructor(cursor: string, kind: 'numeric' | 'organized' = 'numeric') { + super(`Invalid cursor: "${cursor}" is not a valid ${kind} cursor`); this.name = 'InvalidCursorError'; } } @@ -49,6 +57,307 @@ function parseSessionCursor(cursor: string): number | undefined { return parsed; } +interface OrganizedCursor { + group: string; + archiveState: SessionArchiveState; + last: OrganizedCursorKey; +} + +interface OrganizedCursorKey { + isPinned: boolean; + activityTime: number; + sessionId: string; +} + +function parseOrganizedCursor( + cursor: string, + expected: { group: string; archiveState: SessionArchiveState }, +): OrganizedCursorKey | undefined { + if (cursor === '') return undefined; + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); + const parsed = JSON.parse(decoded) as unknown; + const last = (parsed as OrganizedCursor).last; + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) || + typeof last !== 'object' || + last === null || + Array.isArray(last) || + typeof last.isPinned !== 'boolean' || + typeof last.activityTime !== 'number' || + !Number.isFinite(last.activityTime) || + typeof last.sessionId !== 'string' || + last.sessionId.length === 0 || + (parsed as OrganizedCursor).group !== expected.group || + (parsed as OrganizedCursor).archiveState !== expected.archiveState + ) { + throw new Error('invalid organized cursor'); + } + return last; + } catch { + throw new InvalidCursorError(cursor, 'organized'); + } +} + +function encodeOrganizedCursor( + last: OrganizedCursorKey, + group: string, + archiveState: SessionArchiveState, +): string { + return Buffer.from( + JSON.stringify({ group, archiveState, last }), + 'utf8', + ).toString('base64url'); +} + +function toSummary(item: { + sessionId: string; + cwd: string; + startTime: string; + mtime: number; + prompt: string; + customTitle?: string; + isArchived?: boolean; +}): BridgeSessionSummary { + return { + sessionId: item.sessionId, + workspaceCwd: item.cwd, + createdAt: item.startTime, + updatedAt: new Date(item.mtime).toISOString(), + displayName: item.customTitle || item.prompt, + clientCount: 0, + hasActivePrompt: false, + isArchived: item.isArchived === true, + }; +} + +async function listAllPersistedSummaries( + sessionService: SessionService, + archiveState: SessionArchiveState, +): Promise<{ sessions: BridgeSessionSummary[]; truncated: boolean }> { + // Organized view needs global pin/group ordering before pagination; v1 keeps + // the storage API unchanged and performs that merge in memory. + const sessions: BridgeSessionSummary[] = []; + let truncated = false; + let cursor: number | undefined; + do { + const page = await sessionService.listSessions({ + cursor, + size: 10_000, + archiveState, + }); + const remaining = MAX_ORGANIZED_SESSIONS - sessions.length; + sessions.push(...page.items.slice(0, remaining).map(toSummary)); + cursor = page.nextCursor; + if (page.items.length === 0) { + break; + } + if ( + page.items.length > remaining || + (sessions.length >= MAX_ORGANIZED_SESSIONS && cursor !== undefined) + ) { + writeStderrLine( + `qwen serve: organized session list truncated at ${MAX_ORGANIZED_SESSIONS} sessions`, + ); + truncated = true; + break; + } + } while (cursor !== undefined); + return { sessions, truncated }; +} + +function getSummaryActivityTime(session: BridgeSessionSummary): number { + const time = Date.parse(session.updatedAt ?? session.createdAt); + return Number.isFinite(time) ? time : 0; +} + +function compareOrganizedSessions( + activityTimeById: ReadonlyMap, + a: BridgeSessionSummary, + b: BridgeSessionSummary, +): number { + return compareOrganizedCursorKeys( + getOrganizedCursorKey(activityTimeById, a), + getOrganizedCursorKey(activityTimeById, b), + ); +} + +function getOrganizedCursorKey( + activityTimeById: ReadonlyMap, + session: BridgeSessionSummary, +): OrganizedCursorKey { + return { + isPinned: session.isPinned === true, + activityTime: activityTimeById.get(session.sessionId) ?? 0, + sessionId: session.sessionId, + }; +} + +function compareOrganizedCursorKeys( + a: OrganizedCursorKey, + b: OrganizedCursorKey, +): number { + const byPinned = Number(b.isPinned) - Number(a.isPinned); + if (byPinned !== 0) return byPinned; + const byTime = b.activityTime - a.activityTime; + if (byTime !== 0) return byTime; + return a.sessionId.localeCompare(b.sessionId); +} + +function applyOrganization( + session: BridgeSessionSummary, + organization: + | { + groupId: string | null; + isPinned: boolean; + pinnedAt?: string; + } + | undefined, +): BridgeSessionSummary { + return { + ...session, + groupId: organization?.groupId ?? null, + isPinned: organization?.isPinned === true, + ...(organization?.pinnedAt !== undefined + ? { pinnedAt: organization.pinnedAt } + : {}), + }; +} + +async function listOrganizedWorkspaceSessionsForResponse( + bridge: AcpSessionBridge, + workspaceCwd: string, + options: ListWorkspaceSessionsOptions, + pageSize: number, +): Promise { + const archiveState = options.archiveState ?? 'active'; + const sessionService = new SessionService(workspaceCwd); + const organizationService = createSessionOrganizationService(workspaceCwd); + const snapshot = await organizationService.readSnapshot(); + const knownGroupIds = new Set(snapshot.groups.map((group) => group.id)); + const group = options.group ?? 'all'; + if ( + group !== 'all' && + group !== 'pinned' && + group !== 'ungrouped' && + !knownGroupIds.has(group) + ) { + throw new SessionOrganizationError( + `Group not found: ${group}`, + 'group_not_found', + 'group', + ); + } + const cursorKey = + options.cursor !== undefined + ? parseOrganizedCursor(options.cursor, { group, archiveState }) + : undefined; + const isFirstPage = cursorKey === undefined; + let liveMergeFailed = false; + + const bySessionId = new Map(); + const persisted = await listAllPersistedSummaries( + sessionService, + archiveState, + ); + for (const session of persisted.sessions) { + bySessionId.set( + session.sessionId, + applyOrganization(session, snapshot.sessions.get(session.sessionId)), + ); + } + + if (archiveState !== 'archived' && isFirstPage) { + try { + const liveSessions = bridge.listWorkspaceSessions(workspaceCwd); + for (const live of liveSessions) { + const existing = bySessionId.get(live.sessionId); + const organization = snapshot.sessions.get(live.sessionId); + if (existing) { + bySessionId.set( + live.sessionId, + applyOrganization( + { + ...existing, + ...live, + createdAt: existing.createdAt, + displayName: live.displayName ?? existing.displayName, + updatedAt: live.updatedAt ?? existing.updatedAt, + clientCount: live.clientCount, + hasActivePrompt: live.hasActivePrompt, + isArchived: false, + }, + organization, + ), + ); + } else if (!(await sessionService.sessionExists(live.sessionId))) { + bySessionId.set( + live.sessionId, + applyOrganization( + { + ...live, + createdAt: live.createdAt, + clientCount: live.clientCount, + hasActivePrompt: live.hasActivePrompt, + isArchived: false, + }, + organization, + ), + ); + } + } + } catch (error) { + liveMergeFailed = true; + writeStderrLine( + `qwen serve: organized session list live merge failed; using persisted sessions only: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + const filtered = [...bySessionId.values()].filter((session) => { + if (group === 'all') return true; + if (group === 'pinned') return session.isPinned === true; + if (group === 'ungrouped') return session.groupId == null; + return session.groupId === group; + }); + const activityTimeById = new Map( + filtered.map((session) => [ + session.sessionId, + getSummaryActivityTime(session), + ]), + ); + filtered.sort((a, b) => compareOrganizedSessions(activityTimeById, a, b)); + const afterCursor = + cursorKey === undefined + ? filtered + : filtered.filter( + (session) => + compareOrganizedCursorKeys( + cursorKey, + getOrganizedCursorKey(activityTimeById, session), + ) < 0, + ); + const page = afterCursor.slice(0, pageSize); + const nextCursor = + page.length < afterCursor.length + ? encodeOrganizedCursor( + getOrganizedCursorKey(activityTimeById, page[page.length - 1]!), + group, + archiveState, + ) + : undefined; + return { + sessions: page, + ...(nextCursor !== undefined ? { nextCursor } : {}), + ...(liveMergeFailed ? { liveMergeFailed: true } : {}), + ...(persisted.truncated ? { truncated: true } : {}), + }; +} + export async function listWorkspaceSessionsForResponse( bridge: AcpSessionBridge, workspaceCwd: string, @@ -61,6 +370,15 @@ export async function listWorkspaceSessionsForResponse( : DEFAULT_SESSION_PAGE_SIZE; const pageSize = Math.min(Math.max(requestedSize, 1), MAX_SESSION_PAGE_SIZE); + if (options?.view === 'organized') { + return listOrganizedWorkspaceSessionsForResponse( + bridge, + workspaceCwd, + options, + pageSize, + ); + } + let numericCursor: number | undefined; if (options?.cursor != null) { numericCursor = parseSessionCursor(options.cursor); @@ -77,16 +395,7 @@ export async function listWorkspaceSessionsForResponse( const bySessionId = new Map(); for (const item of persisted.items) { - bySessionId.set(item.sessionId, { - sessionId: item.sessionId, - workspaceCwd: item.cwd, - createdAt: item.startTime, - updatedAt: new Date(item.mtime).toISOString(), - displayName: item.customTitle || item.prompt, - clientCount: 0, - hasActivePrompt: false, - isArchived: item.isArchived === true, - }); + bySessionId.set(item.sessionId, toSummary(item)); } if (archiveState === 'archived') { diff --git a/packages/cli/src/serve/session-organization-helpers.ts b/packages/cli/src/serve/session-organization-helpers.ts new file mode 100644 index 0000000000..f131368bc4 --- /dev/null +++ b/packages/cli/src/serve/session-organization-helpers.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SessionOrganizationService } from '@qwen-code/qwen-code-core'; +import { writeStderrLine } from '../utils/stdioHelpers.js'; + +export function createSessionOrganizationService( + workspaceCwd: string, +): SessionOrganizationService { + return new SessionOrganizationService(workspaceCwd, (message) => { + writeStderrLine(`qwen serve: session-org: ${message}`); + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9fba61c12e..c93ff5d0f1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,6 +122,7 @@ export { } from './tools/skill-utils.js'; export { atomicWriteFile } from './utils/atomicFileWrite.js'; export { nextFireTime, parseCron } from './utils/cronParser.js'; +export * from './services/session-organization-service.js'; // Backward-compatible type re-exports for tool classes removed from eager loading. // These preserve TypeScript type compatibility for downstream consumers. diff --git a/packages/core/src/services/session-organization-service.test.ts b/packages/core/src/services/session-organization-service.test.ts new file mode 100644 index 0000000000..a49dba8654 --- /dev/null +++ b/packages/core/src/services/session-organization-service.test.ts @@ -0,0 +1,508 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + GROUP_COLOR_OPTIONS, + SessionOrganizationService, +} from './session-organization-service.js'; + +describe('SessionOrganizationService', () => { + let previousRuntimeDir: string | undefined; + let runtimeDir: string; + let service: SessionOrganizationService; + let warnings: string[]; + + const cwd = '/workspace/project'; + const sessionIdA = '550e8400-e29b-41d4-a716-446655440000'; + const sessionIdB = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + + beforeEach(async () => { + previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-org-')); + process.env['QWEN_RUNTIME_DIR'] = runtimeDir; + warnings = []; + service = new SessionOrganizationService(cwd, (warning) => { + warnings.push(warning); + }); + }); + + afterEach(async () => { + if (previousRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir; + } + await fs.rm(runtimeDir, { recursive: true, force: true }); + }); + + it('starts with no user groups and exposes fixed color options', async () => { + const catalog = await service.listGroups(); + + expect(catalog.groups).toEqual([]); + expect(catalog.colorOptions).toEqual(GROUP_COLOR_OPTIONS); + }); + + it('creates, updates, and rejects duplicate group names case-insensitively', async () => { + const group = await service.createGroup({ + name: ' Frontend ', + color: 'blue', + }); + + expect(group).toEqual( + expect.objectContaining({ + name: 'Frontend', + color: 'blue', + order: 0, + }), + ); + + await expect( + service.createGroup({ name: 'frontend', color: 'green' }), + ).rejects.toMatchObject({ code: 'group_name_conflict' }); + + const renamed = await service.updateGroup(group.id, { + name: 'UI', + color: 'purple', + order: 5, + }); + + expect(renamed).toEqual( + expect.objectContaining({ + id: group.id, + name: 'UI', + color: 'purple', + order: 5, + }), + ); + }); + + it('rejects invalid group names and colors', async () => { + await expect( + service.createGroup({ name: 'Bad\tName', color: 'blue' }), + ).rejects.toMatchObject({ + code: 'invalid_group_name', + field: 'name', + }); + + await expect( + service.createGroup({ name: 'Bad\u007fName', color: 'blue' }), + ).rejects.toMatchObject({ + code: 'invalid_group_name', + field: 'name', + }); + + await expect( + service.createGroup({ name: '\u200b', color: 'blue' }), + ).rejects.toMatchObject({ + code: 'invalid_group_name', + field: 'name', + }); + + await expect( + service.createGroup({ name: 'Bad\u202eName', color: 'blue' }), + ).rejects.toMatchObject({ + code: 'invalid_group_name', + field: 'name', + }); + + await expect( + service.createGroup({ name: 'Feature', color: 'pink' as never }), + ).rejects.toMatchObject({ + code: 'invalid_group_color', + field: 'color', + }); + }); + + it('assigns new group order after the current maximum order', async () => { + const first = await service.createGroup({ name: 'First', color: 'red' }); + const second = await service.createGroup({ + name: 'Second', + color: 'green', + }); + await service.updateGroup(second.id, { order: 10 }); + await service.deleteGroup(first.id); + + const third = await service.createGroup({ name: 'Third', color: 'blue' }); + + expect(third.order).toBe(11); + }); + + it('clamps new group order at the maximum safe integer', async () => { + const first = await service.createGroup({ name: 'First', color: 'red' }); + await service.updateGroup(first.id, { order: Number.MAX_SAFE_INTEGER }); + + const second = await service.createGroup({ + name: 'Second', + color: 'green', + }); + + expect(second.order).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('rejects creating more than 200 groups', async () => { + await fs.mkdir(path.dirname(service.getStorePath()), { recursive: true }); + await fs.writeFile( + service.getStorePath(), + JSON.stringify({ + schemaVersion: 1, + groups: Array.from({ length: 200 }, (_, index) => ({ + id: `group-${index}`, + name: `Group ${index}`, + color: 'blue', + order: index, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + })), + sessions: {}, + }), + 'utf8', + ); + + await expect( + service.createGroup({ name: 'Overflow', color: 'red' }), + ).rejects.toMatchObject({ code: 'group_limit_reached' }); + }); + + it('keeps groups with unsupported stored colors by falling back and warning once', async () => { + await fs.mkdir(path.dirname(service.getStorePath()), { recursive: true }); + await fs.writeFile( + service.getStorePath(), + JSON.stringify({ + schemaVersion: 1, + groups: [ + { + id: 'group-future', + name: 'Future', + color: 'teal', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + sessions: { + [sessionIdA]: { + groupId: 'group-future', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }), + 'utf8', + ); + + await expect(service.listGroups()).resolves.toEqual({ + groups: [ + expect.objectContaining({ + id: 'group-future', + name: 'Future', + color: 'blue', + }), + ], + colorOptions: GROUP_COLOR_OPTIONS, + }); + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.get(sessionIdA)).toEqual( + expect.objectContaining({ + groupId: 'group-future', + }), + ); + await new SessionOrganizationService(cwd, (warning) => { + warnings.push(warning); + }).listGroups(); + + expect(warnings).toEqual([ + 'Session group "Future" (id: group-future) uses unsupported color "teal"; using "blue"', + ]); + }); + + it('warns when dropping duplicate group names from the sidecar', async () => { + await fs.mkdir(path.dirname(service.getStorePath()), { recursive: true }); + await fs.writeFile( + service.getStorePath(), + JSON.stringify({ + schemaVersion: 1, + groups: [ + { + id: 'group-a', + name: 'Work', + color: 'red', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'group-b', + name: 'work', + color: 'blue', + order: 1, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + sessions: {}, + }), + 'utf8', + ); + + await expect(service.listGroups()).resolves.toEqual({ + groups: [ + expect.objectContaining({ + id: 'group-a', + name: 'Work', + }), + ], + colorOptions: GROUP_COLOR_OPTIONS, + }); + expect(warnings).toEqual([ + 'Dropped duplicate session group by name: "work" (id: group-b)', + ]); + }); + + it('pins sessions and assigns them to a single custom group', async () => { + const group = await service.createGroup({ name: 'Release', color: 'red' }); + + const org = await service.updateSessionOrganization(sessionIdA, { + isPinned: true, + groupId: group.id, + }); + + expect(org).toEqual( + expect.objectContaining({ + groupId: group.id, + isPinned: true, + }), + ); + expect(org.pinnedAt).toEqual(expect.any(String)); + + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.get(sessionIdA)).toEqual( + expect.objectContaining({ + groupId: group.id, + isPinned: true, + }), + ); + }); + + it('unpins a session and clears pinnedAt', async () => { + await service.updateSessionOrganization(sessionIdA, { isPinned: true }); + + const org = await service.updateSessionOrganization(sessionIdA, { + isPinned: false, + }); + + expect(org).toEqual( + expect.objectContaining({ + groupId: null, + isPinned: false, + }), + ); + expect(org.pinnedAt).toBeUndefined(); + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.get(sessionIdA)).toEqual( + expect.objectContaining({ isPinned: false }), + ); + }); + + it('treats an empty session organization update as a no-op', async () => { + const pinned = await service.updateSessionOrganization(sessionIdA, { + isPinned: true, + }); + const storeBefore = await fs.readFile(service.getStorePath(), 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 5)); + + const org = await service.updateSessionOrganization(sessionIdA, {}); + + expect(org).toEqual(pinned); + await expect(fs.readFile(service.getStorePath(), 'utf8')).resolves.toBe( + storeBefore, + ); + }); + + it('rejects unknown group updates and assignments', async () => { + await expect( + service.updateGroup('missing-group', { name: 'Missing' }), + ).rejects.toMatchObject({ code: 'group_not_found', field: 'groupId' }); + + await expect( + service.updateSessionOrganization(sessionIdA, { + groupId: 'missing-group', + }), + ).rejects.toMatchObject({ code: 'group_not_found', field: 'groupId' }); + }); + + it('deleting a group clears session references without losing pinned state', async () => { + const group = await service.createGroup({ + name: 'Research', + color: 'yellow', + }); + await service.updateSessionOrganization(sessionIdA, { + isPinned: true, + groupId: group.id, + }); + await service.updateSessionOrganization(sessionIdB, { groupId: group.id }); + + await service.deleteGroup(group.id); + + const snapshot = await service.readSnapshot(); + expect(snapshot.groups).toEqual([]); + expect(snapshot.sessions.get(sessionIdA)).toEqual( + expect.objectContaining({ groupId: null, isPinned: true }), + ); + expect(snapshot.sessions.get(sessionIdB)).toEqual( + expect.objectContaining({ groupId: null, isPinned: false }), + ); + }); + + it('warns once when reading orphaned group references', async () => { + await fs.mkdir(path.dirname(service.getStorePath()), { recursive: true }); + await fs.writeFile( + service.getStorePath(), + JSON.stringify({ + schemaVersion: 1, + groups: [], + sessions: { + [sessionIdA]: { + groupId: 'missing-group', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }), + 'utf8', + ); + + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.get(sessionIdA)).toEqual( + expect.objectContaining({ + groupId: null, + isPinned: false, + }), + ); + await service.readSnapshot(); + await new SessionOrganizationService(cwd, (warning) => { + warnings.push(warning); + }).readSnapshot(); + + expect(warnings).toEqual([ + `Dropped orphaned session group reference: session ${sessionIdA} references missing group missing-group`, + ]); + }); + + it('warns once when reading malformed session entries', async () => { + await fs.mkdir(path.dirname(service.getStorePath()), { recursive: true }); + await fs.writeFile( + service.getStorePath(), + JSON.stringify({ + schemaVersion: 1, + groups: [], + sessions: { + [sessionIdA]: 'bad-entry', + }, + }), + 'utf8', + ); + + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.has(sessionIdA)).toBe(false); + await new SessionOrganizationService(cwd, (warning) => { + warnings.push(warning); + }).readSnapshot(); + + expect(warnings).toEqual([ + `Dropped malformed session organization entry: ${sessionIdA}`, + ]); + }); + + it('treats a malformed sidecar as empty for reads and refuses to overwrite it', async () => { + await fs.mkdir(path.dirname(service.getStorePath()), { recursive: true }); + await fs.writeFile(service.getStorePath(), '{not-json', 'utf8'); + + await expect(service.listGroups()).resolves.toEqual({ + groups: [], + colorOptions: GROUP_COLOR_OPTIONS, + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('Failed to read session organization store'); + await service.listGroups(); + expect(warnings).toHaveLength(1); + + await expect( + service.createGroup({ name: 'Fixed', color: 'orange' }), + ).rejects.toMatchObject({ + code: 'session_organization_store_unreadable', + }); + await expect( + service.createGroup({ name: 'Fixed Again', color: 'orange' }), + ).rejects.toThrow('Delete the file to reset session organization'); + + await expect(fs.readFile(service.getStorePath(), 'utf8')).resolves.toBe( + '{not-json', + ); + }); + + it('includes schema version details in unreadable store warnings', async () => { + await fs.mkdir(path.dirname(service.getStorePath()), { recursive: true }); + await fs.writeFile( + service.getStorePath(), + JSON.stringify({ + schemaVersion: 2, + groups: [], + sessions: {}, + }), + 'utf8', + ); + + await expect(service.listGroups()).resolves.toEqual({ + groups: [], + colorOptions: GROUP_COLOR_OPTIONS, + }); + + expect(warnings).toEqual([ + expect.stringContaining( + 'store schema is unsupported (found schemaVersion 2, expected 1)', + ), + ]); + }); + + it('removes a session organization entry from the sidecar', async () => { + const group = await service.createGroup({ + name: 'Cleanup', + color: 'purple', + }); + await service.updateSessionOrganization(sessionIdA, { + isPinned: true, + groupId: group.id, + }); + + await service.removeSession(sessionIdA); + + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.has(sessionIdA)).toBe(false); + }); + + it('removes multiple session organization entries in one call', async () => { + const group = await service.createGroup({ + name: 'Cleanup', + color: 'purple', + }); + await service.updateSessionOrganization(sessionIdA, { + isPinned: true, + groupId: group.id, + }); + await service.updateSessionOrganization(sessionIdB, { + isPinned: true, + groupId: group.id, + }); + + await service.removeSessions([sessionIdA, sessionIdB, sessionIdA]); + + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.has(sessionIdA)).toBe(false); + expect(snapshot.sessions.has(sessionIdB)).toBe(false); + }); +}); diff --git a/packages/core/src/services/session-organization-service.ts b/packages/core/src/services/session-organization-service.ts new file mode 100644 index 0000000000..e81e7a56b1 --- /dev/null +++ b/packages/core/src/services/session-organization-service.ts @@ -0,0 +1,627 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { atomicWriteJSON } from '../utils/atomicFileWrite.js'; + +export const GROUP_COLOR_OPTIONS = [ + 'red', + 'orange', + 'yellow', + 'green', + 'blue', + 'purple', +] as const; + +export type SessionGroupColor = (typeof GROUP_COLOR_OPTIONS)[number]; + +export interface SessionGroup { + id: string; + name: string; + color: SessionGroupColor; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface SessionOrganization { + groupId: string | null; + pinnedAt?: string; + updatedAt: string; +} + +export interface SessionOrganizationView extends SessionOrganization { + isPinned: boolean; +} + +export interface SessionOrganizationSnapshot { + groups: SessionGroup[]; + sessions: Map; +} + +export interface SessionGroupCatalog { + groups: SessionGroup[]; + colorOptions: SessionGroupColor[]; +} + +export interface CreateSessionGroupInput { + name: string; + color: SessionGroupColor; +} + +export interface UpdateSessionGroupInput { + name?: string; + color?: SessionGroupColor; + order?: number; +} + +export interface UpdateSessionOrganizationInput { + isPinned?: boolean; + groupId?: string | null; +} + +interface SessionOrganizationStoreV1 { + schemaVersion: 1; + groups: SessionGroup[]; + sessions: Record>; +} + +export class SessionOrganizationError extends Error { + constructor( + message: string, + readonly code: string, + readonly field?: string, + ) { + super(message); + this.name = 'SessionOrganizationError'; + } +} + +const STORE_FILE = 'session-organization.v1.json'; +const SCHEMA_VERSION = 1; +const MAX_GROUP_NAME_LENGTH = 64; +const MAX_GROUPS = 200; +const FALLBACK_GROUP_COLOR: SessionGroupColor = 'blue'; +const locks = new Map>(); +const warningKeysByStorePath = new Map>(); + +function hasControlCharacter(value: string): boolean { + return [...value].some((char) => { + const codePoint = char.codePointAt(0); + return ( + codePoint !== undefined && + ((codePoint >= 0x00 && codePoint <= 0x1f) || + (codePoint >= 0x7f && codePoint <= 0x9f) || + (codePoint >= 0x200b && codePoint <= 0x200f) || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) || + codePoint === 0xfeff) + ); + }); +} + +function normalizeGroupName(name: unknown): string { + if (typeof name !== 'string') { + throw new SessionOrganizationError( + '`name` must be a string', + 'invalid_group_name', + 'name', + ); + } + const trimmed = name.trim(); + if ( + trimmed.length === 0 || + trimmed.length > MAX_GROUP_NAME_LENGTH || + hasControlCharacter(trimmed) + ) { + throw new SessionOrganizationError( + '`name` must be 1-64 characters and contain no control characters', + 'invalid_group_name', + 'name', + ); + } + return trimmed; +} + +function assertGroupColor(color: unknown): asserts color is SessionGroupColor { + if (typeof color !== 'string' || !isSupportedGroupColor(color)) { + throw new SessionOrganizationError( + '`color` must be one of the supported color options', + 'invalid_group_color', + 'color', + ); + } +} + +function normalizeOrder(order: unknown): number { + if ( + typeof order !== 'number' || + !Number.isFinite(order) || + !Number.isSafeInteger(order) + ) { + throw new SessionOrganizationError( + '`order` must be a safe integer', + 'invalid_group_order', + 'order', + ); + } + return order; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isSupportedGroupColor(color: string): color is SessionGroupColor { + return GROUP_COLOR_OPTIONS.includes(color as SessionGroupColor); +} + +function emptyStore(): SessionOrganizationStoreV1 { + return { + schemaVersion: SCHEMA_VERSION, + groups: [], + sessions: Object.create(null) as Record< + string, + Partial + >, + }; +} + +function groupNameKey(name: string): string { + return name.trim().toLowerCase(); +} + +function viewOrganization( + organization: Partial | undefined, +): SessionOrganizationView { + const groupId = + typeof organization?.groupId === 'string' ? organization.groupId : null; + const pinnedAt = + typeof organization?.pinnedAt === 'string' + ? organization.pinnedAt + : undefined; + const updatedAt = + typeof organization?.updatedAt === 'string' + ? organization.updatedAt + : new Date(0).toISOString(); + return { + groupId, + ...(pinnedAt !== undefined ? { pinnedAt } : {}), + updatedAt, + isPinned: pinnedAt !== undefined, + }; +} + +function serializeOrganization( + organization: SessionOrganizationView, +): SessionOrganization { + return { + groupId: organization.groupId, + ...(organization.pinnedAt !== undefined + ? { pinnedAt: organization.pinnedAt } + : {}), + updatedAt: organization.updatedAt, + }; +} + +export class SessionOrganizationService { + private readonly storage: Storage; + private readFailed = false; + + constructor( + cwd: string, + private readonly onWarning?: (message: string) => void, + ) { + this.storage = new Storage(cwd); + } + + getStorePath(): string { + return path.join(this.storage.getProjectDir(), STORE_FILE); + } + + async listGroups(): Promise { + const store = await this.readStore(); + return { + groups: this.sortGroups(store.groups), + colorOptions: [...GROUP_COLOR_OPTIONS], + }; + } + + async readSnapshot(): Promise { + const store = await this.readStore(); + const validGroupIds = new Set(store.groups.map((group) => group.id)); + const sessions = new Map(); + for (const [sessionId, raw] of Object.entries(store.sessions)) { + const view = viewOrganization(raw); + if (view.groupId !== null && !validGroupIds.has(view.groupId)) { + this.warnOrphanedGroupReference(sessionId, view.groupId); + view.groupId = null; + } + sessions.set(sessionId, view); + } + return { groups: this.sortGroups(store.groups), sessions }; + } + + async createGroup(input: CreateSessionGroupInput): Promise { + const name = normalizeGroupName(input.name); + assertGroupColor(input.color); + return this.withStoreLock(async () => { + const store = await this.readStore(); + this.assertGroupNameAvailable(store.groups, name); + if (store.groups.length >= MAX_GROUPS) { + throw new SessionOrganizationError( + `Maximum number of groups (${MAX_GROUPS}) reached`, + 'group_limit_reached', + ); + } + const now = new Date().toISOString(); + const group: SessionGroup = { + id: randomUUID(), + name, + color: input.color, + order: Math.min( + store.groups.reduce( + (maxOrder, existing) => Math.max(maxOrder, existing.order), + -1, + ) + 1, + Number.MAX_SAFE_INTEGER, + ), + createdAt: now, + updatedAt: now, + }; + store.groups.push(group); + await this.writeStore(store); + return group; + }); + } + + async updateGroup( + groupId: string, + input: UpdateSessionGroupInput, + ): Promise { + return this.withStoreLock(async () => { + const store = await this.readStore(); + const group = store.groups.find((candidate) => candidate.id === groupId); + if (!group) { + throw new SessionOrganizationError( + `Group not found: ${groupId}`, + 'group_not_found', + 'groupId', + ); + } + if (input.name !== undefined) { + const name = normalizeGroupName(input.name); + this.assertGroupNameAvailable(store.groups, name, groupId); + group.name = name; + } + if (input.color !== undefined) { + assertGroupColor(input.color); + group.color = input.color; + } + if (input.order !== undefined) { + group.order = normalizeOrder(input.order); + } + group.updatedAt = new Date().toISOString(); + await this.writeStore(store); + return group; + }); + } + + async deleteGroup(groupId: string): Promise { + return this.withStoreLock(async () => { + const store = await this.readStore(); + const before = store.groups.length; + store.groups = store.groups.filter((group) => group.id !== groupId); + if (store.groups.length === before) { + return false; + } + const now = new Date().toISOString(); + for (const session of Object.values(store.sessions)) { + if (session.groupId === groupId) { + session.groupId = null; + session.updatedAt = now; + } + } + await this.writeStore(store); + return true; + }); + } + + async updateSessionOrganization( + sessionId: string, + input: UpdateSessionOrganizationInput, + ): Promise { + const hasUpdate = + input.groupId !== undefined || input.isPinned !== undefined; + return this.withStoreLock(async () => { + const store = await this.readStore(); + const current = viewOrganization(store.sessions[sessionId]); + if (!hasUpdate) { + return current; + } + const now = new Date().toISOString(); + if (input.groupId !== undefined) { + if ( + input.groupId !== null && + !store.groups.some((group) => group.id === input.groupId) + ) { + throw new SessionOrganizationError( + `Group not found: ${input.groupId}`, + 'group_not_found', + 'groupId', + ); + } + current.groupId = input.groupId; + } + if (input.isPinned !== undefined) { + if (input.isPinned) { + current.pinnedAt = current.pinnedAt ?? now; + } else { + delete current.pinnedAt; + } + } + current.updatedAt = now; + store.sessions[sessionId] = serializeOrganization(current); + await this.writeStore(store); + return viewOrganization(store.sessions[sessionId]); + }); + } + + async removeSession(sessionId: string): Promise { + await this.withStoreLock(async () => { + const store = await this.readStore(); + if (!Object.prototype.hasOwnProperty.call(store.sessions, sessionId)) { + return; + } + delete store.sessions[sessionId]; + await this.writeStore(store); + }); + } + + async removeSessions(sessionIds: string[]): Promise { + const uniqueSessionIds = [...new Set(sessionIds)]; + if (uniqueSessionIds.length === 0) return; + + await this.withStoreLock(async () => { + const store = await this.readStore(); + let changed = false; + for (const sessionId of uniqueSessionIds) { + if (Object.prototype.hasOwnProperty.call(store.sessions, sessionId)) { + delete store.sessions[sessionId]; + changed = true; + } + } + if (changed) { + await this.writeStore(store); + } + }); + } + + private async withStoreLock(work: () => Promise): Promise { + const storePath = this.getStorePath(); + const previous = locks.get(storePath) ?? Promise.resolve(); + const next = previous.then(work, work); + const lock = next + .catch(() => undefined) + .finally(() => { + if (locks.get(storePath) === lock) { + locks.delete(storePath); + } + }); + locks.set(storePath, lock); + return next; + } + + private async readStore(): Promise { + try { + const raw = JSON.parse( + await fs.readFile(this.getStorePath(), 'utf8'), + ) as unknown; + if (!isPlainRecord(raw)) { + return this.handleUnreadableStore('store root is not an object'); + } + if (raw['schemaVersion'] !== SCHEMA_VERSION) { + return this.handleUnreadableStore( + `store schema is unsupported (found schemaVersion ${String( + raw['schemaVersion'], + )}, expected ${SCHEMA_VERSION})`, + ); + } + const groups: SessionGroup[] = []; + if (Array.isArray(raw['groups'])) { + for (const rawGroup of raw['groups']) { + const group = this.normalizeSessionGroup(rawGroup); + if (group !== undefined) { + groups.push(group); + } + } + } + const sessions = isPlainRecord(raw['sessions']) ? raw['sessions'] : {}; + const normalizedSessions = Object.create(null) as Record< + string, + Partial + >; + for (const [sessionId, organization] of Object.entries(sessions)) { + if (isPlainRecord(organization)) { + normalizedSessions[sessionId] = { + groupId: + typeof organization['groupId'] === 'string' + ? organization['groupId'] + : null, + ...(typeof organization['pinnedAt'] === 'string' + ? { pinnedAt: organization['pinnedAt'] } + : {}), + updatedAt: + typeof organization['updatedAt'] === 'string' + ? organization['updatedAt'] + : new Date(0).toISOString(), + }; + } else { + this.warnMalformedSessionEntry(sessionId); + } + } + this.readFailed = false; + return { + schemaVersion: SCHEMA_VERSION, + groups: this.dedupeGroups(groups), + sessions: normalizedSessions, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.readFailed = false; + return emptyStore(); + } + return this.handleUnreadableStore( + error instanceof Error ? error.message : String(error), + ); + } + } + + private handleUnreadableStore(reason: string): SessionOrganizationStoreV1 { + this.readFailed = true; + this.warnOnce( + `unreadable-store:${reason}`, + `Failed to read session organization store at ${this.getStorePath()}: ${reason}`, + ); + return emptyStore(); + } + + private async writeStore(store: SessionOrganizationStoreV1): Promise { + await fs.mkdir(path.dirname(this.getStorePath()), { recursive: true }); + if (this.readFailed) { + throw new SessionOrganizationError( + `Cannot update session organization store because it could not be read: ${this.getStorePath()}. Delete the file to reset session organization (group/pin data will be lost), or restore it from backup.`, + 'session_organization_store_unreadable', + ); + } + await atomicWriteJSON(this.getStorePath(), { + schemaVersion: SCHEMA_VERSION, + groups: this.sortGroups(store.groups), + sessions: store.sessions, + }); + this.readFailed = false; + } + + private assertGroupNameAvailable( + groups: SessionGroup[], + name: string, + exceptGroupId?: string, + ): void { + const key = groupNameKey(name); + if ( + groups.some( + (group) => + group.id !== exceptGroupId && groupNameKey(group.name) === key, + ) + ) { + throw new SessionOrganizationError( + `Group name already exists: ${name}`, + 'group_name_conflict', + 'name', + ); + } + } + + private sortGroups(groups: SessionGroup[]): SessionGroup[] { + return [...groups].sort((a, b) => { + const byOrder = a.order - b.order; + if (byOrder !== 0) return byOrder; + if (a.name !== b.name) return a.name < b.name ? -1 : 1; + if (a.id !== b.id) return a.id < b.id ? -1 : 1; + return 0; + }); + } + + private normalizeSessionGroup(value: unknown): SessionGroup | undefined { + if ( + !isPlainRecord(value) || + typeof value['id'] !== 'string' || + typeof value['name'] !== 'string' || + typeof value['color'] !== 'string' || + typeof value['order'] !== 'number' || + !Number.isFinite(value['order']) || + typeof value['createdAt'] !== 'string' || + typeof value['updatedAt'] !== 'string' + ) { + this.warnMalformedGroupEntry(value); + return undefined; + } + const color = isSupportedGroupColor(value['color']) + ? value['color'] + : FALLBACK_GROUP_COLOR; + if (color !== value['color']) { + this.warnOnce( + `unknown-group-color:${value['id']}\0${value['color']}`, + `Session group "${value['name']}" (id: ${value['id']}) uses unsupported color "${value['color']}"; using "${FALLBACK_GROUP_COLOR}"`, + ); + } + return { + id: value['id'], + name: value['name'], + color, + order: value['order'], + createdAt: value['createdAt'], + updatedAt: value['updatedAt'], + }; + } + + private dedupeGroups(groups: SessionGroup[]): SessionGroup[] { + const seen = new Set(); + const deduped: SessionGroup[] = []; + for (const group of groups) { + const key = groupNameKey(group.name); + if (seen.has(key)) { + this.onWarning?.( + `Dropped duplicate session group by name: "${group.name}" (id: ${group.id})`, + ); + continue; + } + seen.add(key); + deduped.push(group); + } + return deduped; + } + + private warnOrphanedGroupReference(sessionId: string, groupId: string): void { + this.warnOnce( + `orphaned-group:${sessionId}\0${groupId}`, + `Dropped orphaned session group reference: session ${sessionId} references missing group ${groupId}`, + ); + } + + private warnMalformedSessionEntry(sessionId: string): void { + this.warnOnce( + `malformed-session:${sessionId}`, + `Dropped malformed session organization entry: ${sessionId}`, + ); + } + + private warnMalformedGroupEntry(value: unknown): void { + const id = + isPlainRecord(value) && typeof value['id'] === 'string' + ? value['id'] + : ''; + this.warnOnce( + `malformed-group:${id}`, + `Dropped malformed session group entry: ${id}`, + ); + } + + private warnOnce(key: string, message: string): void { + const storePath = this.getStorePath(); + let warningKeys = warningKeysByStorePath.get(storePath); + if (warningKeys === undefined) { + warningKeys = new Set(); + warningKeysByStorePath.set(storePath, warningKeys); + } + if (warningKeys.has(key)) return; + warningKeys.add(key); + this.onWarning?.(message); + } +} diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 4253d53af4..fc8d7deea7 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -25,6 +25,7 @@ import { getResumeTokenCounts, type ConversationRecord, } from './sessionService.js'; +import { SessionOrganizationService } from './session-organization-service.js'; import { CompressionStatus } from '../core/turn.js'; import type { ChatRecord } from './chatRecordingService.js'; import * as jsonl from '../utils/jsonl-utils.js'; @@ -909,6 +910,28 @@ describe('SessionService', () => { expect(unlinkSyncSpy).toHaveBeenCalled(); }); + it('should clear session organization when removing a session', async () => { + const warnings: string[] = []; + sessionService = new SessionService('/test/project/root', { + onWarning: (message) => warnings.push(message), + }); + const removeOrganizationSpy = vi + .spyOn(SessionOrganizationService.prototype, 'removeSession') + .mockImplementation(function (this: { + onWarning?: (message: string) => void; + }) { + this.onWarning?.('sidecar warning'); + return Promise.resolve(); + }); + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.removeSession(sessionIdA); + + expect(result).toBe(true); + expect(removeOrganizationSpy).toHaveBeenCalledWith(sessionIdA); + expect(warnings).toEqual(['sidecar warning']); + }); + it('should return false when session does not exist', async () => { vi.mocked(jsonl.readLines).mockResolvedValue([]); @@ -1383,6 +1406,9 @@ describe('SessionService', () => { describe('removeSessions', () => { it('should remove multiple sessions and report each outcome', async () => { + const removeOrganizationsSpy = vi + .spyOn(SessionOrganizationService.prototype, 'removeSessions') + .mockResolvedValue(); // recordA1 belongs to current project; recordB1 also; the third id // never has a backing record (notFound). vi.mocked(jsonl.readLines).mockImplementation( @@ -1408,6 +1434,11 @@ describe('SessionService', () => { expect(result.notFound).toEqual([sessionIdC]); expect(result.errors).toEqual([]); expect(unlinkSyncSpy).toHaveBeenCalledTimes(2); + expect(removeOrganizationsSpy).toHaveBeenCalledTimes(1); + expect(removeOrganizationsSpy).toHaveBeenCalledWith([ + sessionIdA, + sessionIdB, + ]); }); it('should de-duplicate input ids', async () => { diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index d9404b750a..43d67b115a 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -35,6 +35,7 @@ import { readLastJsonStringFieldsSync, } from '../utils/sessionStorageUtils.js'; import { getUsageOutputTokenCountForPromptEstimate } from './tokenEstimation.js'; +import { SessionOrganizationService } from './session-organization-service.js'; const debugLogger = createDebugLogger('SESSION'); @@ -409,6 +410,35 @@ export class SessionService { } } + private async removeSessionOrganization(sessionId: string): Promise { + try { + await new SessionOrganizationService(this.projectRoot, (message) => { + this.warn(message); + }).removeSession(sessionId); + } catch (error) { + this.warn( + `removeSession: failed to clear session organization for ${sessionId}: ${error}`, + ); + } + } + + private async removeSessionOrganizations( + sessionIds: string[], + ): Promise { + if (sessionIds.length === 0) return; + try { + await new SessionOrganizationService(this.projectRoot, (message) => { + this.warn(message); + }).removeSessions(sessionIds); + } catch (error) { + this.warn( + `removeSessions: failed to clear session organization for ${sessionIds.join( + ', ', + )}: ${error}`, + ); + } + } + private moveOptionalFile(sourcePath: string, targetPath: string): boolean { if (!fs.existsSync(sourcePath)) { return false; @@ -1031,6 +1061,14 @@ export class SessionService { * @returns true if removed, false if not found */ async removeSession(sessionId: string): Promise { + const removed = await this.removeSessionFiles(sessionId); + if (removed) { + await this.removeSessionOrganization(sessionId); + } + return removed; + } + + private async removeSessionFiles(sessionId: string): Promise { if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { return false; } @@ -1220,7 +1258,7 @@ export class SessionService { const uniqueSessionIds = [...new Set(sessionIds)]; const results = await Promise.allSettled( - uniqueSessionIds.map((sessionId) => this.removeSession(sessionId)), + uniqueSessionIds.map((sessionId) => this.removeSessionFiles(sessionId)), ); for (let i = 0; i < results.length; i++) { @@ -1239,6 +1277,8 @@ export class SessionService { } } + await this.removeSessionOrganizations(removed); + return { removed, notFound, errors }; } diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 56d9d4f1fb..bc353457ab 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -36,7 +36,15 @@ import type { DaemonSessionArchiveState, DaemonSessionExportFormat, DaemonSessionExportResult, + DaemonSessionGroup, + DaemonSessionGroupCatalog, + DaemonSessionGroupInput, + DaemonSessionGroupUpdate, DaemonSessionLspStatus, + DaemonSessionListPage, + DaemonSessionListPageOptions, + DaemonSessionOrganizationResult, + DaemonSessionOrganizationUpdate, DaemonSessionSummary, DaemonSessionSupportedCommandsStatus, DaemonSessionStatsStatus, @@ -198,6 +206,7 @@ const DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5; const MCP_RESTART_DEFAULT_TIMEOUT_MS = MCP_RESTART_SERVER_DEADLINE_MS + MCP_RESTART_CLIENT_HEADROOM_MS; const CLIENT_ID_HEADER = 'X-Qwen-Client-Id'; +const urlEncode = encodeURIComponent; function normalizePermissionRuleInput(rule: string): string { const trimmed = rule.trim(); @@ -710,7 +719,7 @@ export class DaemonClient { serverName: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/mcp/${encodeURIComponent(serverName)}/tools`, + `${this.baseUrl}/workspace/mcp/${urlEncode(serverName)}/tools`, { headers: this.headers() }, async (res) => { if (!res.ok) { @@ -725,7 +734,7 @@ export class DaemonClient { serverName: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/mcp/${encodeURIComponent(serverName)}/resources`, + `${this.baseUrl}/workspace/mcp/${urlEncode(serverName)}/resources`, { headers: this.headers() }, async (res) => { if (!res.ok) { @@ -778,7 +787,7 @@ export class DaemonClient { async sessionHooks(sessionId: string): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/hooks`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/hooks`, { headers: this.headers() }, async (res) => { if (!res.ok) @@ -810,7 +819,7 @@ export class DaemonClient { operationId: string, ): Promise { return await this.jsonRequest( - `/workspace/extensions/operations/${encodeURIComponent(operationId)}`, + `/workspace/extensions/operations/${urlEncode(operationId)}`, 'GET /workspace/extensions/operations/:operationId', ); } @@ -841,7 +850,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.jsonRequest( - `/workspace/extensions/${encodeURIComponent(name)}/enable`, + `/workspace/extensions/${urlEncode(name)}/enable`, 'POST /workspace/extensions/:name/enable', { method: 'POST', body: params, clientId }, ); @@ -853,7 +862,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.jsonRequest( - `/workspace/extensions/${encodeURIComponent(name)}/disable`, + `/workspace/extensions/${urlEncode(name)}/disable`, 'POST /workspace/extensions/:name/disable', { method: 'POST', body: params, clientId }, ); @@ -864,7 +873,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.jsonRequest( - `/workspace/extensions/${encodeURIComponent(name)}/update`, + `/workspace/extensions/${urlEncode(name)}/update`, 'POST /workspace/extensions/:name/update', { method: 'POST', body: {}, clientId }, ); @@ -875,7 +884,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.jsonRequest( - `/workspace/extensions/${encodeURIComponent(name)}`, + `/workspace/extensions/${urlEncode(name)}`, 'DELETE /workspace/extensions/:name', { method: 'DELETE', clientId }, ); @@ -1091,7 +1100,7 @@ export class DaemonClient { opts?: { clientId?: string }, ): Promise { return await this.jsonRequest( - `${WORKSPACE_MEMORY_REMEMBER_PATH}/${encodeURIComponent(taskId)}`, + `${WORKSPACE_MEMORY_REMEMBER_PATH}/${urlEncode(taskId)}`, `GET ${WORKSPACE_MEMORY_REMEMBER_PATH}/:taskId`, { clientId: opts?.clientId }, ); @@ -1117,7 +1126,7 @@ export class DaemonClient { opts?: { clientId?: string }, ): Promise { return await this.jsonRequest( - `${WORKSPACE_MEMORY_FORGET_PATH}/${encodeURIComponent(taskId)}`, + `${WORKSPACE_MEMORY_FORGET_PATH}/${urlEncode(taskId)}`, `GET ${WORKSPACE_MEMORY_FORGET_PATH}/:taskId`, { clientId: opts?.clientId }, ); @@ -1142,7 +1151,7 @@ export class DaemonClient { opts?: { clientId?: string }, ): Promise { return await this.jsonRequest( - `${WORKSPACE_MEMORY_DREAM_PATH}/${encodeURIComponent(taskId)}`, + `${WORKSPACE_MEMORY_DREAM_PATH}/${urlEncode(taskId)}`, `GET ${WORKSPACE_MEMORY_DREAM_PATH}/:taskId`, { clientId: opts?.clientId }, ); @@ -1213,7 +1222,7 @@ export class DaemonClient { agentType: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/agents/${encodeURIComponent(agentType)}`, + `${this.baseUrl}/workspace/agents/${urlEncode(agentType)}`, { headers: this.headers() }, async (res) => { if (!res.ok) { @@ -1242,8 +1251,8 @@ export class DaemonClient { clientId?: string, ): Promise { const url = opts.scope - ? `${this.baseUrl}/workspace/agents/${encodeURIComponent(agentType)}?scope=${encodeURIComponent(opts.scope)}` - : `${this.baseUrl}/workspace/agents/${encodeURIComponent(agentType)}`; + ? `${this.baseUrl}/workspace/agents/${urlEncode(agentType)}?scope=${urlEncode(opts.scope)}` + : `${this.baseUrl}/workspace/agents/${urlEncode(agentType)}`; return await this.fetchWithTimeout( url, { @@ -1275,8 +1284,8 @@ export class DaemonClient { clientId?: string, ): Promise { const url = opts.scope - ? `${this.baseUrl}/workspace/agents/${encodeURIComponent(agentType)}?scope=${encodeURIComponent(opts.scope)}` - : `${this.baseUrl}/workspace/agents/${encodeURIComponent(agentType)}`; + ? `${this.baseUrl}/workspace/agents/${urlEncode(agentType)}?scope=${urlEncode(opts.scope)}` + : `${this.baseUrl}/workspace/agents/${urlEncode(agentType)}`; return await this.fetchWithTimeout( url, { @@ -1409,6 +1418,14 @@ export class DaemonClient { archiveState?: DaemonSessionArchiveState; }, ): Promise { + const page = await this.listWorkspaceSessionsPage(workspaceCwd, options); + return page.sessions; + } + + async listWorkspaceSessionsPage( + workspaceCwd: string, + options?: DaemonSessionListPageOptions, + ): Promise { const requestedPageSize = options?.pageSize ?? DEFAULT_SESSION_LIST_PAGE_SIZE; const pageSize = Math.max( @@ -1423,16 +1440,79 @@ export class DaemonClient { ), ); const query = new URLSearchParams({ size: String(pageSize) }); + if (options?.cursor !== undefined) { + query.set('cursor', options.cursor); + } if (options?.archiveState !== undefined) { query.set('archiveState', options.archiveState); } - const body = await this.jsonRequest<{ - sessions: DaemonSessionSummary[]; - }>( - `/workspace/${encodeURIComponent(workspaceCwd)}/sessions?${query.toString()}`, + if (options?.view !== undefined) { + query.set('view', options.view); + } + if (options?.group !== undefined) { + query.set('group', options.group); + } + return await this.jsonRequest( + `/workspace/${urlEncode(workspaceCwd)}/sessions?${query.toString()}`, 'GET /workspace/sessions', ); - return body.sessions; + } + + async listSessionGroups( + workspaceCwd: string, + ): Promise { + return await this.jsonRequest( + `/workspace/${urlEncode(workspaceCwd)}/session-groups`, + 'GET /workspace/session-groups', + ); + } + + async createSessionGroup( + workspaceCwd: string, + input: DaemonSessionGroupInput, + ): Promise { + const body = await this.jsonRequest<{ group: DaemonSessionGroup }>( + `/workspace/${urlEncode(workspaceCwd)}/session-groups`, + 'POST /workspace/session-groups', + { method: 'POST', body: input }, + ); + return body.group; + } + + async updateSessionGroup( + workspaceCwd: string, + groupId: string, + update: DaemonSessionGroupUpdate, + ): Promise { + const body = await this.jsonRequest<{ group: DaemonSessionGroup }>( + `/workspace/${urlEncode(workspaceCwd)}/session-groups/${urlEncode(groupId)}`, + 'PATCH /workspace/session-groups/:groupId', + { method: 'PATCH', body: update }, + ); + return body.group; + } + + async deleteSessionGroup( + workspaceCwd: string, + groupId: string, + ): Promise<{ deleted: boolean }> { + return await this.jsonRequest<{ deleted: boolean }>( + `/workspace/${urlEncode(workspaceCwd)}/session-groups/${urlEncode(groupId)}`, + 'DELETE /workspace/session-groups/:groupId', + { method: 'DELETE' }, + ); + } + + async updateSessionOrganization( + sessionId: string, + update: DaemonSessionOrganizationUpdate, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/session/${urlEncode(sessionId)}/organization`, + 'PATCH /session/:id/organization', + { method: 'PATCH', body: update, clientId }, + ); } async loadSession( @@ -1451,11 +1531,9 @@ export class DaemonClient { } = {}, ): Promise { const format = opts.format ?? 'html'; - const query = opts.format - ? `?format=${encodeURIComponent(opts.format)}` - : ''; + const query = opts.format ? `?format=${urlEncode(opts.format)}` : ''; return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/export${query}`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/export${query}`, { headers: this.headers({}, opts.clientId) }, async (res) => { if (!res.ok) { @@ -1493,7 +1571,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/branch`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/branch`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -1514,7 +1592,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/fork`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/fork`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -1534,7 +1612,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/context`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/context`, { headers: this.headers({}, clientId) }, async (res) => { if (!res.ok) { @@ -1554,7 +1632,7 @@ export class DaemonClient { if (opts.detail === true) params.set('detail', 'true'); const query = params.toString(); return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/context-usage${ + `${this.baseUrl}/session/${urlEncode(sessionId)}/context-usage${ query ? `?${query}` : '' }`, { headers: this.headers({}, clientId) }, @@ -1572,7 +1650,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/supported-commands`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/supported-commands`, { headers: this.headers({}, clientId) }, async (res) => { if (!res.ok) { @@ -1591,7 +1669,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/tasks`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks`, { headers: this.headers({}, clientId) }, async (res) => { if (!res.ok) { @@ -1607,7 +1685,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/lsp`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/lsp`, { headers: this.headers({}, clientId) }, async (res) => { if (!res.ok) { @@ -1625,7 +1703,7 @@ export class DaemonClient { clientId?: string, ): Promise<{ cancelled: boolean }> { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}/cancel`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks/${urlEncode(taskId)}/cancel`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -1648,7 +1726,7 @@ export class DaemonClient { clientId?: string, ): Promise<{ cleared: boolean; condition?: string }> { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/goal/clear`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/goal/clear`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -1668,7 +1746,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/stats`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/stats`, { headers: this.headers({}, clientId) }, async (res) => { if (!res.ok) { @@ -1693,7 +1771,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/${action}`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/${action}`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -1729,7 +1807,7 @@ export class DaemonClient { opts?: { persist?: boolean; clientId?: string }, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/approval-mode`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/approval-mode`, { method: 'POST', headers: this.headers( @@ -1754,7 +1832,7 @@ export class DaemonClient { sessionId: string, ): Promise<{ snapshots: DaemonRewindSnapshotInfo[] }> { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/rewind/snapshots`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/rewind/snapshots`, { method: 'GET', headers: this.headers() }, async (res) => { if (!res.ok) { @@ -1774,7 +1852,7 @@ export class DaemonClient { opts?: { clientId?: string; rewindFiles?: boolean }, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/rewind`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/rewind`, { method: 'POST', headers: this.headers( @@ -1828,7 +1906,7 @@ export class DaemonClient { opts?: { signal?: AbortSignal; clientId?: string }, ): Promise { const res = await this.transport.fetch( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/recap`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/recap`, { method: 'POST', headers: this.headers( @@ -1849,7 +1927,7 @@ export class DaemonClient { opts?: { signal?: AbortSignal; clientId?: string }, ): Promise { const res = await this.transport.fetch( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/btw`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/btw`, { method: 'POST', headers: this.headers( @@ -1880,7 +1958,7 @@ export class DaemonClient { // The helper composes any caller `signal` (the turn-scoped abort) WITH its // timeout controller, so the mid-turn-settle abort still propagates. return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/mid-turn-message`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/mid-turn-message`, { method: 'POST', headers: this.headers( @@ -1913,7 +1991,7 @@ export class DaemonClient { opts?: { clientId?: string }, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/pending-prompts`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/pending-prompts`, { method: 'GET', headers: this.headers({}, opts?.clientId), @@ -1939,7 +2017,7 @@ export class DaemonClient { opts?: { clientId?: string }, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/pending-prompts/${encodeURIComponent(promptId)}`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/pending-prompts/${urlEncode(promptId)}`, { method: 'DELETE', headers: this.headers({}, opts?.clientId), @@ -1969,7 +2047,7 @@ export class DaemonClient { opts?: { signal?: AbortSignal; clientId?: string }, ): Promise { const res = await this.transport.fetch( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/shell`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/shell`, { method: 'POST', headers: this.headers( @@ -2005,7 +2083,7 @@ export class DaemonClient { opts?: { clientId?: string }, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/tools/${encodeURIComponent(toolName)}/enable`, + `${this.baseUrl}/workspace/tools/${urlEncode(toolName)}/enable`, { method: 'POST', headers: this.headers( @@ -2257,9 +2335,9 @@ export class DaemonClient { const query = opts?.entryIndex === undefined ? '' - : `?entryIndex=${encodeURIComponent(String(opts.entryIndex))}`; + : `?entryIndex=${urlEncode(String(opts.entryIndex))}`; return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/mcp/${encodeURIComponent(serverName)}/restart${query}`, + `${this.baseUrl}/workspace/mcp/${urlEncode(serverName)}/restart${query}`, { method: 'POST', headers: this.headers( @@ -2311,7 +2389,7 @@ export class DaemonClient { opts?: { clientId?: string; timeoutMs?: number }, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/mcp/${encodeURIComponent(serverName)}/${encodeURIComponent(action)}`, + `${this.baseUrl}/workspace/mcp/${urlEncode(serverName)}/${urlEncode(action)}`, { method: 'POST', headers: this.headers( @@ -2378,7 +2456,7 @@ export class DaemonClient { opts?: { clientId?: string; timeoutMs?: number }, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/mcp/servers/${encodeURIComponent(name)}`, + `${this.baseUrl}/workspace/mcp/servers/${urlEncode(name)}`, { method: 'DELETE', headers: this.headers({}, opts?.clientId), @@ -2459,7 +2537,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/model`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/model`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -2480,7 +2558,7 @@ export class DaemonClient { opts?: { syncOutputLanguage?: boolean; clientId?: string }, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/language`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/language`, { method: 'POST', headers: this.headers( @@ -2523,7 +2601,7 @@ export class DaemonClient { let releaseOnExit = true; try { const res = await this.transport.fetch( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/prompt`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/prompt`, { method: 'POST', headers: this.headers( @@ -2589,7 +2667,7 @@ export class DaemonClient { clientId?: string, ): Promise { const res = await this.transport.fetch( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/prompt`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/prompt`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -2658,7 +2736,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/heartbeat`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/heartbeat`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -2675,7 +2753,7 @@ export class DaemonClient { async cancel(sessionId: string, clientId?: string): Promise { await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/cancel`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/cancel`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -2727,7 +2805,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/permission/${encodeURIComponent(requestId)}`, + `${this.baseUrl}/permission/${urlEncode(requestId)}`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -2774,7 +2852,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/permission/${encodeURIComponent(requestId)}`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/permission/${urlEncode(requestId)}`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), @@ -2813,7 +2891,7 @@ export class DaemonClient { */ async closeSession(sessionId: string, clientId?: string): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}`, + `${this.baseUrl}/session/${urlEncode(sessionId)}`, { method: 'DELETE', headers: this.headers({}, clientId), @@ -2835,7 +2913,7 @@ export class DaemonClient { async detachSession(sessionId: string, clientId?: string): Promise { if (!clientId) return; return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/detach`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/detach`, { method: 'POST', headers: this.headers({}, clientId), @@ -2968,7 +3046,7 @@ export class DaemonClient { // that runs only after the body is already settled (or the // daemon-side `fetchTimeoutMs` fires, which can be 30s+). return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/auth/device-flow/${encodeURIComponent(deviceFlowId)}`, + `${this.baseUrl}/workspace/auth/device-flow/${urlEncode(deviceFlowId)}`, { headers: this.headers({}, opts.clientId), signal: opts.signal }, async (res) => { if (!res.ok) { @@ -2992,7 +3070,7 @@ export class DaemonClient { opts: { clientId?: string } = {}, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/workspace/auth/device-flow/${encodeURIComponent(deviceFlowId)}`, + `${this.baseUrl}/workspace/auth/device-flow/${urlEncode(deviceFlowId)}`, { method: 'DELETE', headers: this.headers({}, opts.clientId), @@ -3080,7 +3158,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.jsonRequest( - `/session/${encodeURIComponent(sessionId)}/artifacts`, + `/session/${urlEncode(sessionId)}/artifacts`, 'GET /session/:id/artifacts', { clientId }, ); @@ -3092,7 +3170,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.jsonRequest( - `/session/${encodeURIComponent(sessionId)}/artifacts`, + `/session/${urlEncode(sessionId)}/artifacts`, 'POST /session/:id/artifacts', { method: 'POST', @@ -3108,7 +3186,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.jsonRequest( - `/session/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}`, + `/session/${urlEncode(sessionId)}/artifacts/${urlEncode(artifactId)}`, 'DELETE /session/:id/artifacts/:artifactId', { method: 'DELETE', @@ -3129,7 +3207,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/metadata`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/metadata`, { method: 'PATCH', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index f11ea8f8ce..18e77a69e0 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -75,6 +75,10 @@ function boolParam( return v == null || v === '' ? {} : { [name]: v === 'true' }; } +function bodyRecord(body: unknown): Record { + return isRecord(body) ? body : {}; +} + export interface RouteEntry { httpMethod: string; pattern: RegExp; @@ -113,8 +117,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: 'session/prompt', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -144,8 +148,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: 'session/load', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -156,8 +160,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: 'session/resume', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -168,9 +172,9 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: 'session/permission', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], requestId: segs[1], - ...(isRecord(body) ? body : {}), }), }, }, @@ -181,8 +185,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: 'session/permission', extractParams: (segs, body) => ({ + ...bodyRecord(body), requestId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -193,8 +197,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: 'session/set_model', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -226,8 +230,20 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/session/update_metadata', extractParams: (segs, body) => ({ + ...bodyRecord(body), + sessionId: segs[0], + }), + }, + }, + // PATCH /session/:id/organization → _qwen/session/update_organization + { + httpMethod: 'PATCH', + pattern: /^\/session\/([^/]+)\/organization$/, + mapping: { + method: '_qwen/session/update_organization', + extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -238,8 +254,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/session/heartbeat', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -259,7 +275,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/session/artifacts/add', extractParams: (segs, body) => ({ - ...(isRecord(body) ? body : {}), + ...bodyRecord(body), sessionId: segs[0], }), }, @@ -283,8 +299,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/session/recap', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -295,8 +311,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/session/btw', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -307,8 +323,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/session/shell', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -319,8 +335,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: 'session/fork', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -331,8 +347,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/session/detach', extractParams: (segs, body) => ({ + ...bodyRecord(body), sessionId: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -441,7 +457,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/init\/?$/, mapping: { method: '_qwen/workspace/init', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // GET /workspace/trust → _qwen/workspace/trust @@ -459,7 +475,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/trust\/request\/?$/, mapping: { method: '_qwen/workspace/trust/request', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // GET /workspace/permissions → _qwen/workspace/permissions @@ -477,7 +493,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/permissions\/?$/, mapping: { method: '_qwen/workspace/permissions/set', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // GET /workspace/voice → _qwen/workspace/voice @@ -495,7 +511,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/voice\/?$/, mapping: { method: '_qwen/workspace/voice/set', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // POST /workspace/setup-github → _qwen/workspace/setup-github @@ -504,7 +520,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/setup-github\/?$/, mapping: { method: '_qwen/workspace/setup-github', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // GET /workspace/tools → _qwen/workspace/tools @@ -531,7 +547,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/memory\/?$/, mapping: { method: '_qwen/workspace/memory/write', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // POST /workspace/memory/remember → _qwen/workspace/memory/remember @@ -540,7 +556,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/memory\/remember\/?$/, mapping: { method: '_qwen/workspace/memory/remember', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // GET /workspace/memory/remember/:taskId → _qwen/workspace/memory/remember/get @@ -558,7 +574,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/memory\/forget\/?$/, mapping: { method: '_qwen/workspace/memory/forget', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // GET /workspace/memory/forget/:taskId → _qwen/workspace/memory/forget/get @@ -603,7 +619,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/agents\/?$/, mapping: { method: '_qwen/workspace/agents/create', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // GET /workspace/agents/:agentType → _qwen/workspace/agents/get @@ -622,8 +638,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/workspace/agents/delete', extractParams: (segs, body) => ({ + ...bodyRecord(body), agentType: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -651,7 +667,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/mcp\/servers\/?$/, mapping: { method: '_qwen/workspace/mcp/servers/add', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // DELETE /workspace/mcp/servers/:name → _qwen/workspace/mcp/servers/remove @@ -661,8 +677,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/workspace/mcp/servers/remove', extractParams: (segs, body) => ({ + ...bodyRecord(body), name: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -672,7 +688,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/set-tool-enabled\/?$/, mapping: { method: '_qwen/workspace/set_tool_enabled', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // POST /workspace/mcp/:server/restart → _qwen/workspace/restart_mcp_server @@ -682,8 +698,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/workspace/restart_mcp_server', extractParams: (segs, body) => ({ + ...bodyRecord(body), serverName: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -702,7 +718,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/workspace\/auth\/device-flow\/?$/, mapping: { method: '_qwen/workspace/auth/device_flow/start', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // GET /workspace/auth/device-flow/:id → _qwen/workspace/auth/device_flow/get @@ -736,6 +752,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ workspaceCwd: segs[0], ...strParam(query, 'cursor'), ...strParam(query, 'archiveState'), + ...strParam(query, 'view'), + ...strParam(query, 'group'), ...(size == null || size === '' ? {} : { _meta: { size: Number(size) } }), @@ -743,6 +761,52 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ }, }, }, + // GET /workspace/:id/session-groups → _qwen/workspace/session_groups/list + { + httpMethod: 'GET', + pattern: /^\/workspace\/(.+)\/session-groups\/?$/, + mapping: { + method: '_qwen/workspace/session_groups/list', + extractParams: (segs) => ({ workspaceCwd: segs[0] }), + }, + }, + // POST /workspace/:id/session-groups → _qwen/workspace/session_groups/create + { + httpMethod: 'POST', + pattern: /^\/workspace\/(.+)\/session-groups\/?$/, + mapping: { + method: '_qwen/workspace/session_groups/create', + extractParams: (segs, body) => ({ + ...bodyRecord(body), + workspaceCwd: segs[0], + }), + }, + }, + // PATCH /workspace/:id/session-groups/:groupId → _qwen/workspace/session_groups/update + { + httpMethod: 'PATCH', + pattern: /^\/workspace\/(.+)\/session-groups\/([^/]+)\/?$/, + mapping: { + method: '_qwen/workspace/session_groups/update', + extractParams: (segs, body) => ({ + ...bodyRecord(body), + workspaceCwd: segs[0], + groupId: segs[1], + }), + }, + }, + // DELETE /workspace/:id/session-groups/:groupId → _qwen/workspace/session_groups/delete + { + httpMethod: 'DELETE', + pattern: /^\/workspace\/(.+)\/session-groups\/([^/]+)\/?$/, + mapping: { + method: '_qwen/workspace/session_groups/delete', + extractParams: (segs) => ({ + workspaceCwd: segs[0], + groupId: segs[1], + }), + }, + }, // ---- Workspace catch-all (must be AFTER all specific workspace routes) -- // Handles any workspace path not matched above (e.g., /workspace/custom/path). @@ -760,8 +824,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ mapping: { method: '_qwen/workspace', extractParams: (segs, body) => ({ + ...bodyRecord(body), path: segs[0], - ...(isRecord(body) ? body : {}), }), }, }, @@ -830,7 +894,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/file\/write\/?$/, mapping: { method: '_qwen/file/write', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // POST /file/edit → _qwen/file/edit @@ -839,7 +903,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/file\/edit\/?$/, mapping: { method: '_qwen/file/edit', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, @@ -851,7 +915,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/sessions\/delete\/?$/, mapping: { method: '_qwen/sessions/delete', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // POST /sessions/archive → _qwen/sessions/archive @@ -860,7 +924,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/sessions\/archive\/?$/, mapping: { method: '_qwen/sessions/archive', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, // POST /sessions/unarchive → _qwen/sessions/unarchive @@ -869,7 +933,7 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/sessions\/unarchive\/?$/, mapping: { method: '_qwen/sessions/unarchive', - extractParams: (_s, body) => (isRecord(body) ? body : {}), + extractParams: (_s, body) => bodyRecord(body), }, }, ]; diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 0c2ce2f3f0..8f0cab6645 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -410,6 +410,17 @@ export type { DaemonSessionProcessTaskLifecycleStatus, DaemonSessionContextUsage, DaemonSessionContextUsageStatus, + DaemonSessionGroup, + DaemonSessionGroupCatalog, + DaemonSessionGroupColor, + DaemonSessionGroupFilter, + DaemonSessionGroupInput, + DaemonSessionGroupUpdate, + DaemonSessionListPage, + DaemonSessionListPageOptions, + DaemonSessionListView, + DaemonSessionOrganizationResult, + DaemonSessionOrganizationUpdate, DaemonSessionState, DaemonSessionSummary, DaemonSessionShellTaskStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 32dacffed1..bc54c4b6e8 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -462,6 +462,9 @@ export interface DaemonSessionSummary { clientCount?: number; hasActivePrompt?: boolean; isArchived?: boolean; + isPinned?: boolean; + pinnedAt?: string; + groupId?: string | null; } export type DaemonSessionExportFormat = 'html' | 'md' | 'json' | 'jsonl'; @@ -475,6 +478,75 @@ export interface DaemonSessionExportResult { export type DaemonSessionArchiveState = 'active' | 'archived'; +export type DaemonSessionGroupColor = + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple'; + +export interface DaemonSessionGroup { + id: string; + name: string; + color: DaemonSessionGroupColor; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface DaemonSessionGroupCatalog { + groups: DaemonSessionGroup[]; + colorOptions: DaemonSessionGroupColor[]; +} + +export interface DaemonSessionGroupInput { + name: string; + color: DaemonSessionGroupColor; +} + +export interface DaemonSessionGroupUpdate { + name?: string; + color?: DaemonSessionGroupColor; + order?: number; +} + +export interface DaemonSessionOrganizationUpdate { + isPinned?: boolean; + groupId?: string | null; +} + +export interface DaemonSessionOrganizationResult { + sessionId: string; + groupId: string | null; + isPinned: boolean; + pinnedAt?: string; + updatedAt: string; +} + +export type DaemonSessionListView = 'organized'; + +export type DaemonSessionGroupFilter = + | 'all' + | 'pinned' + | 'ungrouped' + | (string & {}); + +export interface DaemonSessionListPageOptions { + pageSize?: number; + cursor?: string; + archiveState?: DaemonSessionArchiveState; + view?: DaemonSessionListView; + group?: DaemonSessionGroupFilter; +} + +export interface DaemonSessionListPage { + sessions: DaemonSessionSummary[]; + nextCursor?: string; + liveMergeFailed?: boolean; + truncated?: boolean; +} + export interface DaemonArchiveSessionsResult { archived: string[]; alreadyArchived: string[]; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index a95b7896d6..abcba9395f 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -130,7 +130,18 @@ export { type DaemonSessionDiedData, type DaemonSessionDiedEvent, type DaemonSessionEvent, + type DaemonSessionGroup, + type DaemonSessionGroupCatalog, + type DaemonSessionGroupColor, + type DaemonSessionGroupFilter, + type DaemonSessionGroupInput, + type DaemonSessionGroupUpdate, type DaemonSessionShellTaskStatus, + type DaemonSessionListPage, + type DaemonSessionListPageOptions, + type DaemonSessionListView, + type DaemonSessionOrganizationResult, + type DaemonSessionOrganizationUpdate, type DaemonSessionSubscribeOptions, type DaemonSessionState, type DaemonSessionSummary, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index e67948fdf4..f99cfea9e1 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -812,9 +812,7 @@ describe('DaemonClient', () => { await exportClient.exportSession('s-1', { format: 'md' }); - expect(calls[0]?.url).toBe( - 'http://daemon/session/s-1/export?format=md', - ); + expect(calls[0]?.url).toBe('http://daemon/session/s-1/export?format=md'); }); it('throws DaemonHttpError on non-2xx', async () => { @@ -2167,6 +2165,151 @@ describe('DaemonClient', () => { client.listWorkspaceSessions('relative'), ).rejects.toMatchObject({ status: 400 }); }); + + it('returns the session list page envelope for organized views', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + sessions: [ + { + sessionId: 's-1', + workspaceCwd: '/work/a', + isPinned: true, + groupId: 'g-1', + }, + ], + nextCursor: 'next', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const page = await client.listWorkspaceSessionsPage('/work/a', { + view: 'organized', + group: 'g-1', + pageSize: 50, + cursor: 'cur', + }); + + expect(page.nextCursor).toBe('next'); + expect(page.sessions[0]).toMatchObject({ + sessionId: 's-1', + isPinned: true, + groupId: 'g-1', + }); + expect(calls[0]?.url).toBe( + 'http://daemon/workspace/%2Fwork%2Fa/sessions?size=50&cursor=cur&view=organized&group=g-1', + ); + }); + + it('manages session groups and session organization', async () => { + const { fetch, calls } = recordingFetch((request) => { + if ( + request.url.endsWith('/workspace/%2Fwork%2Fa/session-groups') && + request.method === 'GET' + ) { + return jsonResponse(200, { + groups: [], + colorOptions: [ + 'red', + 'orange', + 'yellow', + 'green', + 'blue', + 'purple', + ], + }); + } + if ( + request.url.endsWith('/workspace/%2Fwork%2Fa/session-groups') && + request.method === 'POST' + ) { + return jsonResponse(201, { + group: { + id: 'g-1', + name: 'Frontend', + color: 'blue', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + }); + } + if ( + request.url.endsWith('/workspace/%2Fwork%2Fa/session-groups/g-1') && + request.method === 'PATCH' + ) { + return jsonResponse(200, { + group: { + id: 'g-1', + name: 'UI', + color: 'green', + order: 1, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + }); + } + if ( + request.url.endsWith('/workspace/%2Fwork%2Fa/session-groups/g-1') && + request.method === 'DELETE' + ) { + return jsonResponse(200, { deleted: true }); + } + return jsonResponse(200, { + sessionId: 's-1', + isPinned: true, + groupId: 'g-1', + }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const catalog = await client.listSessionGroups('/work/a'); + const group = await client.createSessionGroup('/work/a', { + name: 'Frontend', + color: 'blue', + }); + const updated = await client.updateSessionGroup('/work/a', group.id, { + name: 'UI', + color: 'green', + order: 1, + }); + const deleted = await client.deleteSessionGroup('/work/a', group.id); + const organization = await client.updateSessionOrganization('s-1', { + isPinned: true, + groupId: group.id, + }); + + expect(catalog.colorOptions).toContain('purple'); + expect(group.id).toBe('g-1'); + expect(updated).toMatchObject({ id: 'g-1', name: 'UI', color: 'green' }); + expect(deleted).toEqual({ deleted: true }); + expect(organization).toEqual({ + sessionId: 's-1', + isPinned: true, + groupId: 'g-1', + }); + expect(calls[0]?.method).toBe('GET'); + expect(calls[1]?.method).toBe('POST'); + expect(JSON.parse(calls[1]!.body!)).toEqual({ + name: 'Frontend', + color: 'blue', + }); + expect(calls[2]?.url).toBe( + 'http://daemon/workspace/%2Fwork%2Fa/session-groups/g-1', + ); + expect(calls[2]?.method).toBe('PATCH'); + expect(JSON.parse(calls[2]!.body!)).toEqual({ + name: 'UI', + color: 'green', + order: 1, + }); + expect(calls[3]?.method).toBe('DELETE'); + expect(calls[4]?.url).toBe('http://daemon/session/s-1/organization'); + expect(calls[4]?.method).toBe('PATCH'); + expect(JSON.parse(calls[4]!.body!)).toEqual({ + isPinned: true, + groupId: 'g-1', + }); + }); }); describe('setSessionModel', () => { diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index d01fecc022..52c8af233c 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -209,6 +209,23 @@ describe('acpRouteTable – matchRoute', () => { }); }); + it('GET /workspace/:id/sessions extracts organized view filters', () => { + const result = matchRoute('/workspace/%2Fwork%2Fa/sessions', 'GET'); + expect(result).not.toBeNull(); + const params = result!.mapping.extractParams( + result!.segments, + undefined, + 'GET', + new URLSearchParams('view=organized&group=pinned&size=25'), + ); + expect(params).toEqual({ + workspaceCwd: '/work/a', + view: 'organized', + group: 'pinned', + _meta: { size: 25 }, + }); + }); + // ---- POST /session/:id/model → session/set_model -------------------- it('POST /session/:id/model maps to session/set_model', () => { @@ -225,6 +242,81 @@ describe('acpRouteTable – matchRoute', () => { expect(result!.mapping.method).toBe('_qwen/session/update_metadata'); }); + it('PATCH /session/:id/organization maps to _qwen/session/update_organization', () => { + const result = matchRoute('/session/s6/organization', 'PATCH'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/update_organization'); + expect( + result!.mapping.extractParams( + result!.segments, + { isPinned: true, groupId: 'g-1' }, + 'PATCH', + ), + ).toEqual({ sessionId: 's6', isPinned: true, groupId: 'g-1' }); + }); + + it('keeps URL session id when organization body contains sessionId', () => { + const result = matchRoute('/session/s6/organization', 'PATCH'); + expect(result).not.toBeNull(); + expect( + result!.mapping.extractParams( + result!.segments, + { sessionId: 'other', isPinned: true }, + 'PATCH', + ), + ).toEqual({ sessionId: 's6', isPinned: true }); + }); + + it('maps session group CRUD routes to _qwen workspace methods', () => { + const list = matchRoute('/workspace/%2Fwork%2Fa/session-groups', 'GET'); + expect(list?.mapping.method).toBe('_qwen/workspace/session_groups/list'); + expect( + list!.mapping.extractParams(list!.segments, undefined, 'GET'), + ).toEqual({ workspaceCwd: '/work/a' }); + + const create = matchRoute('/workspace/%2Fwork%2Fa/session-groups', 'POST'); + expect(create?.mapping.method).toBe( + '_qwen/workspace/session_groups/create', + ); + expect( + create!.mapping.extractParams( + create!.segments, + { workspaceCwd: '/other', name: 'Frontend', color: 'blue' }, + 'POST', + ), + ).toEqual({ workspaceCwd: '/work/a', name: 'Frontend', color: 'blue' }); + + const update = matchRoute( + '/workspace/%2Fwork%2Fa/session-groups/g-1', + 'PATCH', + ); + expect(update?.mapping.method).toBe( + '_qwen/workspace/session_groups/update', + ); + expect( + update!.mapping.extractParams( + update!.segments, + { + workspaceCwd: '/other', + groupId: 'other-group', + name: 'UI', + }, + 'PATCH', + ), + ).toEqual({ workspaceCwd: '/work/a', groupId: 'g-1', name: 'UI' }); + + const remove = matchRoute( + '/workspace/%2Fwork%2Fa/session-groups/g-1', + 'DELETE', + ); + expect(remove?.mapping.method).toBe( + '_qwen/workspace/session_groups/delete', + ); + expect( + remove!.mapping.extractParams(remove!.segments, undefined, 'DELETE'), + ).toEqual({ workspaceCwd: '/work/a', groupId: 'g-1' }); + }); + it('POST /session/:id/heartbeat maps to _qwen/session/heartbeat', () => { const result = matchRoute('/session/s8/heartbeat', 'POST'); expect(result).not.toBeNull(); diff --git a/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx b/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx index 12445fb593..b96b55a5bb 100644 --- a/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx +++ b/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx @@ -211,7 +211,8 @@ describe('EchartsFullDataBlock', () => { notMerge: true, }); const renderedOption = setOption.mock.calls[0]?.[0] as - EchartsFullDataOption | undefined; + | EchartsFullDataOption + | undefined; expect(renderedOption).toEqual( expect.not.objectContaining({ title: expect.anything() }), ); diff --git a/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx b/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx index fbdbbfbd92..f18fd93867 100644 --- a/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx +++ b/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx @@ -44,7 +44,8 @@ export interface EchartsRuntime { } export type EchartsRuntimeLoader = () => - EchartsRuntime | Promise; + | EchartsRuntime + | Promise; export interface EchartsFullDataResolvedDataset { dimensions: string[]; diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 1a5ce8d5e1..58db89d3c8 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -33,10 +33,13 @@ .collapseButton, .projectRow, .sessionRow, +.sessionGroupHeader, +.sessionGroupActionButton, .retry, .iconButton, .projectIconButton, .sessionActionButton, +.groupMenuItem, .secondaryButton, .dangerButton { appearance: none; @@ -66,11 +69,20 @@ padding: 8px 4px; } +.newChatButton:disabled { + cursor: not-allowed; + opacity: 0.48; +} + .newChatButton:hover, .footerButton:hover, .projectRow:hover, .sessionRow:hover, .sessionRow:focus-visible, +.sessionGroupHeader:hover, +.sessionGroupHeader:focus-visible, +.sessionGroupActionButton:hover, +.sessionGroupActionButton:focus-visible, .collapseButton:hover, .collapseButton:focus-visible, .iconButton:hover, @@ -85,6 +97,8 @@ .footerButton:focus-visible, .projectRow:focus-visible, .sessionRow:focus-visible, +.sessionGroupHeader:focus-visible, +.sessionGroupActionButton:focus-visible, .collapseButton:focus-visible, .iconButton:focus-visible, .projectIconButton:focus-visible, @@ -194,11 +208,118 @@ min-height: 0; display: flex; flex-direction: column; - gap: 2px; + gap: 4px; overflow: auto; padding-bottom: 48px; } +.sessionGroupSection { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sessionGroupHeaderRow { + min-width: 0; + display: flex; + align-items: center; + gap: 2px; +} + +.sessionGroupHeader { + min-width: 0; + height: 32px; + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px 0 20px; + border-radius: 8px; + color: var(--muted-foreground); + font-size: 13px; + font-weight: 600; + line-height: 20px; + text-align: left; + cursor: pointer; +} + +.sessionGroupDot { + width: 8px; + height: 8px; + flex: 0 0 8px; + border-radius: 999px; +} + +.sessionGroupTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sessionGroupCount { + flex: 0 0 auto; + color: color-mix(in srgb, var(--muted-foreground) 86%, transparent); +} + +.sessionGroupChevron { + width: 14px; + height: 14px; + flex: 0 0 14px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.sessionGroupChevron svg, +.sessionGroupActionButton svg, +.sessionPinnedIndicator svg { + width: 13px; + height: 13px; + display: block; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.sessionGroupHeaderActions { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 120ms ease; +} + +.sessionGroupHeaderRow:hover .sessionGroupHeaderActions, +.sessionGroupHeaderRow:focus-within .sessionGroupHeaderActions { + opacity: 1; +} + +.sessionGroupActionButton { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 7px; + color: var(--muted-foreground); + cursor: pointer; +} + +.sessionGroupActionButton:disabled { + cursor: not-allowed; + opacity: 0.38; +} + +.sessionGroupList { + display: flex; + flex-direction: column; + gap: 2px; +} + .sessionRow { position: relative; flex: 0 0 auto; @@ -211,11 +332,27 @@ cursor: pointer; } +.groupedSessionRow { + min-height: 42px; + margin-left: 12px; + padding: 8px; + color: var(--muted-foreground); +} + +.groupedSessionRow:not(.currentSession):hover, +.groupedSessionRow:not(.currentSession):focus-visible { + color: var(--sidebar-accent-foreground); +} + .currentSession { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } +.pinnedSession:not(.currentSession) { + color: color-mix(in srgb, var(--foreground) 92%, var(--agent-blue-500)); +} + .busySession { opacity: 0.72; pointer-events: none; @@ -329,6 +466,32 @@ animation: sidebarPulse 1.6s ease-in-out infinite; } +.sessionPinMarker { + width: 12px; + height: 12px; + display: inline-flex; + color: var(--muted-foreground); +} + +.sessionPinnedIndicator { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--muted-foreground); +} + +.sessionPinMarker svg { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + .sessionMetaSlot { position: relative; width: 82px; @@ -443,6 +606,10 @@ color: var(--accent-foreground); } +.activeSessionActionButton { + color: var(--agent-blue-500); +} + .sessionActionButton:disabled { cursor: not-allowed; opacity: 0.42; @@ -478,6 +645,139 @@ box-shadow: 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 18%, transparent); } +.dialogSelect { + border: 1px solid var(--sidebar-border); + border-radius: 6px; + background: var(--background); + color: var(--foreground); + font: inherit; + font-size: 12px; + line-height: 18px; +} + +.dialogInput:focus-visible, +.dialogSelect:focus-visible { + border-color: var(--sidebar-ring); + outline: 0; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 18%, transparent); +} + +.groupMenu { + position: fixed; + z-index: 1100; + width: 240px; + max-height: min(320px, calc(100vh - 16px)); + padding: 6px; + border: 1px solid var(--sidebar-border); + border-radius: 8px; + background: var(--background); + color: var(--foreground); + overflow-y: auto; + box-shadow: + 0 10px 24px rgb(0 0 0 / 22%), + 0 2px 6px rgb(0 0 0 / 16%); +} + +.groupMenuItem { + width: 100%; + min-width: 0; + height: 34px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 10px; + border-radius: 6px; + color: var(--foreground); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 13px; + line-height: 20px; + text-align: left; + cursor: pointer; +} + +.groupMenuItem:hover, +.groupMenuItem:focus-visible, +.groupMenuItemActive { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); + outline: 0; +} + +.groupMenuName { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.groupMenuDot, +.groupMenuEmptyDot, +.groupMenuIcon { + width: 14px; + height: 14px; + flex: 0 0 14px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.groupMenuDot { + border-radius: 999px; +} + +.groupMenuEmptyDot { + border: 1px solid var(--muted-foreground); + border-radius: 999px; +} + +.groupMenuIcon svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.groupMenuCheck { + flex: 0 0 auto; + color: var(--agent-blue-500); + font-size: 13px; + font-weight: 700; +} + +.groupMenuSeparator { + height: 1px; + margin: 6px 4px; + background: var(--sidebar-border); +} + +.groupColorRed { + background: #ff5a5f; +} + +.groupColorOrange { + background: #ff8a2a; +} + +.groupColorYellow { + background: #f6c431; +} + +.groupColorGreen { + background: #69c987; +} + +.groupColorBlue { + background: var(--agent-blue-500); +} + +.groupColorPurple { + background: var(--agent-purple-600); +} + .iconButton:disabled { cursor: not-allowed; opacity: 0.38; @@ -533,6 +833,41 @@ gap: 8px; } +.groupForm { + display: flex; + flex-direction: column; + gap: 14px; + font-family: var(--font-sans, system-ui, sans-serif); +} + +.fieldStack { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--muted-foreground); + font-size: 12px; + line-height: 18px; +} + +.dialogSelect { + width: 100%; + height: 32px; + padding: 0 8px; +} + +.dialogInput { + width: 100%; + height: 32px; + padding: 0 8px; + border: 1px solid var(--sidebar-border); + border-radius: 6px; + background: var(--background); + color: var(--foreground); + font: inherit; + font-size: 13px; + line-height: 20px; +} + .secondaryButton, .dangerButton { height: 28px; diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx index ea592a58a1..a6c70044c0 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx @@ -5,10 +5,12 @@ import { createRoot, type Root } from 'react-dom/client'; const { mockConnection, + mockUseSessions, mockActive, mockArchived, renameSessionSpy, mockExportSession, + mockWorkspaceActions, } = vi.hoisted(() => { const makeStore = () => ({ sessions: [] as MockSession[], @@ -19,6 +21,15 @@ const { archiveSession: vi.fn().mockResolvedValue(true), unarchiveSession: vi.fn().mockResolvedValue(true), }); + const mockActive = makeStore(); + const mockArchived = makeStore(); + const mockExportSession = vi.fn(); + const mockUseSessions = vi.fn( + (options?: { archiveState?: 'active' | 'archived' }) => + options?.archiveState === 'archived' + ? mockArchived + : { ...mockActive, exportSession: mockExportSession }, + ); return { mockConnection: { status: 'connected', @@ -28,10 +39,21 @@ const { | { qwenCodeVersion?: string; features?: string[] } | undefined, }, - mockActive: makeStore(), - mockArchived: makeStore(), + mockUseSessions, + mockActive, + mockArchived, renameSessionSpy: vi.fn(), - mockExportSession: vi.fn(), + mockExportSession, + mockWorkspaceActions: { + listSessionGroups: vi.fn().mockResolvedValue({ + groups: [], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }), + createSessionGroup: vi.fn(), + updateSessionGroup: vi.fn(), + deleteSessionGroup: vi.fn(), + updateSessionOrganization: vi.fn(), + }, }; }); @@ -44,15 +66,16 @@ type MockSession = { clientCount?: number; hasActivePrompt?: boolean; isArchived?: boolean; + isPinned?: boolean; + groupId?: string | null; }; vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useConnection: () => mockConnection, useActions: () => ({ renameSession: renameSessionSpy }), + useWorkspaceActions: () => mockWorkspaceActions, useSessions: (options?: { archiveState?: 'active' | 'archived' }) => - options?.archiveState === 'archived' - ? mockArchived - : { ...mockActive, exportSession: mockExportSession }, + mockUseSessions(options), })); function makeSession( @@ -115,6 +138,7 @@ function renderSidebar( } beforeEach(() => { + mockUseSessions.mockClear(); mockConnection.sessionId = null; mockConnection.capabilities = { qwenCodeVersion: '1.2.3', features: [] }; for (const store of [mockActive, mockArchived]) { @@ -137,6 +161,15 @@ beforeEach(() => { mimeType: 'text/html', format: 'html', }); + mockWorkspaceActions.listSessionGroups.mockReset(); + mockWorkspaceActions.listSessionGroups.mockResolvedValue({ + groups: [], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }); + mockWorkspaceActions.createSessionGroup.mockReset(); + mockWorkspaceActions.updateSessionGroup.mockReset(); + mockWorkspaceActions.deleteSessionGroup.mockReset(); + mockWorkspaceActions.updateSessionOrganization.mockReset(); }); afterEach(() => { @@ -213,8 +246,8 @@ function click(el: Element | null): void { }); } -// Clicks that kick off an async action (archive/unarchive) settle a trailing -// `setBusySessionId` in a `.finally()`; flush those microtasks inside act(). +// Clicks that kick off an async action settle trailing state updates in +// `.finally()`; flush those microtasks inside act(). async function clickAsync(el: Element | null): Promise { expect(el).not.toBeNull(); await act(async () => { @@ -222,6 +255,499 @@ async function clickAsync(el: Element | null): Promise { }); } +describe('WebShellSidebar — session organization', () => { + it('uses organized sessions only when the daemon advertises the capability', () => { + renderSidebar(false); + expect(mockUseSessions).toHaveBeenCalledWith({ + autoLoad: true, + pageSize: 1000, + archiveState: 'active', + }); + + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + mockWorkspaceActions.listSessionGroups.mockReturnValue( + new Promise(() => undefined), + ); + const container = renderSidebar(false); + expect(mockUseSessions).toHaveBeenCalledWith({ + autoLoad: true, + pageSize: 1000, + archiveState: 'active', + view: 'organized', + group: 'all', + }); + expect(container.querySelector('[aria-label="Session group"]')).toBeNull(); + }); + + it('creates session groups from an in-app dialog form', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + mockWorkspaceActions.createSessionGroup.mockResolvedValue({ + id: 'group-1', + name: 'Backend', + color: 'green', + order: 0, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }); + mockActive.sessions = [ + makeSession('550e8400-e29b-41d4-a716-446655440000', { + displayName: 'Review plan', + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }), + ]; + const promptSpy = vi.spyOn(window, 'prompt'); + + renderSidebar(false); + await act(async () => { + await Promise.resolve(); + }); + const organizeButton = document.body.querySelector( + '[aria-label="Move to group"]', + ); + expect(organizeButton).not.toBeNull(); + act(() => { + organizeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + const createButton = Array.from( + document.body.querySelectorAll('button'), + ).find((button) => button.textContent?.includes('Create group')); + expect(createButton).not.toBeNull(); + act(() => { + createButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const nameInput = document.body.querySelector( + 'input[maxlength="64"]', + ); + expect(nameInput).not.toBeNull(); + const setInputValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + act(() => { + setInputValue?.call(nameInput, 'Backend'); + nameInput!.dispatchEvent(new Event('input', { bubbles: true })); + }); + + const colorSelect = Array.from( + document.body.querySelectorAll('select'), + ).find((select) => select.value === 'red'); + expect(colorSelect).toBeDefined(); + const setSelectValue = Object.getOwnPropertyDescriptor( + HTMLSelectElement.prototype, + 'value', + )?.set; + act(() => { + setSelectValue?.call(colorSelect, 'green'); + colorSelect!.dispatchEvent(new Event('change', { bubbles: true })); + }); + + const saveButton = Array.from( + document.body.querySelectorAll('button'), + ).find((button) => button.textContent === 'save'); + expect(saveButton).not.toBeNull(); + await act(async () => { + saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(mockWorkspaceActions.createSessionGroup).toHaveBeenCalledWith({ + name: 'Backend', + color: 'green', + }); + promptSpy.mockRestore(); + }); + + it('uses a themed group menu and assigns the selected group', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + mockWorkspaceActions.listSessionGroups.mockResolvedValue({ + groups: [ + { + id: 'group-1', + name: 'Backend', + color: 'green', + order: 0, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }); + mockWorkspaceActions.updateSessionOrganization.mockResolvedValue({ + sessionId: '550e8400-e29b-41d4-a716-446655440000', + groupId: 'group-1', + isPinned: false, + updatedAt: '2026-07-04T00:00:00.000Z', + }); + mockActive.sessions = [ + makeSession('550e8400-e29b-41d4-a716-446655440000', { + displayName: 'Review plan', + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }), + ]; + + renderSidebar(false); + await act(async () => { + await Promise.resolve(); + }); + const organizeButton = document.body.querySelector( + '[aria-label="Move to group"]', + ); + expect(organizeButton).not.toBeNull(); + act(() => { + organizeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + const menu = document.body.querySelector( + '[role="menu"][aria-label="Group"]', + ); + expect(menu).not.toBeNull(); + expect(menu!.querySelector('select')).toBeNull(); + const selectedOption = menu!.querySelector( + '[role="menuitemradio"][aria-checked="true"]', + ); + expect(selectedOption?.textContent).toContain('Ungrouped'); + await act(async () => { + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + expect(document.activeElement).toBe(selectedOption); + act(() => { + menu!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + }), + ); + }); + expect(document.activeElement?.textContent).toContain('Backend'); + const groupOption = Array.from( + menu!.querySelectorAll('button'), + ).find((button) => button.textContent?.includes('Backend')); + expect(groupOption).not.toBeNull(); + await act(async () => { + groupOption!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith( + '550e8400-e29b-41d4-a716-446655440000', + { groupId: 'group-1' }, + ); + }); + + it('renders organized sessions as collapsible group sections', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + mockWorkspaceActions.listSessionGroups.mockResolvedValue({ + groups: [ + { + id: 'group-1', + name: 'Backend', + color: 'green', + order: 0, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }); + mockActive.sessions = [ + makeSession('session-a', { + displayName: 'API review', + groupId: 'group-1', + }), + makeSession('session-b', { + displayName: 'Release notes', + groupId: null, + }), + ]; + + const container = renderSidebar(false); + await act(async () => { + await Promise.resolve(); + }); + + const backendHeader = Array.from( + container.querySelectorAll('button'), + ).find( + (button) => + button.textContent?.includes('Backend') && + button.textContent.includes('1'), + ); + expect(backendHeader).not.toBeNull(); + expect(container.textContent).toContain('Recent'); + expect(container.textContent).toContain('API review'); + expect(container.textContent).toContain('Release notes'); + + act(() => { + backendHeader!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).not.toContain('API review'); + expect(container.textContent).toContain('Release notes'); + }); + + it('reloads sessions after deleting a group', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + mockWorkspaceActions.listSessionGroups.mockResolvedValue({ + groups: [ + { + id: 'group-1', + name: 'Backend', + color: 'green', + order: 0, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + colorOptions: ['red', 'orange', 'yellow', 'green', 'blue', 'purple'], + }); + mockWorkspaceActions.deleteSessionGroup.mockResolvedValue(true); + mockActive.sessions = [ + makeSession('session-a', { + displayName: 'API review', + groupId: 'group-1', + }), + ]; + + const container = renderSidebar(false); + await act(async () => { + await Promise.resolve(); + }); + mockWorkspaceActions.listSessionGroups.mockClear(); + + const deleteGroupButton = container.querySelector( + '[aria-label="Delete group"]', + ); + expect(deleteGroupButton).not.toBeNull(); + act(() => { + deleteGroupButton!.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + }); + const confirmDeleteButton = Array.from( + document.body.querySelectorAll('button'), + ).find((button) => button.textContent?.trim() === 'Delete group'); + expect(confirmDeleteButton).toBeDefined(); + await act(async () => { + confirmDeleteButton!.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + await Promise.resolve(); + }); + + expect(mockWorkspaceActions.deleteSessionGroup).toHaveBeenCalledWith( + 'group-1', + ); + expect(mockActive.reload).toHaveBeenCalledTimes(1); + expect(mockWorkspaceActions.listSessionGroups).toHaveBeenCalledTimes(1); + }); + + it('toggles pin state from the session action button', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + mockWorkspaceActions.updateSessionOrganization.mockResolvedValue({ + sessionId: '550e8400-e29b-41d4-a716-446655440000', + groupId: null, + isPinned: true, + pinnedAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }); + mockActive.sessions = [ + makeSession('550e8400-e29b-41d4-a716-446655440000', { + displayName: 'Review plan', + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }), + ]; + + const container = renderSidebar(false); + await act(async () => { + await Promise.resolve(); + }); + const pinButton = + container.querySelector('[aria-label="Pin"]'); + expect(pinButton).not.toBeNull(); + await act(async () => { + pinButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith( + '550e8400-e29b-41d4-a716-446655440000', + { isPinned: true }, + ); + expect(mockActive.reload).toHaveBeenCalledTimes(1); + }); + + it('does not drop organization actions for another session while one is busy', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + let resolveFirst: ((value: unknown) => void) | undefined; + mockWorkspaceActions.updateSessionOrganization.mockImplementation( + (sessionId: string) => { + if (sessionId === 'session-a') { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve({ + sessionId, + groupId: null, + isPinned: true, + pinnedAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }); + }, + ); + mockActive.sessions = [makeSession('session-a'), makeSession('session-b')]; + + const container = renderSidebar(false); + await act(async () => { + await Promise.resolve(); + }); + const pinButtons = Array.from( + container.querySelectorAll('[aria-label="Pin"]'), + ); + expect(pinButtons).toHaveLength(2); + + await act(async () => { + pinButtons[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect( + mockWorkspaceActions.updateSessionOrganization, + ).toHaveBeenCalledTimes(1); + + await act(async () => { + pinButtons[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + expect( + mockWorkspaceActions.updateSessionOrganization, + ).toHaveBeenCalledTimes(2); + expect( + mockWorkspaceActions.updateSessionOrganization, + ).toHaveBeenLastCalledWith('session-b', { isPinned: true }); + + await act(async () => { + resolveFirst?.({ + sessionId: 'session-a', + groupId: null, + isPinned: true, + pinnedAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }); + await Promise.resolve(); + }); + }); + + it('keeps new session available while a session organization update is busy', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + let resolveUpdate: ((value: unknown) => void) | undefined; + mockWorkspaceActions.updateSessionOrganization.mockReturnValueOnce( + new Promise((resolve) => { + resolveUpdate = resolve; + }), + ); + mockActive.sessions = [makeSession('session-a')]; + const onNewSession = vi.fn(); + + const container = renderSidebar(false, { onNewSession }); + await act(async () => { + await Promise.resolve(); + }); + const pinButton = + container.querySelector('[aria-label="Pin"]'); + await act(async () => { + pinButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const newSessionButton = container.querySelector( + '[aria-label="New chat"]', + ); + expect(newSessionButton).not.toBeNull(); + expect(newSessionButton!.disabled).toBe(false); + act(() => { + newSessionButton!.click(); + }); + expect(onNewSession).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveUpdate?.({ + sessionId: 'session-a', + groupId: null, + isPinned: true, + pinnedAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }); + await Promise.resolve(); + }); + expect(newSessionButton!.disabled).toBe(false); + }); + + it('does not report organization failure when post-mutation reload fails', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + mockWorkspaceActions.updateSessionOrganization.mockResolvedValueOnce({ + sessionId: 'session-a', + groupId: null, + isPinned: true, + pinnedAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }); + mockActive.reload.mockRejectedValueOnce(new Error('reload failed')); + mockActive.sessions = [makeSession('session-a')]; + const onError = vi.fn(); + + const container = renderSidebar(false, { onError }); + await act(async () => { + await Promise.resolve(); + }); + const pinButton = + container.querySelector('[aria-label="Pin"]'); + await act(async () => { + pinButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith( + 'session-a', + { isPinned: true }, + ); + expect(mockActive.reload).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); +}); + describe('WebShellSidebar — session export', () => { it('hides export action when daemon does not advertise session_export', () => { mockActive.sessions = [makeSession('session-1')]; diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 28031023a5..fd19155015 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -6,6 +6,7 @@ import { useState, type CSSProperties, type FocusEvent as ReactFocusEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, @@ -14,8 +15,13 @@ import { useActions, useConnection, useSessions, + useWorkspaceActions, } from '@qwen-code/webui/daemon-react-sdk'; -import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; +import type { + DaemonSessionGroup, + DaemonSessionGroupColor, + DaemonSessionSummary, +} from '@qwen-code/sdk/daemon'; import { useI18n } from '../../i18n'; import { formatRelativeTime } from '../../utils/formatRelativeTime'; import { DialogShell } from '../dialogs/DialogShell'; @@ -28,6 +34,34 @@ const SIDEBAR_MAX_WIDTH = 420; const SIDEBAR_SESSION_PAGE_SIZE = 1000; const ACTIVE_SESSION_POLL_INTERVAL_MS = 2000; const IDLE_SESSION_POLL_INTERVAL_MS = 30_000; +const SESSION_ORGANIZATION_FEATURE = 'session_organization'; +const DIALOG_SESSION_LABEL_MAX_LENGTH = 96; +const RECENT_SESSION_SECTION_ID = 'recent'; +const GROUP_MENU_WIDTH = 240; +const GROUP_MENU_MARGIN = 8; + +type GroupEditorMode = 'create' | 'edit'; + +interface SessionSection { + id: string; + label: string; + countLabel?: string; + color?: DaemonSessionGroupColor; + group?: DaemonSessionGroup; + sessions: DaemonSessionSummary[]; +} + +interface GroupEditorState { + mode: GroupEditorMode; + group?: DaemonSessionGroup; + targetSession?: DaemonSessionSummary; +} + +interface GroupMenuState { + session: DaemonSessionSummary; + top: number; + left: number; +} interface WebShellSidebarProps { collapsed: boolean; @@ -59,12 +93,47 @@ function getSessionLabel(session: DaemonSessionSummary): string { return displayName || session.sessionId.slice(0, 8); } +function getCompactSessionLabel(session: DaemonSessionSummary): string { + const normalized = getSessionLabel(session).replace(/\s+/g, ' ').trim(); + if (normalized.length <= DIALOG_SESSION_LABEL_MAX_LENGTH) { + return normalized; + } + return `${normalized + .slice(0, DIALOG_SESSION_LABEL_MAX_LENGTH - 3) + .trimEnd()}...`; +} + function getSessionCreatedTime(session: DaemonSessionSummary): number { if (!session.createdAt) return 0; const time = Date.parse(session.createdAt); return Number.isFinite(time) ? time : 0; } +function getDefaultGroupColor( + colorOptions: DaemonSessionGroupColor[], +): DaemonSessionGroupColor { + return colorOptions[0] ?? 'blue'; +} + +function getGroupColorClass(color: DaemonSessionGroupColor): string { + switch (color) { + case 'red': + return styles.groupColorRed; + case 'orange': + return styles.groupColorOrange; + case 'yellow': + return styles.groupColorYellow; + case 'green': + return styles.groupColorGreen; + case 'blue': + return styles.groupColorBlue; + case 'purple': + return styles.groupColorPurple; + } + const exhaustive: never = color; + return exhaustive; +} + function clampSidebarWidth(width: number): number { return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, width)); } @@ -172,6 +241,15 @@ function IconTrash() { ); } +function IconPin() { + return ( + + ); +} + function IconArchive() { return (
{t('sidebar.loadingSessions')}
- ); - } - if (error && sessions.length === 0) { - return ( - - ); - } - if (filteredSessions.length === 0) { - return
{t('sidebar.searchEmpty')}
; - } - return filteredSessions.map((session) => { - const isCurrent = session.sessionId === currentSessionId; - const isEditing = editingSessionId === session.sessionId; + const renderSessionRow = useCallback( + ( + session: DaemonSessionSummary, + options: { isArchived?: boolean; grouped?: boolean } = {}, + ) => { + const { isArchived = false, grouped = false } = options; const label = getSessionLabel(session); const stamp = session.updatedAt || session.createdAt; const time = stamp ? formatRelativeTime(stamp, t) : ''; - const busy = busySessionId === session.sessionId; + const busy = busySessionIds.has(session.sessionId); + const isMenuOpen = + menuState?.session.sessionId === session.sessionId && + menuState.isArchived === isArchived; + + if (isArchived) { + return ( +
+ {label} +
+ {time} +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + +
+
+
+ ); + } + + const isCurrent = session.sessionId === currentSessionId; + const isEditing = editingSessionId === session.sessionId; const exporting = exportingSessionIds.has(session.sessionId); const completedUnread = !isCurrent && completedUnreadIds.has(session.sessionId); - const isMenuOpen = - menuState?.session.sessionId === session.sessionId && - !menuState.isArchived; + const showInlinePin = + grouped && session.isPinned && !session.hasActivePrompt; + return (
)} + {!completedUnread && session.isPinned && !grouped && ( + + + + )} {isEditing ? (
+ ) : showInlinePin ? ( + + + ) : ( {time} )} @@ -1010,6 +1544,42 @@ export function WebShellSidebar({ setTooltip(null); }} > + {organizationEnabled && ( + <> + + + + )} + ); + } + if ( + filteredSessions.length === 0 && + (searchQuery.trim() || + !organizationEnabled || + sessionSections.length === 0) + ) { + return
{t('sidebar.searchEmpty')}
; + } + if (!organizationEnabled) { + return filteredSessions.map((session) => renderSessionRow(session)); + } + + return sessionSections.map((section) => { + const expanded = !collapsedSessionSectionIds.has(section.id); + const group = section.group; + return ( +
+
+ +
+ {group ? ( + <> + + + + + ) : ( + + )} +
+
+ {expanded && ( +
+ {section.sessions.map((session) => + renderSessionRow(session, { grouped: true }), + )} +
+ )} +
+ ); }); }, [ - busySessionId, - canExportSessions, - cancelRename, - collapsed, - completedUnreadIds, - currentSessionId, - editingName, - editingSessionId, + collapsedSessionSectionIds, error, - exportingSessionIds, filteredSessions, - handleArchive, - handleExportSession, - handleLoadSession, - hideTooltip, + groupBusy, + handleCreateGroup, + handleDeleteGroup, + handleRenameGroup, loading, - menuState, - openMenu, + organizationEnabled, projectExpanded, reload, - saveRename, - renderSessionTooltip, + renderSessionRow, + searchQuery, + sessionSections, sessions.length, - showTooltip, - startRename, t, + toggleSessionSection, ]); const archivedSection = useMemo(() => { @@ -1136,61 +1844,9 @@ export function WebShellSidebar({
{t('sidebar.archivedEmpty')}
); } else { - content = archivedSessions.map((session) => { - const label = getSessionLabel(session); - const stamp = session.updatedAt || session.createdAt; - const time = stamp ? formatRelativeTime(stamp, t) : ''; - const busy = busySessionId === session.sessionId; - const isMenuOpen = - menuState?.session.sessionId === session.sessionId && - menuState.isArchived; - return ( -
- {label} -
- {time} -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - - -
-
-
- ); - }); + content = archivedSessions.map((session) => + renderSessionRow(session, { isArchived: true }), + ); } return ( @@ -1204,13 +1860,10 @@ export function WebShellSidebar({ archivedExpanded, archivedLoading, archivedSessions, - busySessionId, collapsed, - handleUnarchive, - menuState, - openMenu, projectExpanded, reloadArchived, + renderSessionRow, searchQuery, t, ]); @@ -1236,7 +1889,8 @@ export function WebShellSidebar({ ]; } const isCurrent = session.sessionId === currentSessionId; - return [ + const sessionBusy = busySessionIds.has(session.sessionId); + const items: SessionMenuItem[] = [ { key: 'rename', label: t('sidebar.rename'), @@ -1245,6 +1899,25 @@ export function WebShellSidebar({ disabledTitle: t('sidebar.renameCurrentOnly'), onSelect: () => handleRenameFromMenu(session), }, + ...(organizationEnabled + ? [ + { + key: 'pin', + label: session.isPinned ? t('sidebar.unpin') : t('sidebar.pin'), + icon: , + disabled: sessionBusy, + onSelect: () => handleTogglePin(session), + }, + { + key: 'group', + label: t('sidebar.organize'), + icon: , + disabled: sessionBusy, + onSelect: () => + openGroupMenuFromAnchor(menuState.anchorEl, session), + }, + ] + : []), { key: 'archive', label: t('sidebar.archive'), @@ -1263,13 +1936,18 @@ export function WebShellSidebar({ onSelect: () => handleDeleteSession(session), }, ]; + return items; }, [ + busySessionIds, currentSessionId, handleArchive, handleDeleteSession, handleRenameFromMenu, + handleTogglePin, handleUnarchive, menuState, + openGroupMenuFromAnchor, + organizationEnabled, t, ]); @@ -1298,6 +1976,76 @@ export function WebShellSidebar({ {tooltip.content}
)} + {groupMenu && ( +
event.stopPropagation()} + onKeyDown={handleGroupMenuKeyDown} + onMouseDown={(event) => event.stopPropagation()} + > + + {groups.map((group) => { + const selected = groupMenuSelectedGroupId === group.id; + return ( + + ); + })} +
+ +
+ )} {menuState && ( )} + {groupEditor && ( + + { + event.preventDefault(); + saveGroupEditor(); + }} + > + + +
+ + +
+ +
+ )} + {deleteGroupCandidate && ( + { + if (!groupBusy) setDeleteGroupCandidate(null); + }} + > +
+

+ {t('sidebar.groupDeleteConfirm', { + name: deleteGroupCandidateLabel, + })} +

+
+ + +
+
+
+ )}