fix(desktop): tighten compact conversation layout

This commit is contained in:
DragonnZhang 2026-04-26 02:33:06 +08:00
parent c4db66afdd
commit c8d5b7e921
5 changed files with 662 additions and 3 deletions

View file

@ -50,6 +50,6 @@
## Known Uncovered Risk ## Known Uncovered Risk
This harness verifies the default 1240 px Electron window. A follow-up compact The default 1240 px window is covered here. The compact desktop width follow-up
viewport pass should assert the same dense message state near the lower is now covered by
supported desktop width. `.qwen/e2e-tests/electron-desktop/compact-dense-conversation.md`.

View file

@ -0,0 +1,60 @@
# Compact Dense Conversation
- Slice date: 2026-04-26
- Executable harness: `packages/desktop/scripts/e2e-cdp-smoke.mjs`
- Command:
`cd packages/desktop && npm run e2e:cdp`
- Result: pass
- Artifact directory:
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T18-31-38-896Z/`
## 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 assistant response with dense repeated file
references.
5. Assert the default-width assistant actions and dense file chips.
6. Resize the real Electron window to the compact desktop bounds near 960 px.
7. Assert compact sidebar, topbar, dense assistant message, file chips, action
row, composer, and collapsed terminal geometry.
8. Restore the default window size and continue the existing review, settings,
terminal, and commit smoke path.
## Assertions
- Compact viewport resolved to 960x608 content pixels.
- Sidebar stayed compact at 236 px and topbar stayed 58 px high.
- The dense assistant message, file chips, and action row stayed inside the
conversation timeline with no horizontal document overflow.
- Required chips remained accessible: `README.md:1`,
`packages/desktop/src/renderer/App.tsx:12:5`, `.env.example`, `Dockerfile`,
`docs/guide.mdx`, and `src/App.vue`.
- Assistant actions remained accessible: `Copy Response`, `Retry Last Prompt`,
and `Open Changes`.
- Compact composer height stayed bounded at about 127 px and did not overflow
its context/action rows.
- The collapsed terminal strip remained docked and closed.
- Console errors: 0.
- Failed local network requests: 0.
## Artifacts
- `compact-dense-conversation.json`
- `compact-dense-conversation.png`
- `compact-summary-visibility-note.json`
- `window-resize-fallback-960x640.json`
- `window-resize-fallback-1240x820.json`
- `assistant-message-actions.json`
- `assistant-message-actions.png`
- `completed-workspace.png`
- `electron.log`
- `summary.json`
## Known Uncovered Risk
This slice covers the dense conversation and composer at compact width. A
follow-up should add a compact-width review drawer assertion because the review
drawer intentionally reduces conversation width.

View file

@ -22,6 +22,108 @@ execution order, verification, decisions, and remaining work.
## Codex Alignment Progress ## Codex Alignment Progress
### Completed Slice: Compact Dense Conversation CDP Coverage
Status: completed in iteration 13.
Goal: extend the real Electron CDP harness so the dense assistant message state
is asserted at the lower supported desktop width, not only at the default
1240 px window size.
User-visible value: long assistant prose, file reference chips, action rows,
changed-file summaries, composer controls, sidebar rows, and the collapsed
terminal remain usable in compact desktop windows without horizontal overflow
or composer overlap.
Expected files:
- `packages/desktop/scripts/e2e-cdp-smoke.mjs`
- `packages/desktop/src/renderer/styles.css`
- `.qwen/e2e-tests/electron-desktop/compact-dense-conversation.md`
- `design/qwen-code-electron-desktop-implementation-plan.md`
Acceptance criteria:
- The CDP harness resizes the real Electron window to the app minimum
960x640-ish compact desktop size after the dense fake ACP assistant response
is visible.
- The compact viewport still shows the workbench landmarks, compact sidebar,
slim topbar, conversation, assistant message, file chips, message actions,
changed-files summary, composer, and collapsed terminal strip.
- Assistant file chips and action buttons stay inside the assistant message and
timeline; document width does not exceed the viewport.
- Composer controls wrap inside the composer instead of overflowing, and the
composer remains contained above the terminal strip.
- The inline changed-files summary remains bounded in the timeline without
horizontal overflow; at compact height it may require normal timeline
scrolling rather than simultaneous visibility with the assistant card.
- The window is restored to the default desktop size before the rest of the
smoke path continues.
Verification:
- Unit/component test command: no renderer unit changes expected.
- 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 Git project, send a prompt, approve the fake
command request, wait for the dense assistant response, assert the default
dense assistant layout, resize the Electron window to the compact desktop
bounds, assert compact geometry and overflow constraints, capture screenshot
and JSON artifacts, restore the default window size, then continue the
existing review/settings/terminal workflow.
- E2E assertions: compact viewport width is near 960 px; sidebar stays compact;
topbar remains slim enough for the viewport; dense assistant chips,
assistant actions, changed-files summary, composer, and terminal strip remain
bounded; compact composer height stays below 154 px; console errors/failed
local requests are absent.
- Diagnostic artifacts: compact dense conversation screenshot and JSON metrics,
plus existing CDP screenshots, Electron log, and summary JSON under
`.qwen/e2e-tests/electron-desktop/artifacts/`.
- Required skills applied: `frontend-design` for prototype-constrained compact
density and overflow expectations; `electron-desktop-dev` for real Electron
CDP window resizing and verification; `brainstorming` applied by selecting
the smallest continuation from the recorded next-work item rather than
introducing new product scope.
Notes and decisions:
- Electron 41 in this test environment does not expose
`Browser.getWindowForTarget` through the remote debugger. The harness first
attempts the browser-level CDP API and then falls back to `window.resizeTo`,
recording a `window-resize-fallback-*.json` artifact when the fallback is
used.
- The first compact run exposed a real density issue: the composer grew to
about 176 px high at the compact viewport. The CSS now shortens the compact
textarea and chips/selectors at the 960 px breakpoint, bringing the compact
composer to about 127 px in the passing CDP artifact.
- At the compact height, the dense assistant card and changed-files summary can
require normal timeline scrolling. The contract is that both remain bounded,
discoverable, and free of horizontal overflow while the composer and terminal
stay docked.
Verification results:
- `node --check packages/desktop/scripts/e2e-cdp-smoke.mjs` 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 launch through real
Electron over CDP, including the compact dense conversation resize path.
- Passing artifacts:
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T18-31-38-896Z/`.
Next work:
- Continue prototype fidelity by reducing remaining card heaviness in the
conversation and changed-files summary so the compact viewport reads closer
to `home.jpg`.
- Add a compact review-drawer CDP assertion so the 960 px width also proves the
conversation and review drawer remain usable together.
### Completed Slice: Dense Assistant File Reference Overflow ### Completed Slice: Dense Assistant File Reference Overflow
Status: completed in iteration 12. Status: completed in iteration 12.

