qwen-code/packages/web-shell/client/components/WorkspaceIndicator.test.tsx
Shaojin Wen 49497f5076
feat(web-shell): color-code each split pane by workspace (#6971)
* feat(web-shell): color-code each split pane by workspace

On a narrow split (split-screen / mobile), it was hard to tell which
workspace each pane belonged to: a pane's header showed only its session
name, and the sole workspace signal — the composer chip at the bottom —
collapsed to a bare folder icon that looked identical for every
workspace, so the workspace was discoverable only by hovering each one.

Surface the workspace where you actually scan — the pane header — and
give each workspace a stable accent color so panes read apart at a
glance and same-workspace panes read as a group:

- Add a colored workspace tag (dot + basename) at the start of each
  pane header on a multi-workspace daemon, and colorize the header
  divider with the same accent. The dot never shrinks, so panes stay
  distinguishable even when the name and session title ellipsize.
- Derive a stable per-workspace color from the workspace's position in
  the daemon's advertised workspaces[], reusing the sidebar
  session-group palette so the two surfaces speak the same color
  language. Extracted into a shared workspaceAccent.module.css.
- Tint the composer workspace chip with the same accent (folder + faint
  background) so it stays distinguishable even in its icon-only compact
  state, instead of a generic folder.

Single-workspace daemons are unchanged: no tag, and the header divider
falls back to the neutral border.

* refactor(web-shell): address review on split-pane workspace accent

- Rename workspaceAccent.module.css -> WorkspaceAccent.module.css to match the
  PascalCase convention used by every other component .module.css; update both
  import sites.
- Hoist the four raw-hex accent colors (red/orange/yellow/green) into shared
  --accent-* theme tokens in App.module.css, and point the workspace accent
  module, the sidebar group dots, and the overview badges at them. The palette
  now has a single source of truth and can't drift between the four surfaces
  (values are unchanged, so rendering is identical).
- Add a compile-time exhaustiveness guard so adding a
  DaemonSessionGroupPresetColor without extending WORKSPACE_ACCENT_COLORS (and
  its CSS class) fails the build instead of silently dropping that accent.
- Give the pane-header workspace tag role="img" so its "Workspace: <name>"
  aria-label is reliably announced; aria-label on a bare span (generic role)
  is not.

* refactor(web-shell): address follow-up review on workspace accent

- Hoist the four --accent-* tokens out of both theme blocks into the
  theme-independent .app scope, so they are declared once (the values do not
  vary by theme) — a genuine single declaration rather than two kept in sync.
- Add a dev-only runtime check that every accent color has a matching class in
  WorkspaceAccent.module.css, closing the gap the compile-time guard cannot
  cover: CSS modules are typed Record<string, string>, so a renamed/removed
  class would otherwise silently drop that color's accent.
- Rename the "same workspace same color" test to describe what it actually
  asserts (a stable color per cwd, and distinct colors across workspaces).

* refactor(web-shell): address second follow-up review on workspace accent

- WorkspaceIndicator tests: assert on imported CSS-module class names instead of
  string literals, so a CSS-module naming change can't silently make the
  substring checks vacuous; add an expanded-mode (non-compact) accent test so a
  refactor that gated the accent on `compact` would be caught.
- workspaceColor.ts: run the CSS-class contract check unconditionally — throw in
  dev, but console.error in production — so a missing class in a prod build is
  at least diagnosable instead of a silent accent drop.
- WorkspaceAccent.module.css: correct the docstring to state exactly which
  tokens come from where — red/orange/yellow/green from --accent-* in
  App.module.css, blue/purple deliberately reusing the --agent-* brand tokens.

---------

Co-authored-by: wenshao <wenshao@example.com>
2026-07-16 02:38:58 +00:00

194 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { describe, expect, it, vi } from 'vitest';
import { getTranslator } from '../i18n';
import { WorkspaceIndicator } from './WorkspaceIndicator';
import styles from './ChatEditor.module.css';
import accentStyles from './WorkspaceAccent.module.css';
// Radix Tooltip only mounts its content while open, and opens on a mouse
// `pointermove` over the trigger after `delayDuration`. jsdom has no
// `PointerEvent`, so a plain bubbling `pointermove` Event stands in (Radix reads
// `event.pointerType`, which is `undefined` here → treated as non-touch).
function openTooltip(chip: HTMLElement) {
act(() => {
chip.dispatchEvent(new Event('pointermove', { bubbles: true }));
vi.advanceTimersByTime(300);
});
}
describe('WorkspaceIndicator', () => {
it('reveals the full cwd via a hover tooltip, not a native title', () => {
vi.useFakeTimers();
const name = 'api';
const title = '/work/services/a-very-long-web-shell-workspace-path/api';
const ariaLabel = `Workspace: ${name}`;
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<WorkspaceIndicator name={name} title={title} ariaLabel={ariaLabel} />,
);
});
const chip = container.querySelector<HTMLElement>(
`[aria-label="${ariaLabel}"]`,
);
if (!chip) throw new Error('workspace chip was not rendered');
expect(chip.tagName).toBe('OUTPUT');
expect(chip.textContent).toContain(name);
// Non-interactive chip: no button, and no native `title` — the full cwd
// rides in the Radix hover tooltip, matching the git branch chip.
expect(container.querySelector('button')).toBeNull();
expect(chip.getAttribute('title')).toBeNull();
expect(chip.getAttribute('data-web-shell-workspace-title')).toBe(title);
// The headline behaviour: hovering renders the Radix tooltip with the full
// cwd (guards against it rendering the short `name` or nothing at all).
expect(document.querySelector('[role="tooltip"]')).toBeNull();
openTooltip(chip);
const tooltip = document.querySelector('[role="tooltip"]');
expect(tooltip?.textContent).toBe(title);
expect(tooltip?.textContent).not.toBe(name);
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
it('keeps the full cwd discoverable when the name collapses in compact mode', () => {
vi.useFakeTimers();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<WorkspaceIndicator
name="api"
title="/work/api"
ariaLabel="Workspace: api"
compact
/>,
);
});
const chip = container.querySelector<HTMLElement>(
'[data-web-shell-workspace]',
);
if (!chip) throw new Error('workspace chip was not rendered');
// Compact must actually apply the icon-only class...
expect(chip.className).toContain('workspaceChipCompact');
expect(chip.getAttribute('data-web-shell-workspace-title')).toBe(
'/work/api',
);
// ...and the tooltip must still reveal the cwd once the name is hidden, so a
// narrow / mobile composer can tell which workspace the pane targets.
openTooltip(chip);
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(
'/work/api',
);
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
it('tints the chip with the workspace accent so it stays distinct when compact', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<WorkspaceIndicator
name="api"
title="/work/api"
ariaLabel="Workspace: api"
color="blue"
compact
/>,
);
});
const chip = container.querySelector<HTMLElement>(
'[data-web-shell-workspace]',
);
if (!chip) throw new Error('workspace chip was not rendered');
// The accent (color-name class + the accented modifier) rides on the chip
// even in compact mode, where the name is hidden — so the icon-only chip is
// still distinguishable per workspace instead of a generic folder. Assert on
// the imported class names (not string literals) so a CSS-module naming
// change can't silently make these substring checks meaningless.
expect(chip.className).toContain(styles.workspaceChipCompact);
expect(chip.className).toContain(accentStyles.blue);
expect(chip.className).toContain(styles.workspaceChipAccented);
act(() => root.unmount());
container.remove();
});
it('applies the accent in expanded mode too, not only when compact', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<WorkspaceIndicator
name="infra"
title="/work/infra"
ariaLabel="Workspace: infra"
color="purple"
/>,
);
});
const chip = container.querySelector<HTMLElement>(
'[data-web-shell-workspace]',
);
if (!chip) throw new Error('workspace chip was not rendered');
// Guards against a refactor that accidentally gates the accent on `compact`:
// the accent classes must be present, and the compact class absent.
expect(chip.className).toContain(accentStyles.purple);
expect(chip.className).toContain(styles.workspaceChipAccented);
expect(chip.className).not.toContain(styles.workspaceChipCompact);
act(() => root.unmount());
container.remove();
});
it('adds no accent classes when no color is given', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<WorkspaceIndicator name="api" title="/work/api" ariaLabel="ws" />,
);
});
const chip = container.querySelector<HTMLElement>(
'[data-web-shell-workspace]',
);
expect(chip?.className).not.toContain(styles.workspaceChipAccented);
act(() => root.unmount());
container.remove();
});
it('localizes the accessible workspace label', () => {
expect(getTranslator('en')('workspace.paneLabel', { name: 'api' })).toBe(
'Workspace: api',
);
expect(getTranslator('zh-CN')('workspace.paneLabel', { name: 'api' })).toBe(
'工作区api',
);
});
});