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

@ -0,0 +1,53 @@
# Sidebar App Rail Prototype Fidelity
- Slice date: 2026-04-26
- Executable harness: `packages/desktop/scripts/e2e-cdp-smoke.mjs`
- Command:
`cd packages/desktop && npm run e2e:cdp`
- Result: pass
- Passing artifact directory:
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-26T01-46-17-523Z/`
## Scenario
1. Launch the real Electron app with isolated HOME, runtime, user-data, and a
fake dirty Git workspace.
2. Open the fake project through the desktop directory picker path.
3. Send the first composer prompt and approve the fake command request.
4. Wait for the fake ACP response and changed-files summary.
5. Assert the left sidebar uses compact top app actions, project/thread browser
sections, and a persistent bottom Settings row.
6. Continue the existing assistant actions, changed-files summary, review
drawer, compact layout, settings, terminal, discard safety, and commit smoke
path.
## Assertions
- `data-testid="sidebar-app-actions"` and
`data-testid="sidebar-footer-settings"` are present in the real Electron DOM.
- Top app action labels are `New Thread`, `Open Project`, and `Models`.
- The old `.sidebar-toolbar` is absent.
- Bottom Settings is below the project/thread browser and contained in the
sidebar.
- Sidebar width stays compact at `272` px in the default viewport.
- App action rows are `32` px high, the project row is `39.75` px high, and the
thread row is `36` px high.
- Sidebar rows and regions have no horizontal overflow.
- Sidebar text does not expose fake ACP session IDs, `Connected to ...`
protocol text, or temp full paths.
- Console errors: 0.
- Failed local network requests: 0.
## Artifacts
- `sidebar-app-rail.json`
- `initial-workspace.png`
- `completed-workspace.png`
- `electron.log`
- `summary.json`
## Known Uncovered Risk
The harness covers one project, one thread, and bounded labels. A dedicated
long-label CDP path is still needed for very long project names, branch names,
model names, and review-open compact widths.

View file

@ -22,6 +22,103 @@ execution order, verification, decisions, and remaining work.
## Codex Alignment Progress
### Completed Slice: Sidebar App Rail Prototype Fidelity
Status: completed in iteration 18.
Goal: make the left sidebar read more like the `home.jpg` prototype by moving
primary app actions into compact top rows, pinning Settings to the bottom, and
tightening project/thread row density without exposing raw paths or prompt
noise.
User-visible value: users get a clearer desktop-native navigation rail: start
a thread, open a project, reach model/settings, scan projects, scan threads,
and find Settings at the expected persistent bottom position.
Expected files:
- `packages/desktop/src/renderer/components/layout/ProjectSidebar.tsx`
- `packages/desktop/src/renderer/styles.css`
- `packages/desktop/scripts/e2e-cdp-smoke.mjs`
- `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx`
- `.qwen/e2e-tests/electron-desktop/sidebar-app-rail-fidelity.md`
- `design/qwen-code-electron-desktop-implementation-plan.md`
Acceptance criteria:
- Sidebar primary actions render as compact icon+label rows at the top.
- Settings is available as a persistent bottom row and no longer competes in
the top project toolbar.
- Project and thread rows stay compact, active rows keep a subtle left accent,
and long project/thread/model labels remain truncated.
- Thread rows do not show raw full paths or protocol/session IDs.
- Real Electron CDP coverage records sidebar geometry and fails if the
navigation loses the top action group or bottom Settings placement.
Verification:
- Unit/component test command:
`cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/components/layout/WorkspacePage.test.tsx`
- Syntax command: `node --check packages/desktop/scripts/e2e-cdp-smoke.mjs`
- Build/typecheck/lint commands:
`cd packages/desktop && npm run typecheck && npm run lint && npm run build`
- Real Electron harness:
`cd packages/desktop && npm run e2e:cdp`
- Harness path: `packages/desktop/scripts/e2e-cdp-smoke.mjs`
- E2E scenario steps: launch real Electron with isolated HOME/runtime/user-data
and fake ACP, open the fake project, send/approve the prompt, assert the
populated sidebar app rail layout, continue the existing review/settings/
terminal/commit smoke, and capture first-viewport screenshots and JSON
metrics.
- E2E assertions: top app action rows include New Thread/Open Project/Models,
bottom Settings is visually below the project/thread lists, rows stay under
the compact height limit, sidebar width remains compact at desktop and
compact widths, and no sidebar row overflows horizontally.
- Diagnostic artifacts: `sidebar-app-rail.json`, `initial-workspace.png`,
`completed-workspace.png`, Electron log, and summary JSON under
`.qwen/e2e-tests/electron-desktop/artifacts/`.
- Required skills applied: `frontend-design` for prototype-constrained
information hierarchy and density; `electron-desktop-dev` for real Electron
CDP verification; `brainstorming` applied by selecting the smallest recorded
fidelity gap, using the prototype over a new visual direction.
Notes and decisions:
- The slice keeps the existing local server, preload, IPC, ACP, review, and
settings behavior unchanged; this is a renderer layout and style fidelity
pass.
- `frontend-design` is applied with the Ralph constraint that `home.jpg` wins:
the sidebar should become quieter and more navigational, not more decorative.
- `electron-desktop-dev` applies because sidebar layout and navigation order
must be verified in the real Electron shell with actual viewport geometry.
Verification results:
- `node --check packages/desktop/scripts/e2e-cdp-smoke.mjs` passed.
- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/components/layout/WorkspacePage.test.tsx`
passed.
- `cd packages/desktop && npm run typecheck` passed.
- `cd packages/desktop && npm run lint` passed.
- `cd packages/desktop && npm run build` passed.
- `cd packages/desktop && npm run e2e:cdp` passed after launching real
Electron over CDP, opening the fake project, sending/approving the fake ACP
prompt, checking the new sidebar app rail metrics, and completing the
existing review, settings, terminal, discard safety, and commit workflows.
- Passing artifacts:
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-26T01-46-17-523Z/`.
- Key recorded metrics: sidebar width `272`, top app action rows
`32` px high, project row `39.75` px high, thread row `36` px high, bottom
Settings row `32` px high, no legacy sidebar toolbar, no sidebar overflows,
and no console errors or failed local requests.
Next work:
- Continue prototype fidelity by reducing the remaining topbar/status pill
weight and making the title/action cluster closer to the slim `home.jpg`
header.
- Add focused long branch/model/project-name CDP coverage with review open,
since compact review and composer chips rely on truncation to avoid overflow.
### Completed Slice: Inline Tool Activity Prototype Fidelity
Status: completed in iteration 17.

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;
}