View file

@ -25,11 +25,14 @@ const artifactRoot = join(
'electron-desktop', 'electron-desktop',
'artifacts', 'artifacts',
); );
const defaultWindowBounds = { width: 1240, height: 820 };
const compactWindowBounds = { width: 960, height: 640 };
const consoleErrors = []; const consoleErrors = [];
const failedRequests = []; const failedRequests = [];
let appProcess; let appProcess;
let browserCdp;
let cdp; let cdp;
let artifactDir; let artifactDir;
let workspaceDir; let workspaceDir;
@ -54,6 +57,8 @@ async function main() {
}); });
const target = await waitForCdpTarget(cdpPort); const target = await waitForCdpTarget(cdpPort);
const browserTarget = await waitForBrowserCdp(cdpPort);
browserCdp = await CdpClient.connect(browserTarget.webSocketDebuggerUrl);
cdp = await CdpClient.connect(target.webSocketDebuggerUrl); cdp = await CdpClient.connect(target.webSocketDebuggerUrl);
cdp.onEvent((event) => collectBrowserEvent(event)); cdp.onEvent((event) => collectBrowserEvent(event));
@ -81,6 +86,12 @@ async function main() {
await saveScreenshot('resolved-tool-activity.png'); await saveScreenshot('resolved-tool-activity.png');
await assertAssistantMessageActions('assistant-message-actions.json'); await assertAssistantMessageActions('assistant-message-actions.json');
await saveScreenshot('assistant-message-actions.png'); await saveScreenshot('assistant-message-actions.png');
await setElectronWindowBounds(target.id, compactWindowBounds);
await assertCompactDenseConversationLayout(
'compact-dense-conversation.json',
);
await saveScreenshot('compact-dense-conversation.png');
await setElectronWindowBounds(target.id, defaultWindowBounds);
await clickButton('Copy Response'); await clickButton('Copy Response');
await waitForText('Copied response.'); await waitForText('Copied response.');
await clickButton('Retry Last Prompt'); await clickButton('Retry Last Prompt');
@ -372,6 +383,31 @@ async function waitForCdpTarget(port) {
); );
} }
async function waitForBrowserCdp(port) {
const deadline = Date.now() + 20_000;
let lastError;
while (Date.now() < deadline) {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
const target = await response.json();
if (typeof target.webSocketDebuggerUrl === 'string') {
return target;
}
} catch (error) {
lastError = error;
}
await delay(250);
}
throw new Error(
`Timed out waiting for Electron browser CDP target on port ${port}: ${
lastError instanceof Error ? lastError.message : 'no response'
}`,
);
}
async function assertWorkbenchLandmarks() { async function assertWorkbenchLandmarks() {
const landmarks = await evaluate(`(() => { const landmarks = await evaluate(`(() => {
return [ return [
@ -1093,6 +1129,349 @@ async function assertAssistantMessageActions(fileName) {
} }
} }
async function assertCompactDenseConversationLayout(fileName) {
await waitFor(
'compact dense conversation viewport',
async () => {
const viewport = await evaluate(`({
width: window.innerWidth,
height: window.innerHeight
})`);
return (
viewport.width >= 940 &&
viewport.width <= 1000 &&
viewport.height >= 600 &&
viewport.height <= 680
);
},
10_000,
);
await waitForSelector('[data-testid="assistant-file-references"]');
const snapshot = 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 isContained = (child, parent, tolerance = 1) =>
Boolean(
child &&
parent &&
child.left >= parent.left - tolerance &&
child.right <= parent.right + tolerance
);
const overflows = (element) =>
element ? element.scrollWidth > element.clientWidth + 4 : false;
const message = [...document.querySelectorAll('[data-testid="assistant-message"]')]
.find((candidate) =>
candidate.innerText.includes('E2E fake ACP response received')
);
const timeline = document.querySelector('.chat-timeline');
const summary = document.querySelector(
'[data-testid="conversation-changes-summary"]'
);
const composer = document.querySelector('[data-testid="message-composer"]');
const terminal = document.querySelector('[data-testid="terminal-drawer"]');
const terminalBody = document.querySelector('[data-testid="terminal-body"]');
const terminalToggle = document.querySelector(
'[data-testid="terminal-toggle"]'
);
const preScroll = {
summaryRect: rectFor(summary),
timelineRect: rectFor(timeline),
composerRect: rectFor(composer),
terminalRect: rectFor(terminal)
};
message?.scrollIntoView({ block: 'center', inline: 'nearest' });
const fileReferences = message?.querySelector(
'[data-testid="assistant-file-references"]'
);
const actions = message?.querySelector(
'[data-testid="assistant-message-actions"]'
);
const messageRect = rectFor(message);
const timelineRect = rectFor(timeline);
const composerRect = rectFor(composer);
const chipRects = fileReferences
? [
...fileReferences.querySelectorAll(
'button, .message-file-reference-overflow'
)
].map((chip) => rectFor(chip))
: [];
const actionRects = actions
? [...actions.querySelectorAll('button')].map((button) =>
rectFor(button)
)
: [];
return {
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
document: {
scrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
bodyScrollHeight: document.body.scrollHeight
},
shell: rectFor(document.querySelector('[data-testid="desktop-workspace"]')),
sidebar: rectFor(document.querySelector('[data-testid="project-sidebar"]')),
topbar: rectFor(document.querySelector('[data-testid="workspace-topbar"]')),
grid: rectFor(document.querySelector('[data-testid="workspace-grid"]')),
chat: rectFor(document.querySelector('[data-testid="chat-thread"]')),
timeline: timelineRect,
message: messageRect,
fileReferences: rectFor(fileReferences),
actions: rectFor(actions),
summary: rectFor(summary),
composer: composerRect,
terminal: rectFor(terminal),
terminalExpanded: terminalToggle?.getAttribute('aria-expanded') ?? null,
terminalBodyPresent: terminalBody !== null,
preScroll,
fileReferenceLabels: fileReferences
? [...fileReferences.querySelectorAll('button')].map(
(button) => button.getAttribute('aria-label') || ''
)
: [],
actionLabels: actions
? [...actions.querySelectorAll('button')].map(
(button) => button.getAttribute('aria-label') || ''
)
: [],
chipRects,
actionRects,
summaryVisibleBeforeAssistantScroll: Boolean(
preScroll.summaryRect &&
preScroll.timelineRect &&
preScroll.composerRect &&
preScroll.summaryRect.top >= preScroll.timelineRect.top - 1 &&
preScroll.summaryRect.bottom <= preScroll.composerRect.top + 1
),
summaryContainedBeforeAssistantScroll: isContained(
preScroll.summaryRect,
preScroll.timelineRect
),
messageContained: isContained(messageRect, timelineRect),
actionsContained: isContained(rectFor(actions), messageRect),
composerContained: isContained(composerRect, timelineRect),
terminalDocked: Boolean(
rectFor(terminal) &&
preScroll.composerRect &&
rectFor(terminal).top >= preScroll.composerRect.bottom - 1
),
overflow: {
shell: overflows(document.querySelector('[data-testid="desktop-workspace"]')),
topbar: overflows(document.querySelector('[data-testid="workspace-topbar"]')),
timeline: overflows(timeline),
message: overflows(message),
fileReferences: overflows(fileReferences),
composer: overflows(composer),
composerContext: overflows(document.querySelector('.composer-context')),
composerActions: overflows(document.querySelector('.composer-actions'))
}
};
})()`);
await writeFile(
join(artifactDir, fileName),
`${JSON.stringify(snapshot, null, 2)}\n`,
'utf8',
);
if (snapshot.viewport.width < 940 || snapshot.viewport.width > 1000) {
throw new Error(
`Compact viewport width is unexpected: ${snapshot.viewport.width}`,
);
}
if (snapshot.viewport.height < 600 || snapshot.viewport.height > 680) {
throw new Error(
`Compact viewport height is unexpected: ${snapshot.viewport.height}`,
);
}
const missing = [
'shell',
'sidebar',
'topbar',
'grid',
'chat',
'timeline',
'message',
'fileReferences',
'actions',
'composer',
'terminal',
].filter((key) => snapshot[key] === null);
if (missing.length > 0) {
throw new Error(
`Missing compact dense conversation rects: ${missing.join(', ')}`,
);
}
if (snapshot.document.bodyScrollWidth > snapshot.viewport.width + 4) {
throw new Error(
`Compact layout caused horizontal body overflow: ${JSON.stringify(
snapshot.document,
)}`,
);
}
if (snapshot.sidebar.width < 232 || snapshot.sidebar.width > 264) {
throw new Error(
`Compact sidebar width should stay narrow: ${snapshot.sidebar.width}`,
);
}
if (snapshot.topbar.height < 50 || snapshot.topbar.height > 76) {
throw new Error(
`Compact topbar height should stay slim: ${snapshot.topbar.height}`,
);
}
if (snapshot.terminalExpanded !== 'false' || snapshot.terminalBodyPresent) {
throw new Error(
'Compact dense conversation should keep Terminal collapsed.',
);
}
if (snapshot.terminal.height < 44 || snapshot.terminal.height > 82) {
throw new Error(
`Compact terminal strip height is unexpected: ${snapshot.terminal.height}`,
);
}
if (!snapshot.summaryVisibleBeforeAssistantScroll) {
await writeFile(
join(artifactDir, 'compact-summary-visibility-note.json'),
`${JSON.stringify(
{
note: 'Compact height can require timeline scrolling; summary must remain bounded and scrollable.',
preScroll: snapshot.preScroll,
},
null,
2,
)}\n`,
'utf8',
);
}
if (!snapshot.summaryContainedBeforeAssistantScroll) {
throw new Error('Changed-files summary escaped the compact timeline.');
}
if (snapshot.composer.height > 154) {
throw new Error(
`Compact composer should not crowd the conversation: ${snapshot.composer.height}`,
);
}
if (!snapshot.messageContained) {
throw new Error('Dense assistant message escaped the compact timeline.');
}
if (!snapshot.actionsContained) {
throw new Error('Assistant action row escaped the compact message.');
}
if (!snapshot.composerContained) {
throw new Error('Composer escaped the compact timeline width.');
}
if (!snapshot.terminalDocked) {
throw new Error('Collapsed terminal strip is not docked below composer.');
}
for (const expectedLabel of [
'Open README.md:1',
'Open packages/desktop/src/renderer/App.tsx:12:5',
'Open .env.example',
'Open Dockerfile',
'Open docs/guide.mdx',
'Open src/App.vue',
]) {
if (!snapshot.fileReferenceLabels.includes(expectedLabel)) {
throw new Error(
`Compact dense assistant chips missing ${expectedLabel}: ${snapshot.fileReferenceLabels.join(
', ',
)}`,
);
}
}
for (const expectedAction of [
'Copy Response',
'Retry Last Prompt',
'Open Changes',
]) {
if (!snapshot.actionLabels.includes(expectedAction)) {
throw new Error(
`Compact assistant actions missing ${expectedAction}: ${snapshot.actionLabels.join(
', ',
)}`,
);
}
}
for (const [key, hasOverflow] of Object.entries(snapshot.overflow)) {
if (hasOverflow) {
throw new Error(`Compact layout element overflowed: ${key}`);
}
}
for (const chipRect of snapshot.chipRects) {
if (!chipRect) {
throw new Error('Compact assistant chip geometry is missing.');
}
if (chipRect.width > 282) {
throw new Error(
`Compact assistant chip is too wide: ${JSON.stringify(chipRect)}`,
);
}
if (
chipRect.left < snapshot.message.left ||
chipRect.right > snapshot.message.right + 1 ||
chipRect.left < snapshot.timeline.left ||
chipRect.right > snapshot.timeline.right + 1
) {
throw new Error(
`Compact assistant chip escaped the message: ${JSON.stringify(
chipRect,
)}`,
);
}
}
for (const actionRect of snapshot.actionRects) {
if (!actionRect) {
throw new Error('Compact assistant action geometry is missing.');
}
if (actionRect.width > 40 || actionRect.height > 40) {
throw new Error(
`Compact assistant action is too large: ${JSON.stringify(actionRect)}`,
);
}
}
}
async function assertRetryDrafted(fileName) { async function assertRetryDrafted(fileName) {
const snapshot = await evaluate(`(() => { const snapshot = await evaluate(`(() => {
const messageField = document.querySelector('[aria-label="Message"]'); const messageField = document.querySelector('[aria-label="Message"]');
@ -1909,6 +2288,68 @@ async function setFieldByLabel(label, value) {
} }
} }
async function setElectronWindowBounds(targetId, bounds) {
const windowCdp = browserCdp ?? cdp;
let fallbackError = null;
try {
const { windowId } = await windowCdp.send('Browser.getWindowForTarget', {
targetId,
});
await windowCdp.send('Browser.setWindowBounds', {
windowId,
bounds: { windowState: 'normal' },
});
await windowCdp.send('Browser.setWindowBounds', {
windowId,
bounds,
});
} catch (error) {
fallbackError = error instanceof Error ? error.message : String(error);
await evaluate(`(() => {
window.resizeTo(${bounds.width}, ${bounds.height});
return true;
})()`);
}
await waitFor(
`Electron window bounds ${bounds.width}x${bounds.height}`,
async () => {
const viewport = await evaluate(`({
width: window.innerWidth,
height: window.innerHeight
})`);
return (
viewport.width >= bounds.width - 24 &&
viewport.width <= bounds.width + 24 &&
viewport.height >= bounds.height - 40 &&
viewport.height <= bounds.height + 40
);
},
10_000,
);
if (fallbackError) {
const viewport = await evaluate(`({
width: window.innerWidth,
height: window.innerHeight
})`);
await writeFile(
join(
artifactDir,
`window-resize-fallback-${bounds.width}x${bounds.height}.json`,
),
`${JSON.stringify(
{
requested: bounds,
viewport,
error: fallbackError,
},
null,
2,
)}\n`,
'utf8',
);
}
}
async function saveScreenshot(fileName) { async function saveScreenshot(fileName) {
const screenshot = await cdp.send('Page.captureScreenshot', { const screenshot = await cdp.send('Page.captureScreenshot', {
format: 'png', format: 'png',
@ -2188,6 +2629,7 @@ try {
throw error; throw error;
} finally { } finally {
cdp?.close(); cdp?.close();
browserCdp?.close();
if (appProcess && !appProcess.killed) { if (appProcess && !appProcess.killed) {
appProcess.kill(); appProcess.kill();
} }

View file

@ -1945,6 +1945,61 @@ textarea:focus {
max-width: 128px; max-width: 128px;
} }
.composer {
width: min(900px, calc(100% - 28px));
gap: 6px;
margin-bottom: 12px;
padding: 8px;
}
.composer textarea {
min-height: 54px;
padding: 6px 8px;
}
.composer-control-row,
.composer-context,
.composer-actions {
gap: 6px;
}
.composer-icon-button {
width: 28px;
height: 28px;
font-size: 16px;
}
.composer-chip,
.composer-context-note,
.composer-disabled-reason {
max-width: 138px;
min-height: 28px;
font-size: 11px;
}
.composer-chip,
.composer-context-note {
padding: 0 8px;
}
.composer-chip-project {
max-width: 176px;
}
.composer-select-label select {
min-width: 104px;
max-width: 148px;
min-height: 28px;
padding: 0 24px 0 8px;
font-size: 11px;
}
.composer-actions .primary-button,
.composer-actions .secondary-button {
min-height: 30px;
padding: 0 10px;
}
.settings-page-content { .settings-page-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }