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
117 changes: 117 additions & 0 deletions .qwen/e2e-tests/session-group-custom-hex-colors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# E2E Test Plan: Custom Hex Session Group Colors

## Scope

Validate issue #6744 across daemon persistence, REST/SDK behavior, WebShell
editing, and preset quick-tag regression boundaries.

## Baseline dry-run

Use the globally installed CLI before the local build:

```bash
qwen --version
```

Create or update a named session group with `#12ABEF` through the existing
session-groups endpoint. Expected baseline: HTTP 400 with
`code=invalid_group_color`; the WebShell editor exposes only six presets.

## Group A: core contract

Command:

```bash
cd packages/core
npx vitest run src/services/session-organization-service.test.ts
```

Expected after implementation:

- create/update accepts `#12ABEF` (including accidental surrounding
whitespace) and returns `#12abef`;
- list/restart preserves the canonical value;
- malformed Hex values return `invalid_group_color`;
- session quick tags still reject Hex;
- the preset catalog remains unchanged.

## Group B: daemon transport and SDK

Commands:

```bash
cd packages/cli
npx vitest run src/serve/server.test.ts src/serve/acp-http/transport.test.ts

cd ../sdk-typescript
npx vitest run test/unit/DaemonClient.test.ts
```

Expected after implementation: REST and ACP group mutations round-trip Hex;
session organization remains preset-only; SDK group types and responses expose
the custom value.

## Group C: WebShell UI

Command:

```bash
cd packages/web-shell
npx vitest run client/components/sidebar/WebShellSidebar.test.tsx
```

Expected after implementation:

- Create/Rename group offers a Custom option.
- The native color input and Hex text field stay synchronized.
- Invalid text keeps the picker on the last valid custom color.
- Invalid Hex disables Save and exposes an accessible error.
- Existing custom values reopen in Custom mode.
- Custom group dots use the persisted Hex color.
- Preset selection remains unchanged.

## Build verification

```bash
npm run format
npm run build
npm run typecheck
npm run bundle
```

## Manual WebShell check

Use a unique temporary workspace and session name:

```bash
export QWEN_RUNTIME_DIR="$(mktemp -d /tmp/qwen-hex-groups.XXXXXX)"
node dist/cli.js serve --web
```

In the WebShell, create `Hex demo` with `#12ABEF`, reload, rename it, switch to
a preset, then back to Custom. Confirm the dot color and lowercase Hex persist.
Also confirm a quick session tag still offers only the six presets.

## Results

Verified on macOS:

- Baseline global `qwen` was `0.19.4-dataworks.0`; it predates session
organization, so the live daemon baseline was skipped. The pre-change main
source was used as the reproducible baseline: non-preset group colors throw
`invalid_group_color`.
- Core contract: 24 passed.
- REST named-group Hex path: 1 passed (674 unrelated tests skipped).
- ACP HTTP named-group Hex path: 1 passed (271 unrelated tests skipped).
- TypeScript SDK: 229 passed.
- WebShell sidebar: 61 passed; only pre-existing React `act()` warnings.
- Full `server.test.ts`: 674 passed, 1 unrelated failure in extension-update
status handling (expected 202, received 200).
- Root build passes after syncing upstream main with the
`ScheduledTasksDialog` import fix from #6748. Core and WebShell package
typechecks pass.

The WebShell editor was rendered against a local fixture daemon to capture the
picker + Hex field screenshot in the PR. Persistence and normalization were
verified through the live REST path and focused tests. Windows and Linux
behavior is delegated to CI.
Binary file added docs/assets/session-group-custom-hex-colors.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 63 additions & 0 deletions docs/design/session-group-custom-hex-colors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Custom Hex Colors for Named Session Groups

## Problem

Named session groups currently share the six-value color enum used by quick
session color tags. The daemon rejects any other value with
`invalid_group_color`, the TypeScript SDK exposes the same closed union, and the
WebShell editor only offers a preset select. Users cannot align named groups
with an existing project palette or visually distinguish a larger group
catalog.

