fix(web-shell): align no-workspace category header with peers (#15897)

* fix(web-shell): align no-workspace category header with peers

* test(web-shell): preserve archived no-workspace header presentation

* docs(web-shell): show sidebar category header comparison
This commit is contained in:
Kun De authored and GitHub committed 2026-10-30 08:23:06 +00:00
1 parent 4d74e86aae
commit 0976826090
6 files changed
+176 -26

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -37,6 +37,22 @@ The sidebar trigger, dialog heading, and non-browse submit button all use
The Recent heading and its region name continue to share the same translation
key; a separate descriptive region label is outside this copy-only update.
## Sidebar category headers
Pinned, Workspaces, No-workspace sessions, and Recent are peer collapsible
categories. Their active sidebar headers use the same height, spacing, muted
text, and trailing expansion chevron, including hover and keyboard-focus reveal
rules. No-workspace sessions retains its independent saved expansion preference
and search behavior. Its header in Archived settings keeps the workspace-entry
presentation.
Chromium comparison with simulated daemon responses (hovering the collapsed
No-workspace sessions category):
| Before | After |
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| ![Workspace-entry header](../../assets/sidebar-category-header-before.png) | ![Peer category header](../../assets/sidebar-category-header-after.png) |
## Shared UI Contract
Use the TypeScript SDK daemon UI exports as the common boundary:
@@ -194,7 +194,9 @@ describe('StandaloneRecents', () => {
const newSession = container.querySelector<HTMLButtonElement>(
'[data-testid="standalone-active-group"] button[aria-label="sidebar.newTask"]',
);
expect(newSession?.parentElement?.className).toContain('invisible');
expect(newSession?.parentElement?.className).toContain(
'projectsHeaderActions',
);
const sessions = container.querySelector(
'[data-testid="standalone-active-group"] > div:nth-child(2)',
);
@@ -202,6 +204,11 @@ describe('StandaloneRecents', () => {
const header = container.querySelector<HTMLButtonElement>(
'[data-testid="standalone-active-group"] > div > button',
)!;
expect(header.parentElement?.className).toContain('projectsHeader');
expect(header.className).toContain('projectsHeaderToggle');
expect(header.querySelector('span + svg')?.classList).toContain(
'lucide-chevron-down',
);
header.focus();
await act(async () =>
header.dispatchEvent(
@@ -209,6 +216,9 @@ describe('StandaloneRecents', () => {
),
);
expect(document.activeElement).not.toBe(header);
expect(header.querySelector('span + svg')?.classList).toContain(
'lucide-chevron-right',
);
await act(async () =>
header.dispatchEvent(
new MouseEvent('click', { bubbles: true, detail: 1 }),
@@ -313,6 +323,16 @@ describe('StandaloneRecents', () => {
it('loads only the requested archived group', async () => {
const { renderSession } = await render({ archiveState: 'archived' });
const group = container.querySelector(
'[data-testid="standalone-archived-group"]',
)!;
expect(group.classList).toContain('mb-3');
const header = group.querySelector('button[aria-expanded]')!;
expect(header.parentElement?.className).toContain('headerRow');
expect(header.querySelector('svg')?.classList).toContain(
'lucide-circle-dashed',
);
expect(header.querySelector('span + svg')).toBeNull();
expect(container.textContent).toContain('Archived chat');
expect(container.textContent).not.toContain('Active chat');
expect(renderSession).toHaveBeenCalledWith(
@@ -5,7 +5,12 @@ import {
useState,
type ReactNode,
} from 'react';
import { CircleDashedIcon, SquarePenIcon } from 'lucide-react';
import {
ChevronDownIcon,
ChevronRightIcon,
CircleDashedIcon,
SquarePenIcon,
} from 'lucide-react';
import {
STANDALONE_SESSIONS_CAPABILITY,
type DaemonSessionArchiveState,
@@ -469,7 +474,11 @@ export function StandaloneRecents({
return (
<>
<section
className={`${workspaceStyles.section} mb-3`}
className={
archiveState === 'active'
? workspaceStyles.section
: `${workspaceStyles.section} mb-3`
}
aria-label={
archiveState === 'active'
? t('sidebar.noWorkspaceSessions')
@@ -477,10 +486,20 @@ export function StandaloneRecents({
}
data-testid={`standalone-${archiveState}-group`}
>
<div className={`${workspaceStyles.headerRow} group`}>
<div
className={
archiveState === 'active'
? sidebarStyles.projectsHeader
: `${workspaceStyles.headerRow} group`
}
>
<button
type="button"
className={workspaceStyles.header}
className={
archiveState === 'active'
? sidebarStyles.projectsHeaderToggle
: workspaceStyles.header
}
aria-expanded={expanded}
disabled={Boolean(query)}
onClick={(event) => {
@@ -490,22 +509,33 @@ export function StandaloneRecents({
if (event.detail > 0) event.currentTarget.blur();
}}
>
<CircleDashedIcon
className={workspaceStyles.folderIcon}
size={14}
strokeWidth={1.4}
aria-hidden="true"
/>
<span className={workspaceStyles.headerContent}>
<span className={workspaceStyles.name}>
{t('sidebar.noWorkspaceSessions')}
</span>
</span>
{archiveState === 'active' ? (
<>
<span>{t('sidebar.noWorkspaceSessions')}</span>
{expanded ? (
<ChevronDownIcon size={14} aria-hidden="true" />
) : (
<ChevronRightIcon size={14} aria-hidden="true" />
)}
</>
) : (
<>
<CircleDashedIcon
className={workspaceStyles.folderIcon}
size={14}
strokeWidth={1.4}
aria-hidden="true"
/>
<span className={workspaceStyles.headerContent}>
<span className={workspaceStyles.name}>
{t('sidebar.noWorkspaceSessions')}
</span>
</span>
</>
)}
</button>
{archiveState === 'active' && onNewSession && (
<div
className={`${sidebarStyles.workspaceHeaderActions} invisible group-hover:visible group-focus-within:visible`}
>
<div className={sidebarStyles.projectsHeaderActions}>
<button
className={sidebarStyles.workspaceHeaderAction}
type="button"
@@ -549,7 +579,13 @@ export function StandaloneRecents({
</div>
)}
{(expanded || Boolean(query)) && (
<div className="flex flex-col gap-0.5">
<div
className={
archiveState === 'active'
? sidebarStyles.pinnedSessionList
: 'flex flex-col gap-0.5'
}
>
{displayedSessions.map((session) => {
const isCurrent = session.sessionId === currentSessionId;
return renderSession(session, {
@@ -110,12 +110,10 @@ test('keeps workspace navigation reachable inside a standalone chat @smoke', asy
// Row actions are hidden until the row is hovered.
await workspaceRow.hover();
const newTaskButtons = sidebar.getByRole('button', {
name: 'New session',
exact: true,
});
await expect(newTaskButtons).toHaveCount(2);
await newTaskButtons.last().click();
await workspaceRow
.locator('..')
.getByRole('button', { name: 'New session', exact: true })
.click();
await fillComposer(page, 'Back to the workspace');
await page.locator('[data-web-shell-composer-submit]').click();
@@ -267,6 +265,86 @@ test('no-workspace sessions remain visible beside collapsed Workspaces and persi
);
});
test('no-workspace category matches peer header geometry and expansion affordances @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
capabilities: {
features: [
...standaloneFeatures,
'workspace_qualified_rest_core',
'session_organization',
],
},
standaloneSessions: [standaloneSummary('standalone-header', 'Standalone')],
});
scenario.sessions[0].isPinned = true;
await installScenario(page, scenario, testInfo);
await page.goto('/');
const sidebar = page.getByRole('complementary');
const names = ['Pinned', 'Workspaces', 'No-workspace sessions', 'Recent'];
const toggles = names.map((name) =>
sidebar.getByRole('button', { name, exact: true }).first(),
);
for (const toggle of toggles) {
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
}
const geometry = await Promise.all(
toggles.map((toggle) =>
toggle.evaluate((button) => {
const header = button.parentElement!;
const label = button.querySelector('span')!;
const style = getComputedStyle(button);
return {
height: header.getBoundingClientRect().height,
labelLeft: label.getBoundingClientRect().left,
labelTop:
label.getBoundingClientRect().top -
header.getBoundingClientRect().top,
color: style.color,
marginBottom: getComputedStyle(header.parentElement!).marginBottom,
};
}),
),
);
for (const header of geometry) {
expect(header.height).toBe(32);
expect(header.labelLeft).toBe(geometry[1].labelLeft);
expect(header.labelTop).toBe(geometry[1].labelTop);
expect(header.color).toBe(geometry[1].color);
}
expect(geometry[2].marginBottom).toBe('0px');
const toggle = toggles[2];
const chevron = toggle.locator('svg');
await expect(chevron).toHaveCount(1);
await expect(chevron).toHaveAttribute('aria-hidden', 'true');
const labelBox = await toggle.locator('span').boundingBox();
const iconBox = await chevron.boundingBox();
expect(iconBox!.x).toBeGreaterThanOrEqual(labelBox!.x + labelBox!.width);
await page.mouse.move(1000, 700);
await expect(chevron).toHaveCSS('opacity', '0');
await toggle.hover();
await expect(chevron).toHaveCSS('opacity', '1');
await page.mouse.move(1000, 700);
await page.keyboard.press('Tab');
await toggle.focus();
await expect(chevron).toHaveCSS('opacity', '1');
const collapsedPath = await chevron.locator('path').getAttribute('d');
await page.keyboard.press('Enter');
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(chevron.locator('path')).not.toHaveAttribute(
'd',
collapsedPath!,
);
await page.keyboard.press('Space');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
test('standalone Recents keeps lifecycle actions on exact standalone routes @smoke', async ({
page,
}, testInfo) => {