Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
135 changes: 135 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,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=<id> 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);
Expand Down Expand Up @@ -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 }) => {
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
12 changes: 10 additions & 2 deletions 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,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) => [
Expand Down
Loading
Loading