Tracked by [#6744](https://github.com/QwenLM/qwen-code/issues/6744).

## Proposed changes

| Layer | Change |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Core | Split preset session-tag colors from named-group display colors. Named groups accept presets or six-digit `#RRGGBB`; quick tags remain preset-only. Normalize valid Hex values to lowercase before persistence. |
| REST and ACP | Keep quick-tag validation preset-only and pass named-group colors to core validation. |
| TypeScript SDK | Export preset and Hex color types. Group input/output uses their union; session organization continues to use preset colors. |
| WebShell | Keep preset choices and add a Custom option with a native color picker and Hex text field. Render custom group dots with an inline background color. |

## Decisions

- Accept only six-digit `#RRGGBB`. Three-, four-, and eight-digit forms are
rejected so every persisted value has one predictable shape.
- Trim surrounding whitespace and canonicalize Hex values to lowercase in
core. Clients may normalize earlier for immediate feedback, but core remains
authoritative.
- Do not expand quick session color tags. Their six-value catalog remains a
compact ordering/filter dimension and stays backward compatible.
- Keep the sidecar schema version at 1. The stored field remains a string and
older preset values remain valid.
- Existing clients that do not recognize a Hex class should fail safely. The
WebShell renders Hex group dots through an inline `background-color`.

## Files

- `packages/core/src/services/session-organization-service.ts`
- `packages/core/src/services/session-organization-service.test.ts`
- `packages/cli/src/serve/routes/session.ts`
- `packages/cli/src/serve/acp-http/dispatch.ts`
- `packages/cli/src/serve/server/session-list.ts`
- `packages/acp-bridge/src/bridgeTypes.ts`
- `packages/sdk-typescript/src/daemon/types.ts`
- `packages/sdk-typescript/src/daemon/index.ts`
- `packages/sdk-typescript/src/index.ts`
- `packages/web-shell/client/components/sidebar/WebShellSidebar.tsx`
- `packages/web-shell/client/components/SessionOverviewPanel.tsx`
- `packages/web-shell/client/components/sidebar/WebShellSidebar.module.css`
- `packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx`
- `packages/web-shell/client/i18n.tsx`

## Out of scope

- Custom colors for quick session tags.
- Alpha channels, gradients, named CSS colors, or short Hex forms.
- Changing the group sidecar format or migrating existing values.

## Open questions

None. The existing structured error and group persistence paths can be extended
without a protocol version bump.
4 changes: 2 additions & 2 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import type {
ApprovalMode,
SessionGroupColor,
SessionGroupPresetColor,
} from '@qwen-code/qwen-code-core';
import type {
CancelNotification,
Expand Down Expand Up @@ -345,7 +345,7 @@ export interface BridgeSessionSummary {
pinnedAt?: string;
groupId?: string | null;
/** Quick color grouping tag; mutually exclusive with `groupId` in the UI. */
color?: SessionGroupColor | null;
color?: SessionGroupPresetColor | null;
}

export interface SessionMetadataUpdate {
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/serve/acp-http/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SessionService,
SessionOrganizationError,
type SessionGroupColor,
type SessionGroupPresetColor,
BuiltinAgentRegistry,
SubagentError,
WorkspaceMemoryFileTooLargeError,
Expand Down Expand Up @@ -2015,7 +2016,7 @@ export class AcpDispatcher {
params['color'] !== null &&
(typeof params['color'] !== 'string' ||
!GROUP_COLOR_OPTIONS.includes(
params['color'] as SessionGroupColor,
params['color'] as SessionGroupPresetColor,
))
) {
throw new AcpParamError(
Expand Down Expand Up @@ -2047,7 +2048,7 @@ export class AcpDispatcher {
? { groupId: params['groupId'] as string | null }
: {}),
...('color' in params
? { color: params['color'] as SessionGroupColor | null }
? { color: params['color'] as SessionGroupPresetColor | null }
: {}),
});
this.replyConn(conn, id, { sessionId, ...organization });
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/serve/acp-http/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6529,13 +6529,20 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
jsonrpc: '2.0',
id: 70,
method: '_qwen/workspace/session_groups/create',
params: { workspaceCwd: '/ws', name: 'Frontend', color: 'blue' },
params: {
workspaceCwd: '/ws',
name: 'Frontend',
color: '#12ABEF',
},
});
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' });
expect(group).toMatchObject({
name: 'Frontend',
color: '#12abef',
});

await post(connId, {
jsonrpc: '2.0',
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
runWithoutDebugLogSession,
type ApprovalMode,
type SessionGroupColor,
type SessionGroupPresetColor,
type SessionArchiveState,
} from '@qwen-code/qwen-code-core';
import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts';
Expand Down Expand Up @@ -2074,7 +2075,7 @@ export function registerSessionRoutes(
rawColor !== undefined &&
rawColor !== null &&
(typeof rawColor !== 'string' ||
!GROUP_COLOR_OPTIONS.includes(rawColor as SessionGroupColor))
!GROUP_COLOR_OPTIONS.includes(rawColor as SessionGroupPresetColor))
) {
res.status(400).json({
error: '`color` must be a supported color or null',
Expand All @@ -2092,7 +2093,7 @@ export function registerSessionRoutes(
? { groupId: rawGroupId as string | null }
: {}),
...(rawColor !== undefined
? { color: rawColor as SessionGroupColor | null }
? { color: rawColor as SessionGroupPresetColor | null }
: {}),
});
res.status(200).json({ sessionId, ...organization });
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7868,8 +7868,9 @@ describe('createServeApp', () => {
request(app).post(
`/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`,
),
).send({ name: 'Frontend', color: 'blue' });
).send({ name: 'Frontend', color: '#12ABEF' });
expect(groupRes.status).toBe(201);
expect(groupRes.body.group.color).toBe('#12abef');
const groupId = groupRes.body.group.id as string;

const updateRes = await host(
Expand All @@ -7878,12 +7879,12 @@ describe('createServeApp', () => {
WS_BOUND,
)}/session-groups/${encodeURIComponent(groupId)}`,
),
).send({ name: 'UI', color: 'purple', order: 4 });
).send({ name: 'UI', color: '#FEDCBA', order: 4 });
expect(updateRes.status).toBe(200);
expect(updateRes.body.group).toMatchObject({
id: groupId,
name: 'UI',
color: 'purple',
color: '#fedcba',
order: 4,
});

Expand Down Expand Up @@ -7986,7 +7987,7 @@ describe('createServeApp', () => {

const invalidColorBody = await host(
request(app).patch(`/session/${sessionId}/organization`),
).send({ color: 'pink' });
).send({ color: '#12abef' });
expect(invalidColorBody.status).toBe(400);
expect(invalidColorBody.body).toMatchObject({
code: 'invalid_session_organization',
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/serve/server/session-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
SessionService,
SessionOrganizationError,
type SessionArchiveState,
type SessionGroupColor,
type SessionGroupPresetColor,
} from '@qwen-code/qwen-code-core';
import type {
AcpSessionBridge,
Expand Down Expand Up @@ -364,7 +364,7 @@ function applyOrganization(
organization:
| {
groupId: string | null;
color?: SessionGroupColor | null;
color?: SessionGroupPresetColor | null;
isPinned: boolean;
pinnedAt?: string;
}
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/services/session-organization-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,26 @@ describe('SessionOrganizationService', () => {
);
});

it('accepts and normalizes custom hex colors for named groups', async () => {
const group = await service.createGroup({
name: 'Custom',
color: ' #12ABef ' as never,
});
expect(group.color).toBe('#12abef');

const updated = await service.updateGroup(group.id, {
color: ' #FEDCBA ' as never,
});
expect(updated.color).toBe('#fedcba');

const catalog = await service.listGroups();
expect(catalog.groups[0]?.color).toBe('#fedcba');
expect(catalog.colorOptions).toEqual(GROUP_COLOR_OPTIONS);

const restarted = new SessionOrganizationService(cwd);
expect((await restarted.listGroups()).groups[0]?.color).toBe('#fedcba');
});

it('rejects invalid group names and colors', async () => {
await expect(
service.createGroup({ name: 'Bad\tName', color: 'blue' }),
Expand Down Expand Up @@ -118,6 +138,13 @@ describe('SessionOrganizationService', () => {
code: 'invalid_group_color',
field: 'color',
});

await expect(
service.createGroup({ name: 'Short Hex', color: '#abc' }),
).rejects.toMatchObject({
code: 'invalid_group_color',
field: 'color',
});
});

it('assigns new group order after the current maximum order', async () => {
Expand Down Expand Up @@ -386,6 +413,18 @@ describe('SessionOrganizationService', () => {
color: 'pink' as never,
}),
).rejects.toMatchObject({ code: 'invalid_group_color', field: 'color' });

await expect(
service.updateSessionOrganization(sessionIdA, {
color: '#12abef' as never,
}),
).rejects.toMatchObject({ code: 'invalid_group_color', field: 'color' });

await expect(
service.updateSessionOrganization(sessionIdA, {
color: ' blue ' as never,
}),
).rejects.toMatchObject({ code: 'invalid_group_color', field: 'color' });
});

it('keeps color, group, and pin independent in the store', async () => {
Expand Down
Loading
Loading