Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/serve/acp-http/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ApprovalMode,
BTW_MAX_INPUT_LENGTH,
createDebugLogger,
GROUP_COLOR_OPTIONS,
SessionService,
SessionOrganizationError,
type SessionGroupColor,
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 =
Expand All @@ -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 });
});
Expand Down
84 changes: 84 additions & 0 deletions packages/cli/src/serve/acp-http/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6384,6 +6384,86 @@ 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 rejects group filter without organized view', async () => {
const connId = await initialize();
const streamRes = openStream(connId);
Expand Down Expand Up @@ -6420,6 +6500,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 }) => {
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as path from 'node:path';
import {
APPROVAL_MODES,
BTW_MAX_INPUT_LENGTH,
GROUP_COLOR_OPTIONS,
SessionService,
SessionOrganizationError,
addDaemonRequestAttribute,
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
});
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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({
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/serve/server/session-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SessionService,
SessionOrganizationError,
type SessionArchiveState,
type SessionGroupColor,
} from '@qwen-code/qwen-code-core';
import type {
AcpSessionBridge,
Expand Down Expand Up @@ -211,6 +212,7 @@ function applyOrganization(
organization:
| {
groupId: string | null;
color?: SessionGroupColor | null;
isPinned: boolean;
pinnedAt?: string;
}
Expand All @@ -219,6 +221,7 @@ function applyOrganization(
return {
...session,
groupId: organization?.groupId ?? null,
color: organization?.color ?? null,
Comment thread
wenshao marked this conversation as resolved.
isPinned: organization?.isPinned === true,
...(organization?.pinnedAt !== undefined
? { pinnedAt: organization.pinnedAt }
Expand Down Expand Up @@ -321,7 +324,8 @@ 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;
if (group === 'ungrouped')
return session.groupId == null && session.color == null;
return session.groupId === group;
Comment thread
wenshao marked this conversation as resolved.
Outdated
});
const activityTimeById = new Map(
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/services/session-organization-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading