From c8d5b7e921adf7cf899111d6a45d05a94e697b54 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Sun, 26 Apr 2026 02:33:06 +0800 Subject: [PATCH] fix(desktop): tighten compact conversation layout --- .../assistant-file-reference-overflow.md | 6 +- .../compact-dense-conversation.md | 60 +++ ...de-electron-desktop-implementation-plan.md | 102 ++++ packages/desktop/scripts/e2e-cdp-smoke.mjs | 442 ++++++++++++++++++ packages/desktop/src/renderer/styles.css | 55 +++ 5 files changed, 662 insertions(+), 3 deletions(-) create mode 100644 .qwen/e2e-tests/electron-desktop/compact-dense-conversation.md diff --git a/.qwen/e2e-tests/electron-desktop/assistant-file-reference-overflow.md b/.qwen/e2e-tests/electron-desktop/assistant-file-reference-overflow.md index ae2ba9092..8b37e46da 100644 --- a/.qwen/e2e-tests/electron-desktop/assistant-file-reference-overflow.md +++ b/.qwen/e2e-tests/electron-desktop/assistant-file-reference-overflow.md @@ -50,6 +50,6 @@ ## Known Uncovered Risk -This harness verifies the default 1240 px Electron window. A follow-up compact -viewport pass should assert the same dense message state near the lower -supported desktop width. +The default 1240 px window is covered here. The compact desktop width follow-up +is now covered by +`.qwen/e2e-tests/electron-desktop/compact-dense-conversation.md`. diff --git a/.qwen/e2e-tests/electron-desktop/compact-dense-conversation.md b/.qwen/e2e-tests/electron-desktop/compact-dense-conversation.md new file mode 100644 index 000000000..40a0e2282 --- /dev/null +++ b/.qwen/e2e-tests/electron-desktop/compact-dense-conversation.md @@ -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. diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index 63e7058f6..ab0f4213c 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -22,6 +22,108 @@ execution order, verification, decisions, and remaining work. ## 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 Status: completed in iteration 12. diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs index 8bbb3f30a..817d79dea 100644 --- a/packages/desktop/scripts/e2e-cdp-smoke.mjs +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -25,11 +25,14 @@ const artifactRoot = join( 'electron-desktop', 'artifacts', ); +const defaultWindowBounds = { width: 1240, height: 820 }; +const compactWindowBounds = { width: 960, height: 640 }; const consoleErrors = []; const failedRequests = []; let appProcess; +let browserCdp; let cdp; let artifactDir; let workspaceDir; @@ -54,6 +57,8 @@ async function main() { }); const target = await waitForCdpTarget(cdpPort); + const browserTarget = await waitForBrowserCdp(cdpPort); + browserCdp = await CdpClient.connect(browserTarget.webSocketDebuggerUrl); cdp = await CdpClient.connect(target.webSocketDebuggerUrl); cdp.onEvent((event) => collectBrowserEvent(event)); @@ -81,6 +86,12 @@ async function main() { await saveScreenshot('resolved-tool-activity.png'); await assertAssistantMessageActions('assistant-message-actions.json'); 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 waitForText('Copied response.'); 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() { const landmarks = await evaluate(`(() => { 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) { const snapshot = await evaluate(`(() => { 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) { const screenshot = await cdp.send('Page.captureScreenshot', { format: 'png', @@ -2188,6 +2629,7 @@ try { throw error; } finally { cdp?.close(); + browserCdp?.close(); if (appProcess && !appProcess.killed) { appProcess.kill(); } diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css index 1f87ad66e..632435e45 100644 --- a/packages/desktop/src/renderer/styles.css +++ b/packages/desktop/src/renderer/styles.css @@ -1945,6 +1945,61 @@ textarea:focus { 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 { grid-template-columns: 1fr; }