diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 09a83abeeaf..70da34dc1a8 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ApprovalMode } from '@qwen-code/qwen-code-core'; +import type { + ApprovalMode, + SessionGroupColor, +} from '@qwen-code/qwen-code-core'; import type { CancelNotification, LoadSessionResponse, @@ -216,6 +219,8 @@ export interface BridgeSessionSummary { isPinned?: boolean; pinnedAt?: string; groupId?: string | null; + /** Quick color grouping tag; mutually exclusive with `groupId` in the UI. */ + color?: SessionGroupColor | 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 5c0cbdcc845..9c26d9b6e87 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -10,6 +10,7 @@ import { type ApprovalMode, BTW_MAX_INPUT_LENGTH, createDebugLogger, + GROUP_COLOR_OPTIONS, SessionService, SessionOrganizationError, type SessionGroupColor, @@ -1267,6 +1268,7 @@ export class AcpDispatcher { ...(s.isPinned !== undefined ? { isPinned: s.isPinned } : {}), ...(s.pinnedAt !== undefined ? { pinnedAt: s.pinnedAt } : {}), ...(s.groupId !== undefined ? { groupId: s.groupId } : {}), + ...(s.color !== undefined ? { color: s.color } : {}), })), ...(result.nextCursor != null ? { nextCursor: result.nextCursor } @@ -1932,6 +1934,18 @@ export class AcpDispatcher { ) { throw new AcpParamError('`groupId` must be a string or null'); } + if ( + 'color' in params && + params['color'] !== null && + (typeof params['color'] !== 'string' || + !GROUP_COLOR_OPTIONS.includes( + params['color'] as SessionGroupColor, + )) + ) { + throw new AcpParamError( + '`color` must be a supported color or null', + ); + } await this.archiveCoordinator.runSharedMany([sessionId], async () => { const sessionService = new SessionService(this.boundWorkspace); let exists = @@ -1956,6 +1970,9 @@ export class AcpDispatcher { ...('groupId' in params ? { groupId: params['groupId'] as string | null } : {}), + ...('color' in params + ? { color: params['color'] as SessionGroupColor | null } + : {}), }); this.replyConn(conn, id, { sessionId, ...organization }); }); diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 3135556b6d3..57d610a084e 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -6384,6 +6384,137 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('_qwen/session/update_organization assigns a color echoed by session/list', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440011'; + 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: 80, + method: '_qwen/session/update_organization', + params: { sessionId, color: 'purple' }, + }); + expect(await reader.next()).toMatchObject({ + result: { sessionId, color: 'purple', groupId: null }, + }); + + await post(connId, { + jsonrpc: '2.0', + id: 81, + method: 'session/list', + params: { + workspaceCwd: '/ws', + view: 'organized', + group: 'all', + _meta: { size: 20 }, + }, + }); + expect(await reader.next()).toMatchObject({ + result: { + sessions: [{ sessionId, color: 'purple', groupId: null }], + }, + }); + reader.close(); + }); + }); + + it('session/list group=ungrouped excludes color-tagged sessions', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440012'; + await writeStoredSession(sessionId); + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + const reader = frameReader(await streamRes); + + // Tag the session with a color and no named group. + await post(connId, { + jsonrpc: '2.0', + id: 82, + method: '_qwen/session/update_organization', + params: { sessionId, color: 'red' }, + }); + expect(await reader.next()).toMatchObject({ + result: { sessionId, color: 'red', groupId: null }, + }); + + // A color tag is its own sidebar bucket, so the session is not + // "ungrouped" even though it belongs to no named group. The server + // filter must agree with that taxonomy for REST/ACP consumers. + await post(connId, { + jsonrpc: '2.0', + id: 83, + method: 'session/list', + params: { + workspaceCwd: '/ws', + view: 'organized', + group: 'ungrouped', + _meta: { size: 20 }, + }, + }); + expect(await reader.next()).toMatchObject({ + result: { sessions: [] }, + }); + reader.close(); + }); + }); + + it('session/list group= excludes sessions that also carry a color tag', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440013'; + 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: 84, + method: '_qwen/workspace/session_groups/create', + params: { workspaceCwd: '/ws', name: 'Frontend', color: 'blue' }, + }); + const createFrame = (await reader.next()) as { + result: { group: { id: string } }; + }; + const groupId = createFrame.result.group.id; + + // An SDK/API consumer can set both groupId and color in one update — + // the core store keeps both. The sidebar gives color precedence, so the + // named-group filter must not surface this session under the group. + await post(connId, { + jsonrpc: '2.0', + id: 85, + method: '_qwen/session/update_organization', + params: { sessionId, groupId, color: 'red' }, + }); + expect(await reader.next()).toMatchObject({ + result: { sessionId, groupId, color: 'red' }, + }); + + await post(connId, { + jsonrpc: '2.0', + id: 86, + method: 'session/list', + params: { + workspaceCwd: '/ws', + view: 'organized', + group: groupId, + _meta: { size: 20 }, + }, + }); + expect(await reader.next()).toMatchObject({ + result: { sessions: [] }, + }); + reader.close(); + }); + }); + it('session/list rejects group filter without organized view', async () => { const connId = await initialize(); const streamRes = openStream(connId); @@ -6420,6 +6551,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { params: { sessionId: 'session-1', groupId: 1 }, message: '`groupId` must be a string or null', }, + { + params: { sessionId: 'session-1', color: 'pink' }, + message: '`color` must be a supported color or null', + }, ])( '_qwen/session/update_organization rejects invalid params: $message', async ({ params, message }) => { diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index c9636e87776..e45b73dddff 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -9,6 +9,7 @@ import * as path from 'node:path'; import { APPROVAL_MODES, BTW_MAX_INPUT_LENGTH, + GROUP_COLOR_OPTIONS, SessionService, SessionOrganizationError, addDaemonRequestAttribute, @@ -1156,6 +1157,20 @@ export function registerSessionRoutes( }); return; } + const rawColor = body['color']; + if ( + rawColor !== undefined && + rawColor !== null && + (typeof rawColor !== 'string' || + !GROUP_COLOR_OPTIONS.includes(rawColor as SessionGroupColor)) + ) { + res.status(400).json({ + error: '`color` must be a supported color or null', + code: 'invalid_session_organization', + field: 'color', + }); + return; + } const organization = await createSessionOrganizationService( boundWorkspace, @@ -1164,6 +1179,9 @@ export function registerSessionRoutes( ...(rawGroupId !== undefined ? { groupId: rawGroupId as string | null } : {}), + ...(rawColor !== undefined + ? { color: rawColor as SessionGroupColor | null } + : {}), }); res.status(200).json({ sessionId, ...organization }); }); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 50c7bee7855..bc1733c539c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -7051,6 +7051,15 @@ describe('createServeApp', () => { 'invalid_session_organization', ); + const invalidColorBody = await host( + request(app).patch(`/session/${sessionId}/organization`), + ).send({ color: 'pink' }); + expect(invalidColorBody.status).toBe(400); + expect(invalidColorBody.body).toMatchObject({ + code: 'invalid_session_organization', + field: 'color', + }); + const unknownGroupFilter = await host( request(app).get( `/workspace/${encodeURIComponent(WS_BOUND)}/sessions?view=organized&group=missing-group`, @@ -7073,6 +7082,52 @@ describe('createServeApp', () => { expect(missingSession.status).toBe(404); }); + it('assigns a quick color tag through REST and surfaces it in organized lists', 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 assignRes = await host( + request(app).patch(`/session/${sessionId}/organization`), + ).send({ color: 'green' }); + expect(assignRes.status).toBe(200); + expect(assignRes.body).toMatchObject({ + sessionId, + color: 'green', + groupId: null, + }); + + const organized = await host( + 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, color: 'green', groupId: null }), + ]); + + // Picking "Ungrouped" (or a named group) clears the color tag. + const clearRes = await host( + request(app).patch(`/session/${sessionId}/organization`), + ).send({ color: null }); + expect(clearRes.status).toBe(200); + expect(clearRes.body.color).toBeNull(); + }); + it('paginates organized sessions with opaque cursors', async () => { for (let i = 0; i < 4; i++) { await writeStoredSession({ diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 57ecf81a7ad..db52851991b 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -8,6 +8,7 @@ import { SessionService, SessionOrganizationError, type SessionArchiveState, + type SessionGroupColor, } from '@qwen-code/qwen-code-core'; import type { AcpSessionBridge, @@ -211,6 +212,7 @@ function applyOrganization( organization: | { groupId: string | null; + color?: SessionGroupColor | null; isPinned: boolean; pinnedAt?: string; } @@ -219,6 +221,7 @@ function applyOrganization( return { ...session, groupId: organization?.groupId ?? null, + color: organization?.color ?? null, isPinned: organization?.isPinned === true, ...(organization?.pinnedAt !== undefined ? { pinnedAt: organization.pinnedAt } @@ -321,8 +324,13 @@ async function listOrganizedWorkspaceSessionsForResponse( 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; + if (group === 'ungrouped') + return session.groupId == null && session.color == null; + // Color takes precedence over a named group in the sidebar's bucketing, so + // a session carrying a color tag is never shown under its group. Keep the + // named-group filter consistent for REST/ACP consumers (the store allows + // both fields even though the UI keeps them mutually exclusive). + return session.color == null && session.groupId === group; }); const activityTimeById = new Map( filtered.map((session) => [ diff --git a/packages/core/src/services/session-organization-service.test.ts b/packages/core/src/services/session-organization-service.test.ts index a49dba86549..70e8525cfe2 100644 --- a/packages/core/src/services/session-organization-service.test.ts +++ b/packages/core/src/services/session-organization-service.test.ts @@ -359,6 +359,77 @@ describe('SessionOrganizationService', () => { ); }); + it('assigns and clears a quick color grouping tag', async () => { + const assigned = await service.updateSessionOrganization(sessionIdA, { + color: 'green', + }); + expect(assigned).toEqual( + expect.objectContaining({ color: 'green', groupId: null }), + ); + + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.get(sessionIdA)).toEqual( + expect.objectContaining({ color: 'green' }), + ); + + const cleared = await service.updateSessionOrganization(sessionIdA, { + color: null, + }); + expect(cleared.color).toBeNull(); + const afterClear = await service.readSnapshot(); + expect(afterClear.sessions.get(sessionIdA)?.color).toBeNull(); + }); + + it('rejects unsupported session colors', async () => { + await expect( + service.updateSessionOrganization(sessionIdA, { + color: 'pink' as never, + }), + ).rejects.toMatchObject({ code: 'invalid_group_color', field: 'color' }); + }); + + it('keeps color, group, and pin independent in the store', async () => { + const group = await service.createGroup({ name: 'Docs', color: 'blue' }); + // Core records exactly the fields provided; it never auto-clears the other + // grouping dimension (the UI enforces the single-choice rule explicitly). + await service.updateSessionOrganization(sessionIdA, { groupId: group.id }); + const withColor = await service.updateSessionOrganization(sessionIdA, { + color: 'red', + isPinned: true, + }); + expect(withColor).toEqual( + expect.objectContaining({ + color: 'red', + groupId: group.id, + isPinned: true, + }), + ); + }); + + it('normalizes unknown stored session colors to null', async () => { + await fs.mkdir(path.dirname(service.getStorePath()), { recursive: true }); + await fs.writeFile( + service.getStorePath(), + JSON.stringify({ + schemaVersion: 1, + groups: [], + sessions: { + [sessionIdA]: { + groupId: null, + color: 'teal', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }), + 'utf8', + ); + + const snapshot = await service.readSnapshot(); + expect(snapshot.sessions.get(sessionIdA)).toEqual( + expect.objectContaining({ color: null }), + ); + }); + it('warns once when reading orphaned group references', async () => { await fs.mkdir(path.dirname(service.getStorePath()), { recursive: true }); await fs.writeFile( diff --git a/packages/core/src/services/session-organization-service.ts b/packages/core/src/services/session-organization-service.ts index e81e7a56b13..d7ba3066c2b 100644 --- a/packages/core/src/services/session-organization-service.ts +++ b/packages/core/src/services/session-organization-service.ts @@ -32,11 +32,18 @@ export interface SessionGroup { export interface SessionOrganization { groupId: string | null; + /** + * Quick color grouping tag. Independent of `groupId`: the UI treats the two + * as mutually exclusive (a color is a lightweight, name-free bucket), but the + * store only records whatever fields callers provide. Absent/unknown → null. + */ + color?: SessionGroupColor | null; pinnedAt?: string; updatedAt: string; } export interface SessionOrganizationView extends SessionOrganization { + color: SessionGroupColor | null; isPinned: boolean; } @@ -64,6 +71,7 @@ export interface UpdateSessionGroupInput { export interface UpdateSessionOrganizationInput { isPinned?: boolean; groupId?: string | null; + color?: SessionGroupColor | null; } interface SessionOrganizationStoreV1 { @@ -182,6 +190,11 @@ function viewOrganization( ): SessionOrganizationView { const groupId = typeof organization?.groupId === 'string' ? organization.groupId : null; + const color = + typeof organization?.color === 'string' && + isSupportedGroupColor(organization.color) + ? organization.color + : null; const pinnedAt = typeof organization?.pinnedAt === 'string' ? organization.pinnedAt @@ -192,6 +205,7 @@ function viewOrganization( : new Date(0).toISOString(); return { groupId, + color, ...(pinnedAt !== undefined ? { pinnedAt } : {}), updatedAt, isPinned: pinnedAt !== undefined, @@ -203,6 +217,7 @@ function serializeOrganization( ): SessionOrganization { return { groupId: organization.groupId, + ...(organization.color != null ? { color: organization.color } : {}), ...(organization.pinnedAt !== undefined ? { pinnedAt: organization.pinnedAt } : {}), @@ -338,7 +353,9 @@ export class SessionOrganizationService { input: UpdateSessionOrganizationInput, ): Promise { const hasUpdate = - input.groupId !== undefined || input.isPinned !== undefined; + input.groupId !== undefined || + input.isPinned !== undefined || + input.color !== undefined; return this.withStoreLock(async () => { const store = await this.readStore(); const current = viewOrganization(store.sessions[sessionId]); @@ -346,6 +363,12 @@ export class SessionOrganizationService { return current; } const now = new Date().toISOString(); + if (input.color !== undefined) { + if (input.color !== null) { + assertGroupColor(input.color); + } + current.color = input.color; + } if (input.groupId !== undefined) { if ( input.groupId !== null && @@ -454,6 +477,10 @@ export class SessionOrganizationService { typeof organization['groupId'] === 'string' ? organization['groupId'] : null, + ...(typeof organization['color'] === 'string' && + isSupportedGroupColor(organization['color']) + ? { color: organization['color'] } + : {}), ...(typeof organization['pinnedAt'] === 'string' ? { pinnedAt: organization['pinnedAt'] } : {}), diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 151a9bdb085..75956147481 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -486,6 +486,8 @@ export interface DaemonSessionSummary { isPinned?: boolean; pinnedAt?: string; groupId?: string | null; + /** Quick color grouping tag; mutually exclusive with `groupId` in the UI. */ + color?: DaemonSessionGroupColor | null; } export type DaemonSessionExportFormat = 'html' | 'md' | 'json' | 'jsonl'; @@ -535,6 +537,7 @@ export interface DaemonSessionGroupUpdate { export interface DaemonSessionOrganizationUpdate { isPinned?: boolean; groupId?: string | null; + color?: DaemonSessionGroupColor | null; } export interface DaemonSessionOrganizationResult { @@ -542,6 +545,7 @@ export interface DaemonSessionOrganizationResult { groupId: string | null; isPinned: boolean; pinnedAt?: string; + color?: DaemonSessionGroupColor | null; updatedAt: string; } diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx index 47b341ee2e3..3b286e80c5a 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx @@ -68,6 +68,7 @@ type MockSession = { isArchived?: boolean; isPinned?: boolean; groupId?: string | null; + color?: 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | null; }; vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ @@ -318,7 +319,7 @@ describe('WebShellSidebar — session organization', () => { await Promise.resolve(); }); const organizeButton = document.body.querySelector( - '[aria-label="Move to group"]', + '[aria-label="Group"]', ); expect(organizeButton).not.toBeNull(); act(() => { @@ -371,6 +372,12 @@ describe('WebShellSidebar — session organization', () => { name: 'Backend', color: 'green', }); + // Creating a group for a session assigns it and clears any color tag — + // color and named group are mutually exclusive in the UI. + expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith( + '550e8400-e29b-41d4-a716-446655440000', + { groupId: 'group-1', color: null }, + ); promptSpy.mockRestore(); }); @@ -411,7 +418,7 @@ describe('WebShellSidebar — session organization', () => { await Promise.resolve(); }); const organizeButton = document.body.querySelector( - '[aria-label="Move to group"]', + '[aria-label="Group"]', ); expect(organizeButton).not.toBeNull(); act(() => { @@ -440,7 +447,8 @@ describe('WebShellSidebar — session organization', () => { }), ); }); - expect(document.activeElement?.textContent).toContain('Backend'); + // One ArrowDown from "Ungrouped" now lands on the first color quick-pick. + expect(document.activeElement?.textContent).toContain('Red'); const groupOption = Array.from( menu!.querySelectorAll('button'), ).find((button) => button.textContent?.includes('Backend')); @@ -449,10 +457,102 @@ describe('WebShellSidebar — session organization', () => { groupOption!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + // Assigning a named group clears any color tag (single-choice). + expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith( + '550e8400-e29b-41d4-a716-446655440000', + { groupId: 'group-1', color: null }, + ); + }); + + it('offers the six color quick-picks and assigns the chosen color', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + mockWorkspaceActions.updateSessionOrganization.mockResolvedValue({ + sessionId: '550e8400-e29b-41d4-a716-446655440000', + groupId: null, + color: 'red', + isPinned: false, + updatedAt: '2026-07-04T00:00:00.000Z', + }); + mockActive.sessions = [ + makeSession('550e8400-e29b-41d4-a716-446655440000', { + displayName: 'Review plan', + }), + ]; + + renderSidebar(false); + await act(async () => { + await Promise.resolve(); + }); + const organizeButton = document.body.querySelector( + '[aria-label="Group"]', + ); + expect(organizeButton).not.toBeNull(); + act(() => { + organizeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const menu = document.body.querySelector( + '[role="menu"][aria-label="Group"]', + ); + expect(menu).not.toBeNull(); + const radioLabels = Array.from( + menu!.querySelectorAll('[role="menuitemradio"]'), + ).map((button) => button.textContent ?? ''); + // Ungrouped + all six colors are offered as single-choice radios. + for (const name of [ + 'Ungrouped', + 'Red', + 'Orange', + 'Yellow', + 'Green', + 'Blue', + 'Purple', + ]) { + expect(radioLabels.some((label) => label.includes(name))).toBe(true); + } + + const redOption = Array.from( + menu!.querySelectorAll('[role="menuitemradio"]'), + ).find((button) => button.textContent?.includes('Red')); + expect(redOption).not.toBeNull(); + await act(async () => { + redOption!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Picking a color clears any named-group assignment (single-choice). expect(mockWorkspaceActions.updateSessionOrganization).toHaveBeenCalledWith( '550e8400-e29b-41d4-a716-446655440000', - { groupId: 'group-1' }, + { color: 'red', groupId: null }, + ); + }); + + it('groups sessions into color sections ahead of the recent bucket', async () => { + mockConnection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_organization'], + }; + mockActive.sessions = [ + makeSession('session-red', { displayName: 'Red work', color: 'red' }), + makeSession('session-plain', { displayName: 'Loose end', color: null }), + ]; + + const { container } = renderSidebar(false); + await act(async () => { + await Promise.resolve(); + }); + + // A "Red" color section exists and holds the tagged session. + const redSection = container.querySelector( + 'section[aria-label="Red"]', ); + expect(redSection).not.toBeNull(); + expect(redSection!.textContent).toContain('Red work'); + // The untagged session falls through to the Recent bucket. + expect(container.textContent).toContain('Recent'); + expect(container.textContent).toContain('Loose end'); }); it('renders organized sessions as collapsible group sections', async () => { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index d6743370fc4..8cfdc05c46a 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -40,10 +40,28 @@ const RECENT_SESSION_SECTION_ID = 'recent'; const GROUP_MENU_WIDTH = 240; const GROUP_MENU_MARGIN = 8; +/** + * Palette order for the quick color-grouping buckets. Mirrors core's + * `GROUP_COLOR_OPTIONS`; kept as a local constant so the client never imports + * from core. Used both to order the color sections and as a fallback when the + * daemon's color catalog has not loaded yet. + */ +const SESSION_GROUP_COLORS: DaemonSessionGroupColor[] = [ + 'red', + 'orange', + 'yellow', + 'green', + 'blue', + 'purple', +]; + type GroupEditorMode = 'create' | 'edit'; +type SessionSectionKind = 'color' | 'group' | 'recent'; + interface SessionSection { id: string; + kind: SessionSectionKind; label: string; countLabel?: string; color?: DaemonSessionGroupColor; @@ -1030,7 +1048,9 @@ export function WebShellSidebar({ try { await workspaceActions.updateSessionOrganization( groupEditor.targetSession.sessionId, - { groupId: group.id }, + // Assigning a named group clears any color tag (single choice + // in the UI), matching assignSessionGroup. + { groupId: group.id, color: null }, ); void reload().catch(() => undefined); } catch (err) { @@ -1169,7 +1189,10 @@ export function WebShellSidebar({ : window.innerWidth; const viewportHeight = typeof window === 'undefined' ? rect.top + 320 : window.innerHeight; - const estimatedHeight = Math.min(320, 34 * (groups.length + 2) + 25); + const estimatedHeight = Math.min( + 320, + 34 * (groups.length + SESSION_GROUP_COLORS.length + 2) + 25, + ); const left = rect.right + GROUP_MENU_MARGIN + GROUP_MENU_WIDTH <= viewportWidth ? rect.right + GROUP_MENU_MARGIN @@ -1233,7 +1256,31 @@ export function WebShellSidebar({ setGroupMenu(null); setSessionBusy(sessionId, true); workspaceActions - .updateSessionOrganization(sessionId, { groupId }) + // Group and color are a single choice in the UI: assigning a named + // group (or "Ungrouped", groupId=null) clears any color tag. + .updateSessionOrganization(sessionId, { groupId, color: null }) + .then(() => { + void reload().catch(() => undefined); + }) + .catch((err: unknown) => onError(err, t('sidebar.organizationFailed'))) + .finally(() => { + setSessionBusy(sessionId, false); + }); + }, + [onError, organizationEnabled, reload, setSessionBusy, t, workspaceActions], + ); + + const assignSessionColor = useCallback( + (session: DaemonSessionSummary, color: DaemonSessionGroupColor | null) => { + const sessionId = session.sessionId; + if (!organizationEnabled || busySessionIdsRef.current.has(sessionId)) { + return; + } + setGroupMenu(null); + setSessionBusy(sessionId, true); + workspaceActions + // Picking a color clears any named-group assignment (single choice). + .updateSessionOrganization(sessionId, { color, groupId: null }) .then(() => { void reload().catch(() => undefined); }) @@ -1275,37 +1322,68 @@ export function WebShellSidebar({ const sessionSections = useMemo(() => { if (!organizationEnabled) return []; const searching = searchQuery.trim().length > 0; + const validGroupIds = new Set(groups.map((group) => group.id)); + const sessionsByColor = new Map< + DaemonSessionGroupColor, + DaemonSessionSummary[] + >(); const sessionsByGroupId = new Map(); for (const group of groups) { sessionsByGroupId.set(group.id, []); } const recentSessions: DaemonSessionSummary[] = []; for (const session of filteredSessions) { - const groupSessions = session.groupId - ? sessionsByGroupId.get(session.groupId) - : undefined; + // Color takes precedence: the picker keeps color and group mutually + // exclusive, but stay defensive if a store somehow carries both. + if (session.color && SESSION_GROUP_COLORS.includes(session.color)) { + const bucket = sessionsByColor.get(session.color) ?? []; + bucket.push(session); + sessionsByColor.set(session.color, bucket); + continue; + } + const groupSessions = + session.groupId && validGroupIds.has(session.groupId) + ? sessionsByGroupId.get(session.groupId) + : undefined; if (groupSessions) { groupSessions.push(session); } else { recentSessions.push(session); } } - const sections: SessionSection[] = groups - .map((group) => { - const groupSessions = sessionsByGroupId.get(group.id) ?? []; - return { - id: `group:${group.id}`, - label: group.name, - countLabel: String(groupSessions.length), - color: group.color, - group, - sessions: groupSessions, - }; - }) - .filter((section) => !searching || section.sessions.length > 0); + const sections: SessionSection[] = []; + // Color buckets first, in palette order; only render non-empty ones so the + // sidebar never shows six empty color headers. + for (const color of SESSION_GROUP_COLORS) { + const colorSessions = sessionsByColor.get(color); + if (!colorSessions || colorSessions.length === 0) continue; + sections.push({ + id: `color:${color}`, + kind: 'color', + label: t(`sidebar.groupColor.${color}`), + countLabel: String(colorSessions.length), + color, + sessions: colorSessions, + }); + } + // Named groups next (kept visible even when empty, unless searching). + for (const group of groups) { + const groupSessions = sessionsByGroupId.get(group.id) ?? []; + if (searching && groupSessions.length === 0) continue; + sections.push({ + id: `group:${group.id}`, + kind: 'group', + label: group.name, + countLabel: String(groupSessions.length), + color: group.color, + group, + sessions: groupSessions, + }); + } if (recentSessions.length > 0) { sections.push({ id: RECENT_SESSION_SECTION_ID, + kind: 'recent', label: t('sidebar.groupRecent'), sessions: recentSessions, }); @@ -1383,11 +1461,21 @@ export function WebShellSidebar({ const deleteCandidateLabel = deleteCandidate ? getCompactSessionLabel(deleteCandidate) : ''; + const groupMenuSelectedColor = + groupMenu?.session.color && + SESSION_GROUP_COLORS.includes(groupMenu.session.color) + ? groupMenu.session.color + : null; const groupMenuSelectedGroupId = + !groupMenuSelectedColor && groupMenu?.session.groupId && groups.some((group) => group.id === groupMenu.session.groupId) ? groupMenu.session.groupId : null; + const menuColorOptions = + colorOptions.length > 0 ? colorOptions : SESSION_GROUP_COLORS; + const groupMenuUngroupedSelected = + groupMenuSelectedGroupId === null && groupMenuSelectedColor === null; const deleteGroupCandidateLabel = deleteGroupCandidate?.name ?? ''; const canSaveGroup = groupName.trim().length > 0 && !groupBusy; const groupEditorTitle = @@ -1590,8 +1678,8 @@ export function WebShellSidebar({ className={styles.sessionActionButton} type="button" disabled={busy} - title={t('sidebar.organize')} - aria-label={t('sidebar.organize')} + title={t('sidebar.sessionGroup')} + aria-label={t('sidebar.sessionGroup')} onClick={(event) => openGroupMenu(event, session)} > @@ -1737,7 +1825,7 @@ export function WebShellSidebar({
- {group ? ( + {section.kind === 'group' && group ? ( <> - ) : ( + ) : section.kind === 'recent' ? ( - )} + ) : null}
{expanded && ( @@ -1928,7 +2016,7 @@ export function WebShellSidebar({ }, { key: 'group', - label: t('sidebar.organize'), + label: t('sidebar.sessionGroup'), icon: , disabled: sessionBusy, onSelect: () => @@ -2008,21 +2096,45 @@ export function WebShellSidebar({ + {menuColorOptions.map((color) => { + const selected = groupMenuSelectedColor === color; + return ( + + ); + })} {groups.map((group) => { const selected = groupMenuSelectedGroupId === group.id; return ( diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 5501f04696f..2dfaccd525d 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -596,7 +596,6 @@ const EN: Messages = { 'sidebar.completedUnread': 'Finished', 'sidebar.pin': 'Pin', 'sidebar.unpin': 'Unpin', - 'sidebar.organize': 'Move to group', 'sidebar.sessionGroup': 'Group', 'sidebar.groupFilter': 'Session group', 'sidebar.groupAll': 'All', @@ -2052,7 +2051,6 @@ const ZH: Messages = { 'sidebar.completedUnread': '刚完成', 'sidebar.pin': '置顶', 'sidebar.unpin': '取消置顶', - 'sidebar.organize': '移动到分组', 'sidebar.sessionGroup': '分组', 'sidebar.groupFilter': '会话分组', 'sidebar.groupAll': '全部',