From 75f757df5f7f0e3bb4631ff6841d175fdd60e144 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 23:47:31 +0800 Subject: [PATCH 1/5] fix(desktop): follow-up fixes from #8896 review - Restore toBeInstanceOf(SessionNotFoundError) in bridge test - Remove dead Ord derive from LocalNetwork - Narrow zh locale match to zh-cn only - Clear stale connection.error during reconnection --- packages/acp-bridge/src/bridge.test.ts | 1 + packages/desktop-shell/bootstrap/local-control.js | 11 ++++------- packages/desktop-shell/src-tauri/src/local_control.rs | 2 +- .../src/daemon/session/DaemonSessionProvider.tsx | 1 + 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 7e124032744..937d60e810c 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4485,6 +4485,7 @@ describe('createAcpSessionBridge', () => { hasMore: false, }); + await expect(refresh).rejects.toBeInstanceOf(SessionNotFoundError); await expect(refresh).rejects.toMatchObject({ code: 'session_closing', }); diff --git a/packages/desktop-shell/bootstrap/local-control.js b/packages/desktop-shell/bootstrap/local-control.js index 0982474eff7..d4c8e8b1d52 100644 --- a/packages/desktop-shell/bootstrap/local-control.js +++ b/packages/desktop-shell/bootstrap/local-control.js @@ -20,7 +20,8 @@ const messages = { on: 'On', inactiveCopy: 'Turn this on, then scan from a phone on the same trusted Wi-Fi.', - inactiveNotice: 'Uses unencrypted HTTP. Phone access stays closed until enabled.', + inactiveNotice: + 'Uses unencrypted HTTP. Phone access stays closed until enabled.', qrLabel: 'Local Control QR code', turnOn: 'Turn on Local Control', disconnect: 'Disconnect phone access', @@ -46,9 +47,7 @@ const messages = { }, }; -const language = navigator.language.toLowerCase().startsWith('zh') - ? 'zh-CN' - : 'en'; +const language = navigator.language.toLowerCase() === 'zh-cn' ? 'zh-CN' : 'en'; const t = (key) => messages[language][key]; document.documentElement.lang = language; @@ -70,9 +69,7 @@ function render(state) { toggle.className = enabled ? 'stop' : ''; qr.innerHTML = enabled ? state.qrSvg || '' : ''; url.textContent = enabled ? state.url || '' : ''; - sleep.textContent = state.sleepInhibited - ? t('awake') - : t('maySleep'); + sleep.textContent = state.sleepInhibited ? t('awake') : t('maySleep'); error.hidden = true; error.textContent = ''; } diff --git a/packages/desktop-shell/src-tauri/src/local_control.rs b/packages/desktop-shell/src-tauri/src/local_control.rs index 4f453081a16..cb62b2ee29e 100644 --- a/packages/desktop-shell/src-tauri/src/local_control.rs +++ b/packages/desktop-shell/src-tauri/src/local_control.rs @@ -19,7 +19,7 @@ const MAX_CONNECTIONS: usize = 64; const HEADER_TIMEOUT: Duration = Duration::from_secs(10); static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1); -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] struct LocalNetwork { address: Ipv4Addr, netmask: Ipv4Addr, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index ac35606141a..8e1804601be 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2943,6 +2943,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setConnection((current) => ({ ...current, status: 'disconnected', + error: undefined, })); await delay(delayMs, abort.signal); } From b9ebc4435cc07809f60646bf1dda66a332855f60 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 12 Aug 2026 12:57:59 +0800 Subject: [PATCH 2/5] docs(desktop-shell): add debugging section to README --- packages/desktop-shell/README.md | 36 +++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/desktop-shell/README.md b/packages/desktop-shell/README.md index bcb0c3b7591..f537837ec72 100644 --- a/packages/desktop-shell/README.md +++ b/packages/desktop-shell/README.md @@ -21,12 +21,46 @@ From this directory: ```bash npm install --workspaces=false npm run build:runtime --workspaces=false -npm test --workspaces=false npm run dev --workspaces=false ``` +The first two steps are one-time setup. After that, `npm run dev` is all you need. + +`build:runtime` bundles the current platform's Node.js, the `qwen` CLI, and the built Web Shell into `runtime/qwen-code/`. Re-run it only when you change the CLI or Web Shell source. + Use `QWEN_DESKTOP_WORKSPACE=/absolute/path` to override the initial workspace. The app otherwise restores its saved primary workspace or creates `~/Documents/Qwen` on first launch. `QWEN_DEFAULT_WORKSPACE_DIR=/absolute/path` relocates that first-launch default, matching the Electron shell. Add and switch project workspaces from the Web Shell after startup. +## Debugging + +### Runtime log + +The daemon log is written to `~/Library/Logs/com.alibaba.qwen-code/desktop-runtime.log` on macOS. Tail it to see `qwen serve` output: + +```bash +tail -f ~/Library/Logs/com.alibaba.qwen-code/desktop-runtime.log +``` + +The desktop state (saved workspace, window position) is stored in `~/Library/Application Support/com.alibaba.qwen-code/desktop-state.json`. + +### WebView DevTools + +Open the Web Shell's DevTools from the running window with `Cmd+Option+I` (macOS) or `Ctrl+Shift+I` (Windows/Linux). This lets you inspect network requests, console output, and the React component tree. + +### Environment variables + +| Variable | Purpose | +| ---------------------------- | ------------------------------------------------------------------- | +| `QWEN_DESKTOP_WORKSPACE` | Override the initial workspace path | +| `QWEN_DEFAULT_WORKSPACE_DIR` | Relocate the first-launch default workspace directory | +| `QWEN_DESKTOP_SKIP_BUILD` | Set to `1` to skip the CLI/Web Shell rebuild during `build:runtime` | +| `QWEN_CODE_ROOT` | Point to a local qwen-code checkout for the runtime bundle | + +### Rust tests + +```bash +cargo test --manifest-path src-tauri/Cargo.toml +``` + ## Releases The `Desktop Release` workflow builds signed updater artifacts when `dry_run` is disabled. Published releases require the Tauri updater private key. macOS releases also require Apple signing and notarization credentials. From 360ff38b3a0c9c88b84f546ec5feec6850d197fb Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 12 Aug 2026 13:19:38 +0800 Subject: [PATCH 3/5] fix(webui): remove dead error write in retriable reconnect path, add test --- .../session/DaemonSessionProvider.test.tsx | 54 +++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 1 - 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 5d4c5c2e3ba..c2cfad1a4fd 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -12382,6 +12382,60 @@ describe('DaemonSessionProvider', () => { ]); }); + it('clears error during autoReconnect backoff after a retriable SSE error', async () => { + let callCount = 0; + const secondAttempt = createDeferred(); + const events = vi.fn(async function* retriableEvents( + opts: { signal?: AbortSignal } = {}, + ) { + callCount += 1; + if (callCount === 1) { + yield { + id: 1, + v: 1 as const, + type: 'session_update' as const, + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'before' }, + }, + }, + } satisfies DaemonEvent; + throw new Error('network timeout'); + } + await secondAttempt.promise; + if (opts.signal?.aborted) return; + }); + const session = createMockSession({ events }); + sdkMocks.sessions.push(session); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + await wait(30); + await flushPromises(); + }); + await act(async () => { + await flushPromises(); + }); + + expect(events).toHaveBeenCalledTimes(2); + // The retriable error is cleared during backoff. + expect(connection?.error).toBeUndefined(); + + secondAttempt.resolve(); + }); + it('routes session_died errors to notices, not transcript', async () => { const session = createMockSession({ events: async function* sessionDiedEvents( diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 8e1804601be..4d1baeeadda 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2912,7 +2912,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setConnection((current) => ({ ...current, status: 'disconnected', - error: message, errorStatus: resolveConnectionErrorStatus( errorStatus, current.errorStatus, From 012690fe10cf7371c21a08140f33630c437f4e08 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 12 Aug 2026 17:04:00 +0800 Subject: [PATCH 4/5] test(webui): pin stale error clearing during reconnect Co-authored-by: Qwen-Coder --- .../session/DaemonSessionProvider.test.tsx | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index c2cfad1a4fd..98b83cd9c29 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -12382,31 +12382,37 @@ describe('DaemonSessionProvider', () => { ]); }); - it('clears error during autoReconnect backoff after a retriable SSE error', async () => { + it('clears an existing error during autoReconnect backoff', async () => { + sdkMocks.capabilities.mockResolvedValue({ + v: 1, + mode: 'http-bridge', + features: ['client_heartbeat'], + modelServices: [], + workspaceCwd: '/mock-workspace', + }); let callCount = 0; + const firstAttempt = createDeferred(); const secondAttempt = createDeferred(); const events = vi.fn(async function* retriableEvents( opts: { signal?: AbortSignal } = {}, ) { callCount += 1; if (callCount === 1) { - yield { - id: 1, - v: 1 as const, - type: 'session_update' as const, - data: { - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'before' }, - }, - }, - } satisfies DaemonEvent; + await firstAttempt.promise; throw new Error('network timeout'); } await secondAttempt.promise; if (opts.signal?.aborted) return; }); - const session = createMockSession({ events }); + const laterHeartbeat = createDeferred(); + const heartbeat = vi + .fn() + .mockRejectedValueOnce(new Error('heartbeat lost')) + .mockImplementation(async () => { + await laterHeartbeat.promise; + return { ok: true }; + }); + const session = createMockSession({ events, heartbeat }); sdkMocks.sessions.push(session); let connection: DaemonConnectionState | undefined; @@ -12420,20 +12426,18 @@ describe('DaemonSessionProvider', () => { autoReconnect: true, reconnectDelayMs: 1, maxReconnectDelayMs: 1, + heartbeatIntervalMs: 1, + heartbeatFailureThreshold: 1, }); - await act(async () => { - await wait(30); - await flushPromises(); - }); - await act(async () => { - await flushPromises(); - }); + await vi.waitFor(() => expect(connection?.error).toBe('heartbeat lost')); + + firstAttempt.resolve(); + await vi.waitFor(() => expect(events).toHaveBeenCalledTimes(2)); - expect(events).toHaveBeenCalledTimes(2); - // The retriable error is cleared during backoff. expect(connection?.error).toBeUndefined(); secondAttempt.resolve(); + laterHeartbeat.resolve(); }); it('routes session_died errors to notices, not transcript', async () => { From a541f7a6cf21ab29d01aa0db20aac4e3f2cedeea Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 12 Aug 2026 17:55:03 +0800 Subject: [PATCH 5/5] test(webui): satisfy reconnect generator lint --- packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 98b83cd9c29..ffda8bc6083 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -12403,6 +12403,7 @@ describe('DaemonSessionProvider', () => { } await secondAttempt.promise; if (opts.signal?.aborted) return; + yield* []; }); const laterHeartbeat = createDeferred(); const heartbeat = vi