feat(desktop): align sidebar app rail

This commit is contained in:
DragonnZhang 2026-04-26 09:48:33 +08:00
parent 3bf70ebb57
commit 18d5552cc3
6 changed files with 450 additions and 102 deletions

View file

@ -101,6 +101,7 @@ async function main() {
await setFieldByAriaLabel('Message', '');
await assertConversationChangesSummary('conversation-changes-summary.json');
await waitForSelector('[data-testid="thread-list"]');
await assertSidebarAppRail('sidebar-app-rail.json');
await clickButton('Review Changes');
await waitForText('README.md');
@ -419,6 +420,8 @@ async function assertWorkbenchLandmarks() {
return [
'desktop-workspace',
'project-sidebar',
'sidebar-app-actions',
'sidebar-footer-settings',
'workspace-topbar',
'workspace-grid',
'chat-thread',
@ -621,6 +624,172 @@ async function assertRalphWorkspaceLayout(fileName) {
}
}
async function assertSidebarAppRail(fileName) {
const metrics = await evaluate(`(() => {
const rectFor = (element) => {
if (!element) {
return null;
}
const rect = element.getBoundingClientRect();
return {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height
};
};
const overflows = (element) =>
Boolean(element && element.scrollWidth > element.clientWidth + 4);
const sidebar = document.querySelector('[data-testid="project-sidebar"]');
const appActions = document.querySelector('[data-testid="sidebar-app-actions"]');
const footerSettings = document.querySelector(
'[data-testid="sidebar-footer-settings"]'
);
const projectList = document.querySelector('[data-testid="project-list"]');
const threadList = document.querySelector('[data-testid="thread-list"]');
const rowSelector =
'.sidebar-action-row, .project-row, .session-row';
const rows = [...document.querySelectorAll(rowSelector)].map((row) => {
const label =
row.getAttribute('aria-label') ||
row.getAttribute('title') ||
row.textContent.trim();
return {
label,
text: row.textContent.trim(),
rect: rectFor(row),
scrollWidth: row.scrollWidth,
clientWidth: row.clientWidth,
overflows: overflows(row)
};
});
return {
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
sidebar: rectFor(sidebar),
appActions: rectFor(appActions),
footerSettings: rectFor(footerSettings),
projectList: rectFor(projectList),
threadList: rectFor(threadList),
hasLegacyToolbar: document.querySelector('.sidebar-toolbar') !== null,
appActionLabels: appActions
? [...appActions.querySelectorAll('button')].map(
(button) => button.getAttribute('aria-label') || ''
)
: [],
footerLabel:
footerSettings?.getAttribute('aria-label') ||
footerSettings?.textContent.trim() ||
'',
rows,
sidebarText: sidebar?.innerText ?? '',
overflows: {
sidebar: overflows(sidebar),
appActions: overflows(appActions),
projectList: overflows(projectList),
threadList: overflows(threadList),
footerSettings: overflows(footerSettings)
}
};
})()`);
await writeFile(
join(artifactDir, fileName),
`${JSON.stringify(metrics, null, 2)}\n`,
'utf8',
);
const missing = [
'sidebar',
'appActions',
'footerSettings',
'projectList',
'threadList',
].filter((key) => metrics[key] === null);
if (missing.length > 0) {
throw new Error(`Missing sidebar app rail rects: ${missing.join(', ')}`);
}
if (metrics.hasLegacyToolbar) {
throw new Error('Sidebar should not render the old project toolbar.');
}
for (const expectedLabel of ['New Thread', 'Open Project', 'Models']) {
if (!metrics.appActionLabels.includes(expectedLabel)) {
throw new Error(
`Sidebar app actions missing ${expectedLabel}: ${metrics.appActionLabels.join(
', ',
)}`,
);
}
}
if (metrics.footerLabel !== 'Settings') {
throw new Error(
`Sidebar footer label should be Settings: ${metrics.footerLabel}`,
);
}
if (metrics.sidebar.width < 236 || metrics.sidebar.width > 320) {
throw new Error(
`Sidebar width is no longer compact: ${metrics.sidebar.width}`,
);
}
if (metrics.appActions.top > metrics.sidebar.top + 24) {
throw new Error('Sidebar app actions are not pinned near the top.');
}
if (metrics.footerSettings.bottom > metrics.sidebar.bottom + 1) {
throw new Error('Sidebar Settings footer overflows the sidebar.');
}
if (metrics.footerSettings.top < metrics.threadList.bottom - 1) {
throw new Error(
'Sidebar Settings should stay below the project/thread browser.',
);
}
const tallRows = metrics.rows.filter((row) => row.rect.height > 44);
if (tallRows.length > 0) {
throw new Error(
`Sidebar rows are too tall for the compact rail: ${JSON.stringify(
tallRows,
)}`,
);
}
const overflowingRows = metrics.rows.filter((row) => row.overflows);
if (overflowingRows.length > 0) {
throw new Error(
`Sidebar rows overflow horizontally: ${JSON.stringify(overflowingRows)}`,
);
}
if (Object.values(metrics.overflows).some(Boolean)) {
throw new Error(
`Sidebar rail regions overflow horizontally: ${JSON.stringify(
metrics.overflows,
)}`,
);
}
if (
metrics.sidebarText.includes('session-e2e') ||
metrics.sidebarText.includes('/tmp/') ||
metrics.sidebarText.includes('Connected to')
) {
throw new Error(
`Sidebar leaked protocol or path noise: ${metrics.sidebarText}`,
);
}
}
async function assertConversationChangesSummary(fileName) {
await waitForSelector('[data-testid="conversation-changes-summary"]');
const snapshot = await evaluate(`(() => {

View file

@ -50,44 +50,49 @@ export function ProjectSidebar({
aria-label="Projects and threads"
data-testid="project-sidebar"
>
<div className="sidebar-toolbar">
<h1>Projects</h1>
<div className="sidebar-toolbar-actions">
<button
aria-label="Settings"
className="sidebar-icon-button"
title="Settings"
type="button"
onClick={onOpenSettings}
>
<SlidersIcon />
<span className="sr-only">Settings</span>
</button>
<button
aria-label="New Thread"
className="sidebar-icon-button"
disabled={loadState.state !== 'ready' || !activeProject}
title="New Thread"
type="button"
onClick={onCreateSession}
>
<NewThreadIcon />
<span className="sr-only">New Thread</span>
</button>
<button
aria-label="Open Project"
className="sidebar-icon-button"
title="Open Project"
type="button"
onClick={onChooseWorkspace}
>
<FolderPlusIcon />
<span className="sr-only">Open Project</span>
</button>
</div>
</div>
<nav
className="sidebar-app-actions"
aria-label="Workspace actions"
data-testid="sidebar-app-actions"
>
<button
aria-label="New Thread"
className="sidebar-action-row"
disabled={loadState.state !== 'ready' || !activeProject}
title="New Thread"
type="button"
onClick={onCreateSession}
>
<NewThreadIcon />
<span>New Thread</span>
</button>
<button
aria-label="Open Project"
className="sidebar-action-row"
title="Open Project"
type="button"
onClick={onChooseWorkspace}
>
<FolderPlusIcon />
<span>Open Project</span>
</button>
<button
aria-label="Models"
className="sidebar-action-row"
title="Models"
type="button"
onClick={onOpenSettings}
>
<SlidersIcon />
<span>Models</span>
</button>
</nav>
<section className="sidebar-section project-navigator">
<div className="sidebar-section-heading">
<h2>Projects</h2>
<span>{projects.length}</span>
</div>
<ProjectList
activeProjectId={activeProjectId}
projects={projects}
@ -107,6 +112,19 @@ export function ProjectSidebar({
onSelect={onSelectSession}
/>
</section>
<div className="sidebar-footer">
<button
aria-label="Settings"
className="sidebar-action-row sidebar-footer-action"
data-testid="sidebar-footer-settings"
title="Settings"
type="button"
onClick={onOpenSettings}
>
<SlidersIcon />
<span>Settings</span>
</button>
</div>
</aside>
);
}

View file

@ -47,6 +47,7 @@ describe('WorkspacePage', () => {
for (const testId of [
'desktop-workspace',
'project-sidebar',
'sidebar-app-actions',
'workspace-topbar',
'workspace-grid',
'chat-thread',
@ -64,9 +65,18 @@ describe('WorkspacePage', () => {
expect(
renderedContainer.querySelector('[data-testid="settings-page"]'),
).toBeNull();
expect(renderedContainer.querySelector('.sidebar-toolbar')).toBeNull();
expect(
renderedContainer.querySelector(
'[data-testid="sidebar-footer-settings"]',
),
).toBeTruthy();
expect(renderedContainer.textContent).toContain('example-workspace');
expect(renderedContainer.textContent).toContain('main');
expect(renderedContainer.textContent).toContain('New Thread');
expect(renderedContainer.textContent).toContain('Open Project');
expect(renderedContainer.textContent).toContain('Models');
expect(
renderedContainer.querySelector('[data-testid="project-sidebar"]')
?.textContent,

View file

@ -96,44 +96,21 @@ summary:focus-visible {
height: 100vh;
min-height: 0;
flex-direction: column;
gap: 10px;
gap: 9px;
overflow: hidden;
padding: 16px 12px 14px;
padding: 12px 10px;
border-right: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(37, 58, 61, 0.95), rgba(22, 28, 29, 0.98)),
radial-gradient(
circle at 32px 0,
rgba(242, 199, 112, 0.08),
transparent 34%
),
#151b1d;
linear-gradient(180deg, rgba(20, 26, 51, 0.98), rgba(13, 17, 32, 0.98)),
#101322;
}
.sidebar-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 30px;
gap: 12px;
padding: 0 4px 4px;
}
.sidebar-toolbar h1,
.topbar h2,
.topbar p,
.panel h3 {
margin: 0;
}
.sidebar-toolbar h1 {
color: rgba(225, 232, 228, 0.62);
font-size: 16px;
font-weight: 720;
letter-spacing: 0;
line-height: 1;
}
.eyebrow,
.message-role {
color: var(--muted);
@ -143,35 +120,48 @@ summary:focus-visible {
text-transform: uppercase;
}
.sidebar-toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
.sidebar-app-actions {
display: grid;
gap: 2px;
padding-bottom: 4px;
}
.sidebar-icon-button {
position: relative;
.sidebar-action-row {
display: grid;
width: 24px;
height: 24px;
place-items: center;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
min-height: 32px;
gap: 8px;
padding: 0 8px;
border-radius: 6px;
background: transparent;
color: rgba(225, 232, 228, 0.58);
color: rgba(225, 232, 242, 0.72);
font-size: 13px;
font-weight: 680;
letter-spacing: 0;
text-align: left;
transition:
background 140ms ease,
color 140ms ease,
transform 140ms ease;
color 140ms ease;
}
.sidebar-icon-button:not(:disabled):hover {
background: rgba(238, 244, 239, 0.08);
color: rgba(245, 248, 244, 0.9);
transform: translateY(-1px);
.sidebar-action-row:not(:disabled):hover {
background: rgba(213, 224, 255, 0.07);
color: rgba(247, 249, 255, 0.96);
}
.sidebar-icon-button svg {
.sidebar-action-row svg {
display: block;
width: 16px;
height: 16px;
justify-self: center;
}
.sidebar-action-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sr-only {
@ -188,7 +178,7 @@ summary:focus-visible {
display: flex;
min-height: 0;
flex-direction: column;
gap: 4px;
gap: 3px;
}
.sidebar-section-fill {
@ -213,8 +203,8 @@ summary:focus-visible {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 24px;
padding: 4px 8px 0 36px;
min-height: 22px;
padding: 4px 8px 0;
}
.sidebar-section-heading span {
@ -223,10 +213,10 @@ summary:focus-visible {
.empty-row,
.workspace-path {
min-height: 40px;
padding: 10px 2px;
min-height: 34px;
padding: 8px 2px;
color: rgba(225, 232, 228, 0.52);
font-size: 15px;
font-size: 13px;
font-weight: 680;
}
@ -286,7 +276,7 @@ summary:focus-visible {
}
.project-list {
max-height: 132px;
max-height: 120px;
}
.session-list {
@ -299,7 +289,7 @@ summary:focus-visible {
display: grid;
align-items: center;
width: 100%;
min-height: 42px;
min-height: 36px;
border: 0;
border-radius: 6px;
background: transparent;
@ -311,44 +301,44 @@ summary:focus-visible {
}
.project-row {
grid-template-columns: 28px minmax(0, 1fr);
padding: 6px 8px 6px 6px;
grid-template-columns: 24px minmax(0, 1fr);
padding: 5px 8px 5px 6px;
}
.session-row {
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 8px;
padding: 6px 8px 6px 36px;
column-gap: 7px;
padding: 5px 8px 5px 28px;
}
.project-row-active,
.session-row-active {
background: rgba(238, 244, 239, 0.052);
background: rgba(213, 224, 255, 0.062);
color: var(--text);
}
.project-row-active::before,
.session-row-active::before {
position: absolute;
top: 9px;
bottom: 9px;
top: 7px;
bottom: 7px;
left: 0;
width: 2px;
border-radius: 999px;
background: rgba(242, 199, 112, 0.74);
background: rgba(117, 131, 255, 0.78);
content: '';
}
.project-row:not(.project-row-active):hover,
.session-row:not(.session-row-active):hover {
background: rgba(238, 244, 239, 0.035);
background: rgba(213, 224, 255, 0.045);
color: rgba(248, 250, 247, 0.95);
}
.project-row-icon {
justify-self: start;
width: 18px;
height: 18px;
width: 16px;
height: 16px;
margin-left: 0;
color: rgba(225, 232, 228, 0.82);
}
@ -376,8 +366,8 @@ summary:focus-visible {
.project-row-copy span,
.session-row-title {
font-size: 14px;
font-weight: 680;
font-size: 13px;
font-weight: 660;
line-height: 1.25;
}
@ -419,11 +409,22 @@ summary:focus-visible {
.session-row-meta {
max-width: 48px;
color: rgba(225, 232, 228, 0.54);
font-size: 11px;
font-size: 10px;
font-weight: 720;
line-height: 1;
}
.sidebar-footer {
flex: 0 0 auto;
margin-top: auto;
padding-top: 6px;
border-top: 1px solid rgba(213, 224, 255, 0.08);
}
.sidebar-footer-action {
width: 100%;
}
.session-row-draft {
cursor: default;
}