fix(web-shell): show workspace chip tooltip on narrow composer (#6958)

* fix(web-shell): show workspace chip tooltip on narrow composer

The composer's workspace chip surfaced its full cwd only through a native
`title` attribute, unlike the sibling git-branch and model chips which use a
styled Radix tooltip. On a narrow (split-screen / mobile) composer the chip
ellipsizes or collapses to an icon, so the workspace is discoverable only on
hover — and a native `title` is inconsistent and never fires on touch.

Give WorkspaceIndicator the same Radix tooltip as GitBranchIndicator (with the
full cwd as content), completing the documented "mirrors GitBranchIndicator"
intent. Its visually-hidden tooltip mirror also exposes the cwd to screen
readers, which the native `title` did not do reliably.

* test(web-shell): assert the workspace tooltip renders on hover

Address review feedback: the WorkspaceIndicator tests checked the
`data-web-shell-workspace-title` hook but never opened the tooltip, so a
regression rendering the short name (or nothing) in the Radix `TooltipContent`
would have gone unnoticed. Open the tooltip via a `pointermove` (jsdom has no
`PointerEvent`; Radix opens on mouse move after `delayDuration`) and assert the
portalled `[role="tooltip"]` shows the full cwd — and, in compact mode, assert
the `workspaceChipCompact` icon-only class is actually applied.

* test(web-shell): guard the compact chip before asserting on it

Move the `if (!chip)` null guard above the assertions in the compact-mode test
so a failure to render surfaces the descriptive "workspace chip was not
rendered" error instead of an opaque `expect(undefined)` throw from the
optional-chained access. Matches the first test in the file.

---------

Co-authored-by: wenshao <wenshao@example.com>
This commit is contained in:
Shaojin Wen 2026-07-15 20:32:17 +08:00 committed by GitHub
parent 7a1b182cd1
commit 38429bc100
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 144 additions and 16 deletions

View file

@ -232,7 +232,12 @@ describe('ChatEditor workspace toolbar integration', () => {
});
const chip = container.querySelector('[aria-label="Workspace: api"]');
expect(chip).not.toBeNull();
expect(chip?.getAttribute('title')).toBe('/work/api');
// The full cwd is surfaced via the hover tooltip (mirroring the git branch
// chip), not a native `title` attribute.
expect(chip?.getAttribute('data-web-shell-workspace-title')).toBe(
'/work/api',
);
expect(chip?.getAttribute('title')).toBeNull();
expect(
container.querySelector('[data-web-shell-workspace]'),
).not.toBeNull();
@ -247,7 +252,7 @@ describe('ChatEditor workspace toolbar integration', () => {
expect(
container
.querySelector('[data-web-shell-workspace]')
?.getAttribute('title'),
?.getAttribute('data-web-shell-workspace-title'),
).toBe('api');
});

View file

@ -0,0 +1,108 @@
// @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';
// 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('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',
);
});
});

View file

@ -5,6 +5,12 @@
*/
import styles from './ChatEditor.module.css';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from './ui/tooltip';
function WorkspaceFolderIcon() {
return (
@ -24,7 +30,9 @@ function WorkspaceFolderIcon() {
* session belongs to. Mirrors {@link GitBranchIndicator}; both sit in the
* composer toolbar. Shown only on a multi-workspace daemon (the pane composer
* opts into the `workspace` toolbar action) so it's clear which workspace a
* message goes to.
* message goes to. The full cwd stays in a hover tooltip matching the git
* branch chip so it's still discoverable once the name ellipsizes or
* collapses to an icon on a narrow (split-screen / mobile) composer.
*/
export function WorkspaceIndicator({
name,
@ -38,18 +46,25 @@ export function WorkspaceIndicator({
compact?: boolean;
}) {
return (
<output
className={`${styles.workspaceChip} ${
compact ? styles.workspaceChipCompact : ''
}`}
title={title}
aria-label={ariaLabel}
data-web-shell-workspace
>
<span className={styles.workspaceChipIcon}>
<WorkspaceFolderIcon />
</span>
<span className={styles.workspaceChipText}>{name}</span>
</output>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<output
className={`${styles.workspaceChip} ${
compact ? styles.workspaceChipCompact : ''
}`}
aria-label={ariaLabel}
data-web-shell-workspace
data-web-shell-workspace-title={title}
>
<span className={styles.workspaceChipIcon}>
<WorkspaceFolderIcon />
</span>
<span className={styles.workspaceChipText}>{name}</span>
</output>
</TooltipTrigger>
<TooltipContent side="top">{title}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}