diff --git a/.qwen/e2e-tests/electron-desktop/conversation-changes-summary.md b/.qwen/e2e-tests/electron-desktop/conversation-changes-summary.md new file mode 100644 index 000000000..3a1782765 --- /dev/null +++ b/.qwen/e2e-tests/electron-desktop/conversation-changes-summary.md @@ -0,0 +1,47 @@ +# Conversation Changed-Files Summary and Protocol Noise Cleanup + +- 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-25T17-28-04-569Z/` + +## 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 without manually creating a thread. +4. Approve the fake command request. +5. Assert the main body does not expose `session-e2e-1`, + `Connected to session-e2e`, or `Turn complete`. +6. Assert the conversation inline changed-files summary is visible with + `README.md`, `notes.txt`, `2 files changed`, `+2`, and `-1`. +7. Open review from the inline `Review Changes` action and continue the + existing review, settings, and terminal smoke path. + +## Assertions + +- Protocol connection and completion events stay out of the visible + conversation body. +- The changed-files summary is present before review opens and includes a + compact file/status/stat summary. +- The summary opens the review drawer without replacing the conversation. +- Console errors: 0. +- Failed local network requests: 0. + +## Artifacts + +- `conversation-changes-summary.json` +- `review-drawer.png` +- `completed-workspace.png` +- `electron.log` +- `summary.json` + +## Known Uncovered Risk + +This harness uses deterministic fake ACP updates and a small two-file Git +workspace. It does not yet validate long file paths, many changed files, or +live ACP tool/file-reference payloads. diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index da6d76e75..a51aa7c00 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -22,6 +22,99 @@ execution order, verification, decisions, and remaining work. ## Codex Alignment Progress +### Completed Slice: Conversation Changed-Files Summary and Protocol Noise Cleanup + +Status: completed in iteration 7. + +Goal: make the main conversation timeline feel like a product task flow by +hiding ACP/session protocol noise and surfacing Git changes inline. + +User-visible value: users should not see internal session IDs or protocol stop +reasons in the main reading flow, and they can discover changed files from the +conversation itself instead of starting from the topbar. + +Expected files: + +- `packages/desktop/src/renderer/components/layout/ChatThread.tsx` +- `packages/desktop/src/renderer/components/layout/WorkspacePage.tsx` +- `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx` +- `packages/desktop/src/renderer/stores/chatStore.ts` +- `packages/desktop/src/renderer/stores/chatStore.test.ts` +- `packages/desktop/src/renderer/styles.css` +- `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- `.qwen/e2e-tests/electron-desktop/conversation-changes-summary.md` +- `design/qwen-code-electron-desktop-implementation-plan.md` + +Acceptance criteria: + +- The chat timeline no longer renders `Connected to ` or + `Turn complete: ` event rows. +- Connection state remains available in the compact header/topbar rather than + as protocol prose in the timeline. +- When the active project has Git changes, the conversation shows a compact + changed-files summary with file names, staged/unstaged/untracked state, and + addition/deletion totals. +- The inline summary opens the review drawer while keeping the conversation + mounted. +- The summary hides itself when there are no changed files. + +Verification: + +- Unit/component test commands: + `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/stores/chatStore.test.ts src/renderer/components/layout/WorkspacePage.test.tsx` +- 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, create a composer-first thread, + approve the fake command, assert protocol IDs and stop reasons are absent + from the body text, assert the conversation changed-files summary is present, + open review from that summary, then continue through discard cancel, stage, + commit, settings, and terminal paths. +- E2E assertions: the body text does not contain `Connected to session-e2e`, + `session-e2e-1`, or `Turn complete`; the inline summary reports the fake + dirty files and opens the review drawer; console errors and failed local + requests are absent. +- Diagnostic artifacts: CDP screenshots, conversation summary JSON, review + layout JSON, Electron log, summary JSON under + `.qwen/e2e-tests/electron-desktop/artifacts/`. +- Required skills applied: `frontend-design` for prototype-constrained inline + cards and conversation density; `electron-desktop-dev` for renderer changes + and real Electron CDP verification. + +Notes and decisions: + +- The renderer still tracks connection state in `ChatState.connection` and the + compact header/topbar, but `connected` and `message_complete` protocol + messages no longer create timeline rows. +- The changed-files summary is derived from the active project Git diff instead + of fake ACP payloads, so it appears whenever the review drawer would have + meaningful content and disappears after commit/clean states. +- The inline summary opens the existing review drawer rather than introducing a + separate review surface, keeping the first viewport conversation-first and + consistent with `home.jpg`. + +Verification results: + +- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/stores/chatStore.test.ts src/renderer/components/layout/WorkspacePage.test.tsx` + passed with 9 tests. +- `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. +- Passing artifacts: + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-28-04-569Z/`. + +Next work: + +- Continue rich conversation primitives by rendering command approvals and tool + activity as inline cards instead of relying mostly on the permission strip. +- Improve settings information architecture so runtime diagnostics move under + Advanced and model/API key controls are reachable as product settings. + ### Completed Slice: Review Safety Terminology and Discard Confirmation Status: completed in iteration 6. diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs index 52d0f3bb1..eb2929a49 100644 --- a/packages/desktop/scripts/e2e-cdp-smoke.mjs +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -72,15 +72,13 @@ async function main() { await assertProjectComposerReady('project-composer.json'); await setFieldByAriaLabel('Message', 'Please exercise command approval.'); await clickButton('Send'); - await waitForText('session-e2e-1'); - await waitForText('Connected to session-e2e-1'); await waitForText('Approve Once'); await clickButton('Approve Once'); await waitForText('E2E fake ACP response received'); - await waitForText('Turn complete: end_turn'); + await assertConversationChangesSummary('conversation-changes-summary.json'); await waitForSelector('[data-testid="thread-list"]'); - await clickButton('Open Changes'); + await clickButton('Review Changes'); await waitForText('README.md'); await assertReviewDrawerLayout('review-drawer-layout.json'); await saveScreenshot('review-drawer.png'); @@ -566,6 +564,100 @@ async function assertRalphWorkspaceLayout(fileName) { } } +async function assertConversationChangesSummary(fileName) { + await waitForSelector('[data-testid="conversation-changes-summary"]'); + const snapshot = await evaluate(`(() => { + const bodyText = document.body.innerText; + const summary = document.querySelector( + '[data-testid="conversation-changes-summary"]' + ); + return { + bodyHasSessionId: bodyText.includes('session-e2e-1'), + bodyHasConnectedEvent: bodyText.includes('Connected to session-e2e'), + bodyHasTurnComplete: bodyText.includes('Turn complete'), + summaryText: summary?.innerText ?? '', + summaryRect: (() => { + if (!summary) { + return null; + } + const rect = summary.getBoundingClientRect(); + return { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height + }; + })(), + hasReviewAction: Boolean( + [...(summary?.querySelectorAll('button') ?? [])].some((button) => { + const label = + button.getAttribute('aria-label') || + button.getAttribute('title') || + button.textContent.trim(); + return label === 'Review Changes'; + }) + ), + reviewOpen: Boolean(document.querySelector('[data-testid="review-panel"]')) + }; + })()`); + + await writeFile( + join(artifactDir, fileName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8', + ); + + if (snapshot.bodyHasSessionId || snapshot.bodyHasConnectedEvent) { + throw new Error( + `Conversation leaked a protocol session id: ${JSON.stringify(snapshot)}`, + ); + } + + if (snapshot.bodyHasTurnComplete) { + throw new Error( + `Conversation leaked a protocol stop reason: ${JSON.stringify( + snapshot, + )}`, + ); + } + + for (const expectedText of [ + '2 files changed', + 'README.md', + 'notes.txt', + '+2', + '-1', + ]) { + if (!snapshot.summaryText.includes(expectedText)) { + throw new Error( + `Changed-files summary is missing ${expectedText}: ${snapshot.summaryText}`, + ); + } + } + + if (!snapshot.hasReviewAction) { + throw new Error('Changed-files summary is missing its review action.'); + } + + if (snapshot.reviewOpen) { + throw new Error('Changed-files summary should not open review by default.'); + } + + if ( + !snapshot.summaryRect || + snapshot.summaryRect.width < 360 || + snapshot.summaryRect.height > 220 + ) { + throw new Error( + `Changed-files summary geometry is unexpected: ${JSON.stringify( + snapshot.summaryRect, + )}`, + ); + } +} + async function assertReviewDrawerLayout(fileName) { const metrics = await evaluate(`(() => { const rectFor = (selector) => { diff --git a/packages/desktop/src/renderer/components/layout/ChatThread.tsx b/packages/desktop/src/renderer/components/layout/ChatThread.tsx index 127db27d2..edd2defde 100644 --- a/packages/desktop/src/renderer/components/layout/ChatThread.tsx +++ b/packages/desktop/src/renderer/components/layout/ChatThread.tsx @@ -5,7 +5,11 @@ */ import { useEffect, useRef, type FormEvent, type KeyboardEvent } from 'react'; -import type { DesktopProject } from '../../api/client.js'; +import type { + DesktopGitChangedFile, + DesktopGitDiff, + DesktopProject, +} from '../../api/client.js'; import type { ChatState, ChatTimelineItem } from '../../stores/chatStore.js'; import type { ModelState } from '../../stores/modelStore.js'; import type { DesktopApprovalMode } from '../../../shared/desktopProtocol.js'; @@ -14,6 +18,7 @@ export function ChatThread({ activeProject, activeSessionId, chatState, + gitDiff, isDraftSession, messageText, modelState, @@ -21,6 +26,7 @@ export function ChatThread({ onModeChange, onModelChange, onMessageTextChange, + onOpenReview, onPermissionResponse, onSendMessage, onStopGeneration, @@ -28,6 +34,7 @@ export function ChatThread({ activeProject: DesktopProject | null; activeSessionId: string | null; chatState: ChatState; + gitDiff: DesktopGitDiff | null; isDraftSession: boolean; messageText: string; modelState: ModelState; @@ -35,6 +42,7 @@ export function ChatThread({ onModeChange: (mode: DesktopApprovalMode) => void; onModelChange: (modelId: string) => void; onMessageTextChange: (message: string) => void; + onOpenReview: () => void; onPermissionResponse: (requestId: string, optionId: string) => void; onSendMessage: (event: FormEvent) => void; onStopGeneration: () => void; @@ -66,7 +74,9 @@ export function ChatThread({ activeProject={activeProject} state={chatState} activeSessionId={activeSessionId} + gitDiff={gitDiff} isDraftSession={isDraftSession} + onOpenReview={onOpenReview} /> void; state: ChatState; }) { const timelineRef = useRef(null); @@ -204,17 +218,21 @@ function ChatTimeline({ if (!activeSessionId && !isDraftSession && state.items.length === 0) { return ( -
- Start a task in {activeProject.name} -
+ ); } if (state.items.length === 0) { return ( -
- {isDraftSession ? 'New thread ready' : 'Session ready'} -
+ ); } @@ -223,11 +241,29 @@ function ChatTimeline({ {state.items.map((item) => ( ))} + ); } +function ConversationEmpty({ + gitDiff, + label, + onOpenReview, +}: { + gitDiff: DesktopGitDiff | null; + label: string; + onOpenReview: () => void; +}) { + return ( +
+ {label} + +
+ ); +} + function TimelineItem({ item }: { item: ChatTimelineItem }) { if (item.type === 'message') { return ( @@ -267,6 +303,118 @@ function TimelineItem({ item }: { item: ChatTimelineItem }) { return
{item.label}
; } +function ChangedFilesSummaryCard({ + gitDiff, + onOpenReview, +}: { + gitDiff: DesktopGitDiff | null; + onOpenReview: () => void; +}) { + const files = gitDiff?.files ?? []; + if (files.length === 0) { + return null; + } + + const stats = summarizeChangedFiles(files); + const visibleFiles = files.slice(0, 4); + const hiddenFileCount = Math.max(0, files.length - visibleFiles.length); + + return ( +
+
+
+ Changed files + + {files.length} {files.length === 1 ? 'file' : 'files'} changed + +
+ + +{stats.additions} + -{stats.deletions} + +
+
    + {visibleFiles.map((file) => ( +
  • + {file.path} + {formatChangedFileState(file)} +
  • + ))} + {hiddenFileCount > 0 ? ( +
  • + {hiddenFileCount} more + Open review +
  • + ) : null} +
+
+ +
+
+ ); +} + +function summarizeChangedFiles(files: DesktopGitChangedFile[]): { + additions: number; + deletions: number; +} { + return files.reduce( + (totals, file) => { + const lines = + file.hunks.length > 0 + ? file.hunks.flatMap((hunk) => hunk.lines) + : file.diff.split('\n'); + + for (const line of lines) { + if (line.startsWith('+++') || line.startsWith('---')) { + continue; + } + + if (line.startsWith('+')) { + totals.additions += 1; + } else if (line.startsWith('-')) { + totals.deletions += 1; + } + } + + return totals; + }, + { additions: 0, deletions: 0 }, + ); +} + +function formatChangedFileState(file: DesktopGitChangedFile): string { + const states: string[] = []; + if (file.staged) { + states.push('staged'); + } + if (file.unstaged) { + states.push('unstaged'); + } + if (file.untracked) { + states.push('untracked'); + } + + if (states.length === 1 && states[0] === file.status) { + return file.status; + } + + return states.length > 0 + ? `${file.status} ยท ${states.join(' + ')}` + : file.status; +} + function handleComposerKeyDown(event: KeyboardEvent) { if (event.key !== 'Enter' || event.shiftKey) { return; diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx index 7e99776f9..b1b89f8d3 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx @@ -67,7 +67,10 @@ describe('WorkspacePage', () => { expect(renderedContainer.textContent).toContain('example-workspace'); expect(renderedContainer.textContent).toContain('main'); - expect(renderedContainer.textContent).not.toContain('src/index.ts'); + expect( + renderedContainer.querySelector('[data-testid="project-sidebar"]') + ?.textContent, + ).not.toContain('src/index.ts'); expect(renderedContainer.textContent).toContain('Terminal'); expect(renderedContainer.textContent).toContain('Idle'); expect(renderedContainer.textContent).toContain('No recent command'); @@ -84,6 +87,33 @@ describe('WorkspacePage', () => { expect( renderedContainer.querySelector('button[aria-label="Open Changes"]'), ).toBeTruthy(); + expect( + renderedContainer.querySelector( + '[data-testid="conversation-changes-summary"]', + ), + ).toBeTruthy(); + expect(renderedContainer.textContent).toContain('1 file changed'); + expect(renderedContainer.textContent).toContain('+1'); + expect(renderedContainer.textContent).toContain('-1'); + + act(() => { + clickButton(renderedContainer, 'Review Changes'); + }); + + expect( + renderedContainer.querySelector('[data-testid="review-panel"]'), + ).toBeTruthy(); + expect( + renderedContainer.querySelector('[data-testid="chat-thread"]'), + ).toBeTruthy(); + + act(() => { + clickButton(renderedContainer, 'Conversation'); + }); + + expect( + renderedContainer.querySelector('[data-testid="review-panel"]'), + ).toBeNull(); act(() => { clickButton(renderedContainer, 'Expand Terminal'); diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx index 1288e95e0..ea4bbb406 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx @@ -142,6 +142,10 @@ export function WorkspacePage({ setWorkspaceView('chat'); setIsReviewOpen(false); }; + const showReview = () => { + setWorkspaceView('chat'); + setIsReviewOpen(true); + }; const toggleReview = () => { setWorkspaceView('chat'); setIsReviewOpen((current) => !current); @@ -199,6 +203,7 @@ export function WorkspacePage({ activeProject={activeProject} activeSessionId={activeSessionId} chatState={chatState} + gitDiff={gitDiff} isDraftSession={isDraftSession} messageText={messageText} modelState={modelState} @@ -206,6 +211,7 @@ export function WorkspacePage({ onModeChange={onModeChange} onModelChange={onModelChange} onMessageTextChange={onMessageTextChange} + onOpenReview={showReview} onPermissionResponse={onPermissionResponse} onSendMessage={onSendMessage} onStopGeneration={onStopGeneration} diff --git a/packages/desktop/src/renderer/stores/chatStore.test.ts b/packages/desktop/src/renderer/stores/chatStore.test.ts index 9873a07f0..30fbb3049 100644 --- a/packages/desktop/src/renderer/stores/chatStore.test.ts +++ b/packages/desktop/src/renderer/stores/chatStore.test.ts @@ -39,11 +39,50 @@ describe('chatStore', () => { const loaded = chatReducer(replaying, { type: 'history_loaded' }); expect(loaded.streaming).toBe(false); - expect(loaded.items).toHaveLength(2); - expect(loaded.items[1]).toMatchObject({ + expect(loaded.items).toHaveLength(1); + expect(loaded.items[0]).toMatchObject({ type: 'message', streaming: false, text: 'Recovered history', }); }); + + it('keeps protocol connection and stop reasons out of the timeline', () => { + const connected = chatReducer(createInitialChatState(), { + type: 'server_message', + message: { + type: 'connected', + sessionId: 'session-e2e-1', + }, + }); + + expect(connected.connection).toBe('connected'); + expect(connected.items).toHaveLength(0); + + const streaming = chatReducer(connected, { + type: 'server_message', + message: { + type: 'message_delta', + role: 'assistant', + text: 'Work finished', + }, + }); + const complete = chatReducer(streaming, { + type: 'server_message', + message: { + type: 'message_complete', + stopReason: 'end_turn', + }, + }); + + expect(complete.streaming).toBe(false); + expect(complete.items).toHaveLength(1); + expect(complete.items[0]).toMatchObject({ + type: 'message', + streaming: false, + text: 'Work finished', + }); + expect(JSON.stringify(complete.items)).not.toContain('session-e2e-1'); + expect(JSON.stringify(complete.items)).not.toContain('end_turn'); + }); }); diff --git a/packages/desktop/src/renderer/stores/chatStore.ts b/packages/desktop/src/renderer/stores/chatStore.ts index 0c594591e..73e1e103a 100644 --- a/packages/desktop/src/renderer/stores/chatStore.ts +++ b/packages/desktop/src/renderer/stores/chatStore.ts @@ -155,10 +155,6 @@ function applyServerMessage( ...state, connection: 'connected', error: null, - items: [ - ...state.items, - createEventItem(`Connected to ${message.sessionId}`), - ], }; case 'pong': @@ -240,14 +236,7 @@ function applyServerMessage( return { ...state, streaming: false, - items: [ - ...markStreamingMessagesComplete(state.items), - createEventItem( - message.stopReason - ? `Turn complete: ${message.stopReason}` - : 'Turn complete', - ), - ], + items: markStreamingMessagesComplete(state.items), }; case 'error': diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css index 38a49833f..83ada14ef 100644 --- a/packages/desktop/src/renderer/styles.css +++ b/packages/desktop/src/renderer/styles.css @@ -668,6 +668,12 @@ summary:focus-visible { font-size: 14px; } +.conversation-empty-stack { + align-content: center; + gap: 18px; + padding: 18px; +} + .chat-timeline { display: flex; min-height: 0; @@ -768,6 +774,111 @@ summary:focus-visible { font-size: 12px; } +.conversation-changes-card { + display: grid; + width: min(860px, 100%); + align-self: center; + gap: 10px; + padding: 12px; + border: 1px solid rgba(85, 166, 255, 0.2); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(85, 166, 255, 0.08), transparent), + rgba(238, 244, 239, 0.035); + color: var(--text-soft); +} + +.conversation-empty-stack .conversation-changes-card { + width: min(720px, 100%); +} + +.conversation-changes-heading, +.conversation-changes-actions, +.conversation-changes-list li { + display: flex; + align-items: center; + gap: 10px; +} + +.conversation-changes-heading { + justify-content: space-between; +} + +.conversation-changes-heading > div { + display: grid; + min-width: 0; + gap: 4px; +} + +.conversation-changes-heading strong { + min-width: 0; + overflow: hidden; + color: var(--text); + font-size: 14px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-diff-stat { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + monospace; + font-size: 12px; + font-weight: 800; +} + +.diff-addition { + color: #75d99c; +} + +.diff-deletion { + color: #ff9b8b; +} + +.conversation-changes-list { + display: grid; + gap: 1px; + margin: 0; + padding: 0; + list-style: none; +} + +.conversation-changes-list li { + min-height: 30px; + justify-content: space-between; + padding: 5px 8px; + border-radius: 6px; + background: rgba(8, 10, 11, 0.24); +} + +.conversation-changes-list span { + min-width: 0; + overflow: hidden; + color: var(--text-soft); + font-size: 12px; + font-weight: 720; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-changes-list small { + flex: 0 0 auto; + color: var(--muted); + font-size: 10px; + font-weight: 820; + text-transform: uppercase; + white-space: nowrap; +} + +.conversation-changes-actions { + justify-content: flex-end; +} + .chat-scroll-anchor { width: 1px; height: 1px;