diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index 2b4035604..ec1c3e003 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -20,6 +20,95 @@ execution order, verification, decisions, and remaining work. - Every completed slice must leave targeted verification and a conventional commit. -## Task Breakdown +## Codex Alignment Progress -### Slice 1: Composer-First Thread Creation Alignment +### Active Slice: Composer-First Thread Creation Alignment + +Status: completed in iteration 2. + +Goal: let a user open a project and type immediately, without first learning +that they must create or select a session. + +User-visible value: the default path becomes +`Open project -> type request -> agent works`; the composer explains the active +project context and creates the backing desktop session on first send. + +Expected files: + +- `packages/desktop/src/renderer/App.tsx` +- `packages/desktop/src/renderer/components/layout/WorkspacePage.tsx` +- `packages/desktop/src/renderer/components/layout/ChatThread.tsx` +- `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx` +- `packages/desktop/src/renderer/styles.css` +- `packages/desktop/scripts/e2e-cdp-smoke.mjs` +- `.qwen/e2e-tests/electron-desktop/composer-first-thread-creation.md` + +Acceptance criteria: + +- Composer is enabled whenever a project is active, even when no session is + selected. +- With no project, composer remains disabled and gives a clear disabled reason. +- First send from a project with no selected session creates a desktop session, + sends the message, clears the composer, and publishes the created thread. +- Existing explicit `New Thread` behavior continues to work. +- The composer visibly carries compact project/branch, permission, and model + context so it reads as the task control center rather than a plain textarea. +- `Enter` send and `Shift+Enter` newline behavior are preserved. + +Verification: + +- Unit/component test command: + `cd packages/desktop && SHELL=/bin/bash npx vitest run 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, type a prompt into the project-scoped + composer without clicking `New Thread`, send it, approve the fake command + request, and assert the created thread/message/response appear. +- E2E assertions: first viewport landmarks stay present; composer is enabled + after project open; no `New Thread` click is required; fake ACP response is + received; console errors and failed local requests are absent. +- Diagnostic artifacts: CDP screenshots, layout JSON, DOM text, Electron log, + summary JSON under `.qwen/e2e-tests/electron-desktop/artifacts/`. +- Required skills applied: `frontend-design` for composer layout/control + hierarchy with the prototype as the strict visual contract; `electron-desktop-dev` + for renderer changes and real Electron CDP verification. + +Notes and decisions: + +- The prototype wins over earlier tab/dashboard guidance. This slice keeps the + conversation as the default surface and upgrades the bottom composer without + opening review, terminal, or settings by default. +- Model and permission controls are compact context controls in the composer. + They use existing session runtime state when available and safe fallback + labels before a session exists; changing values still requires a live session + until the server API supports project-level defaults. +- Implementation changed first-send behavior so any active project with no + active session creates a session on submit. The explicit `New Thread` button + still creates a draft thread for users who want to start intentionally from + the sidebar. +- CDP smoke now sends the first prompt immediately after opening the fake + project and before clicking `Changes`, proving the `New Thread` click is no + longer required. + +Verification results: + +- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/components/layout/WorkspacePage.test.tsx` + passed with 4 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. +- Passing artifacts: + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T16-41-09-752Z/`. + +Next work: + +- Continue prototype fidelity by reducing topbar tab weight and moving review + access toward compact icon/drawer behavior. +- Follow-up model configuration work should make composer model/permission + controls editable before a session exists by persisting project-level + defaults, rather than only reflecting live session runtime state. diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs index 079cbdfad..5b56c8f99 100644 --- a/packages/desktop/scripts/e2e-cdp-smoke.mjs +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -69,6 +69,17 @@ async function main() { await saveScreenshot('initial-workspace.png'); await clickButtonUntilText('Open Project', 'desktop-e2e-workspace'); + 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 waitForSelector('[data-testid="thread-list"]'); + await clickButton('Changes'); await waitForText('README.md'); await waitForText('Accept Hunk'); @@ -88,18 +99,6 @@ async function main() { await waitForSelector('[data-testid="project-list"]'); await clickButton('Chat'); - await clickButton('New Thread'); - await waitForText('New thread ready'); - await waitForSelector('[data-testid="thread-list"]'); - - 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 waitForSelector('[data-testid="thread-list"]'); await clickButton('Settings'); @@ -308,6 +307,57 @@ async function assertWorkbenchLandmarks() { } } +async function assertProjectComposerReady(fileName) { + await waitFor( + 'project-scoped composer', + async () => + evaluate(`(() => { + const textarea = document.querySelector('textarea[aria-label="Message"]'); + return Boolean( + textarea && + !textarea.disabled && + textarea.placeholder.includes('desktop-e2e-workspace') && + document.body.innerText.includes('Start a task in desktop-e2e-workspace') && + document.body.innerText.includes('New thread') + ); + })()`), + 15_000, + ); + + const snapshot = await evaluate(`(() => { + const textarea = document.querySelector('textarea[aria-label="Message"]'); + const permission = document.querySelector('select[aria-label="Permission mode"]'); + const model = document.querySelector('select[aria-label="Model"]'); + return { + composerText: document.querySelector('[data-testid="message-composer"]')?.textContent.trim() ?? '', + placeholder: textarea?.placeholder ?? null, + textareaDisabled: textarea?.disabled ?? null, + permissionDisabled: permission?.disabled ?? null, + modelDisabled: model?.disabled ?? null, + bodyHasStartTask: document.body.innerText.includes('Start a task in desktop-e2e-workspace'), + bodyHasNewThread: document.body.innerText.includes('New thread') + }; + })()`); + + await writeFile( + join(artifactDir, fileName), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8', + ); + + if (snapshot.textareaDisabled !== false) { + throw new Error( + 'Project composer should be enabled before a thread exists.', + ); + } + + if (snapshot.permissionDisabled !== true || snapshot.modelDisabled !== true) { + throw new Error( + 'Project composer runtime selectors should stay disabled before a session exists.', + ); + } +} + async function assertRalphWorkspaceLayout(fileName) { const metrics = await evaluate(`(() => { const rectFor = (selector) => { diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index f44ad2c0f..8fc154101 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -841,7 +841,7 @@ export function App() { return; } - if (!activeSessionId && isDraftSession) { + if (!activeSessionId && activeProject) { if (loadState.state !== 'ready' || !activeProject) { return; } @@ -896,7 +896,7 @@ export function App() { socketRef.current.sendUserMessage(content); setMessageText(''); }, - [activeProject, activeSessionId, isDraftSession, loadState, messageText], + [activeProject, activeSessionId, loadState, messageText], ); const stopGeneration = useCallback(() => { diff --git a/packages/desktop/src/renderer/components/layout/ChatThread.tsx b/packages/desktop/src/renderer/components/layout/ChatThread.tsx index 44e59e671..127db27d2 100644 --- a/packages/desktop/src/renderer/components/layout/ChatThread.tsx +++ b/packages/desktop/src/renderer/components/layout/ChatThread.tsx @@ -4,31 +4,53 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useEffect, useRef, type FormEvent } from 'react'; +import { useEffect, useRef, type FormEvent, type KeyboardEvent } from 'react'; +import type { 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'; export function ChatThread({ + activeProject, activeSessionId, chatState, isDraftSession, messageText, + modelState, onAskUserQuestionResponse, + onModeChange, + onModelChange, onMessageTextChange, onPermissionResponse, onSendMessage, onStopGeneration, }: { + activeProject: DesktopProject | null; activeSessionId: string | null; chatState: ChatState; isDraftSession: boolean; messageText: string; + modelState: ModelState; onAskUserQuestionResponse: (requestId: string, optionId: string) => void; + onModeChange: (mode: DesktopApprovalMode) => void; + onModelChange: (modelId: string) => void; onMessageTextChange: (message: string) => void; onPermissionResponse: (requestId: string, optionId: string) => void; onSendMessage: (event: FormEvent) => void; onStopGeneration: () => void; }) { - const canCompose = Boolean(activeSessionId) || isDraftSession; + const canCompose = Boolean(activeProject); + const disabledReason = activeProject ? null : 'Open a project to start'; + const placeholder = activeProject + ? `Ask Qwen Code about ${activeProject.name}` + : 'Open a project to start'; + const currentModeId = modelState.modes?.currentModeId ?? 'default'; + const modeOptions = modelState.modes?.availableModes ?? fallbackModeOptions; + const currentModelId = + modelState.models?.currentModelId ?? fallbackModelOption.modelId; + const modelOptions = modelState.models?.availableModels.length + ? modelState.models.availableModels + : [fallbackModelOption]; return (
{chatState.streaming ? 'Streaming' : chatState.connection}