From 13b181bf28f81837cb8511f577830547c4bcbb2f Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 10 Aug 2026 18:06:24 +0800 Subject: [PATCH 01/23] fix(webui): retry session load while closing --- .../session/DaemonSessionProvider.test.tsx | 61 +++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 29 ++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 11fa59c7e1c..e140ea31f21 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -7246,6 +7246,67 @@ describe('DaemonSessionProvider', () => { expect(loadCalls[1]?.[3]).toBe('client-a'); }); + it('retries a session switch while the target session is closing', async () => { + const firstSession = createMockSession({ sessionId: 'session-a' }); + const secondSession = createMockSession({ sessionId: 'session-b' }); + sdkMocks.sessions.push(firstSession); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + let notices: readonly DaemonSessionNotice[] = []; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + notices = useDaemonSessionNotices().notices; + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + await flushPromises(); + }); + sdkMocks.MockDaemonSessionClient.load.mockClear(); + sdkMocks.MockDaemonSessionClient.load.mockRejectedValueOnce( + new DaemonHttpError( + 404, + { + error: + 'No session with id "session-b". The session is closing; retry after close completes', + sessionId: 'session-b', + }, + 'POST /session/:id/load: No session with id "session-b". The session is closing; retry after close completes', + ), + ); + sdkMocks.sessions.push(secondSession); + + let switched: Promise | undefined; + act(() => { + switched = requireActions(actions).loadSession('session-b'); + }); + if (!switched) throw new Error('Session switch was not started'); + await act(async () => { + await wait(10); + await flushPromises(); + }); + + await act(async () => { + await expect(switched).resolves.toBeUndefined(); + await flushPromises(); + }); + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(2); + expect(connection).toMatchObject({ + status: 'connected', + sessionId: 'session-b', + missingSession: false, + }); + expect(notices).toEqual([]); + }); + it('reuses the branched session client when switching after branch', async () => { window.sessionStorage.clear(); const sourceSession = createMockSession({ diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 80e6778127e..974ec95c312 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2360,6 +2360,34 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const message = error instanceof Error ? error.message : String(error); const errorStatus = extractHttpStatus(error); + const pendingLoad = pendingSessionLoadRef.current; + const errorBody = + error instanceof DaemonHttpError && isRecord(error.body) + ? error.body + : undefined; + if ( + loadingRequestedSession && + pendingLoad?.sessionId === restoreSessionId && + error instanceof DaemonHttpError && + error.status === 404 && + typeof errorBody?.['error'] === 'string' && + errorBody['error'].endsWith( + 'The session is closing; retry after close completes', + ) + ) { + reconnectAttempt += 1; + const reconnectConfig = reconnectConfigRef.current; + await delay( + getReconnectDelayMs( + reconnectAttempt, + reconnectConfig.reconnectDelayMs, + reconnectConfig.maxReconnectDelayMs, + ), + abort.signal, + ); + if (pendingSessionLoadRef.current !== pendingLoad) return; + continue; + } const failedSessionId = session?.sessionId; const isAuthFailure = isAuthFailureHttpError(error); const isTerminal = isTerminalSessionHttpError(error); @@ -2375,7 +2403,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); setPromptStatus('idle'); } - const pendingLoad = pendingSessionLoadRef.current; if ( pendingLoad && (pendingLoad.sessionId === restoreSessionId || From 6ac08da52719ce35fd843015ca1773f69e80e1d5 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 10 Aug 2026 18:49:35 +0800 Subject: [PATCH 02/23] fix(desktop): support enterprise LAN addresses --- .../src-tauri/src/local_control.rs | 97 +++++++++++++------ 1 file changed, 70 insertions(+), 27 deletions(-) diff --git a/packages/desktop-shell/src-tauri/src/local_control.rs b/packages/desktop-shell/src-tauri/src/local_control.rs index 5e5d3b612a5..a3a1ad88808 100644 --- a/packages/desktop-shell/src-tauri/src/local_control.rs +++ b/packages/desktop-shell/src-tauri/src/local_control.rs @@ -19,6 +19,22 @@ 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)] +struct LocalNetwork { + address: Ipv4Addr, + netmask: Ipv4Addr, +} + +impl LocalNetwork { + fn contains(&self, peer: IpAddr) -> bool { + let IpAddr::V4(peer) = peer else { + return false; + }; + u32::from(peer) & u32::from(self.netmask) + == u32::from(self.address) & u32::from(self.netmask) + } +} + struct Connections { stopping: AtomicBool, streams: Mutex>>, @@ -58,7 +74,8 @@ impl LocalControlSession { current_url: &Url, ) -> Result { let target = runtime_socket_addr(runtime_url)?; - let lan_ip = primary_lan_ipv4()?; + let network = primary_lan_ipv4()?; + let lan_ip = network.address; let listener = TcpListener::bind((lan_ip, 0)) .map_err(|error| format!("Failed to open Local Control on the LAN: {error}"))?; listener @@ -89,6 +106,7 @@ impl LocalControlSession { public_origin, pair_token, runtime_token.to_string(), + network, Arc::clone(&connections), ); let inhibitor = start_sleep_inhibitor(); @@ -141,16 +159,21 @@ fn spawn_proxy( public_origin: String, pair_token: String, runtime_token: String, + network: LocalNetwork, connections: Arc, ) -> JoinHandle<()> { thread::spawn(move || { while !connections.stopping.load(Ordering::SeqCst) { match listener.accept() { - Ok((mut client, _)) => { + Ok((mut client, peer)) => { if connections.stopping.load(Ordering::SeqCst) { let _ = client.shutdown(Shutdown::Both); break; } + if !network.contains(peer.ip()) { + let _ = write_rejection(&mut client, 403, "Forbidden"); + continue; + } if client.set_nonblocking(false).is_err() { continue; } @@ -441,14 +464,14 @@ fn local_control_url( Ok(url.into()) } -fn primary_lan_ipv4() -> Result { +fn primary_lan_ipv4() -> Result { select_lan_ipv4(routed_ipv4().ok(), NetworkInterface::show().ok()) } fn select_lan_ipv4( routed: Option, interfaces: Option>, -) -> Result { +) -> Result { let interfaces = interfaces.ok_or_else(|| "Local Control could not inspect IPv4 networks.".to_string())?; let physical = interfaces @@ -465,10 +488,10 @@ fn select_lan_ipv4( Addr::V4(address) if address.broadcast.is_some() && !address.ip.is_loopback() - && !address.ip.is_unspecified() => - { - Some(address.ip) - } + && !address.ip.is_unspecified() => address.netmask.map(|netmask| LocalNetwork { + address: address.ip, + netmask, + }), _ => None, }) .collect(); @@ -477,14 +500,19 @@ fn select_lan_ipv4( fn choose_lan_ipv4( routed: Option, - mut physical: Vec, -) -> Result { + mut physical: Vec, +) -> Result { physical.sort_unstable(); physical.dedup(); - physical.retain(|address| address.is_private() || address.is_link_local()); - if let Some(routed) = routed.filter(|address| physical.contains(address)) { - return Ok(routed); + if let Some(network) = routed.and_then(|routed| { + physical + .iter() + .find(|network| network.address == routed) + .copied() + }) { + return Ok(network); } + physical.retain(|network| network.address.is_private() || network.address.is_link_local()); match physical.as_slice() { [address] => Ok(*address), [] => Err("Local Control could not find a usable IPv4 network.".to_string()), @@ -567,7 +595,7 @@ fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { mod tests { use super::{ choose_lan_ipv4, find_header_end, local_control_url, rewrite_request, runtime_socket_addr, - select_lan_ipv4, spawn_proxy, Connections, + select_lan_ipv4, spawn_proxy, Connections, LocalNetwork, }; use std::collections::HashMap; use std::io::{Read, Write}; @@ -580,28 +608,42 @@ mod tests { use std::time::Duration; use url::Url; + fn network(address: &str, netmask: &str) -> LocalNetwork { + LocalNetwork { + address: address.parse().expect("network address"), + netmask: netmask.parse().expect("network mask"), + } + } + #[test] - fn selects_only_a_private_physical_lan() { + fn selects_and_limits_the_physical_lan() { assert_eq!( choose_lan_ipv4( Some("10.8.0.2".parse::().expect("VPN address")), - vec!["192.168.1.20".parse::().expect("Wi-Fi address")], + vec![network("192.168.1.20", "255.255.255.0")], ) .expect("LAN address"), - "192.168.1.20" - .parse::() - .expect("expected address"), + network("192.168.1.20", "255.255.255.0"), ); - assert!(choose_lan_ipv4( - Some("203.0.113.10".parse().expect("public route")), - vec!["203.0.113.10".parse().expect("public interface")], - ) - .is_err()); + let enterprise = network("203.0.113.10", "255.255.255.0"); + assert_eq!( + choose_lan_ipv4(Some(enterprise.address), vec![enterprise]).expect("enterprise LAN"), + enterprise, + ); + assert!(enterprise.contains("203.0.113.20".parse().expect("same subnet"))); + assert!(!enterprise.contains("198.51.100.20".parse().expect("other subnet"))); let routed = Ipv4Addr::new(192, 168, 1, 20); assert_eq!( - choose_lan_ipv4(Some(routed), vec![routed, Ipv4Addr::new(192, 168, 2, 5)],) - .expect("routed LAN"), - routed, + choose_lan_ipv4( + Some(routed), + vec![ + network("192.168.1.20", "255.255.255.0"), + network("192.168.2.5", "255.255.255.0"), + ], + ) + .expect("routed LAN") + .address, + routed ); } @@ -685,6 +727,7 @@ mod tests { format!("http://{public_address}"), "pair-token".to_string(), "runtime-token".to_string(), + network("127.0.0.1", "255.0.0.0"), Arc::clone(&connections), ); From a8b0b1f258a8e1ae3f9fd2ecbf33fa9a7f73c761 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 10 Aug 2026 18:57:08 +0800 Subject: [PATCH 03/23] fix(desktop): reject unbounded LAN masks --- packages/desktop-shell/src-tauri/src/local_control.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/desktop-shell/src-tauri/src/local_control.rs b/packages/desktop-shell/src-tauri/src/local_control.rs index a3a1ad88808..ddaa73009f3 100644 --- a/packages/desktop-shell/src-tauri/src/local_control.rs +++ b/packages/desktop-shell/src-tauri/src/local_control.rs @@ -488,10 +488,13 @@ fn select_lan_ipv4( Addr::V4(address) if address.broadcast.is_some() && !address.ip.is_loopback() - && !address.ip.is_unspecified() => address.netmask.map(|netmask| LocalNetwork { - address: address.ip, - netmask, - }), + && !address.ip.is_unspecified() => address + .netmask + .filter(|netmask| !netmask.is_unspecified()) + .map(|netmask| LocalNetwork { + address: address.ip, + netmask, + }), _ => None, }) .collect(); From 04267c5ca918c0ac71fe7a7484b9e40be2d88f4e Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 01:18:35 +0800 Subject: [PATCH 04/23] fix(webui): close desktop session and voice regressions --- packages/acp-bridge/src/bridge.ts | 11 ++++++++++- packages/acp-bridge/src/bridgeErrors.ts | 8 +++++++- .../cli/src/serve/server/error-response.ts | 4 +++- .../qwen/code/daemon/DaemonSessionClient.java | 3 ++- .../client/voice/VoiceButton.test.tsx | 19 +++---------------- .../web-shell/client/voice/VoiceButton.tsx | 4 ++-- .../session/DaemonSessionProvider.test.tsx | 3 +++ .../daemon/session/DaemonSessionProvider.tsx | 6 +----- .../webui/src/daemon/session/actions.test.ts | 11 +++++++++-- packages/webui/src/daemon/session/actions.ts | 3 +++ 10 files changed, 43 insertions(+), 29 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 833d1de2d7a..6ee38b0dcf1 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5063,6 +5063,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError( req.sessionId, 'The session is closing; retry after close completes', + 'session_closing', ); } const replayFields = @@ -5580,6 +5581,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError( req.sessionId, 'The session is closing; retry after close completes', + 'session_closing', ); } // Self + any coalescers we accumulated while the restore was @@ -5818,6 +5820,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError( sessionId, 'The session is already closing', + 'session_closing', ); } let originatorClientId: string | undefined; @@ -6188,6 +6191,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError( existing.sessionId, 'The session is closing; retry after close completes', + 'session_closing', ); } // BRSCi: bump attach counter BEFORE any await so the @@ -6455,6 +6459,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { new SessionNotFoundError( sessionId, 'The session is closing; retry after close completes', + 'session_closing', ), ); } @@ -9210,7 +9215,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); if (isClosingOrAuthorizingClose(entry)) { - throw new SessionNotFoundError(sessionId, 'The session is closing'); + throw new SessionNotFoundError( + sessionId, + 'The session is closing; retry after close completes', + 'session_closing', + ); } const info = channelInfoForEntry(entry); if (!info || info.isDying) throw new SessionNotFoundError(sessionId); diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index 73ecc628658..2fbfe5084ca 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -57,10 +57,16 @@ function isNotCurrentlyGeneratingText(value: unknown): boolean { export class SessionNotFoundError extends Error { readonly sessionId: string; - constructor(sessionId: string, extra?: string) { + readonly code: 'session_not_found' | 'session_closing'; + constructor( + sessionId: string, + extra?: string, + code: 'session_not_found' | 'session_closing' = 'session_not_found', + ) { super(`No session with id "${sessionId}"` + (extra ? `. ${extra}` : '')); this.name = 'SessionNotFoundError'; this.sessionId = sessionId; + this.code = code; } } diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 57605c8eaf7..3f99b5e4a6d 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -390,7 +390,9 @@ export function sendBridgeError( return; } if (err instanceof SessionNotFoundError) { - res.status(404).json({ error: err.message, sessionId: err.sessionId }); + res + .status(404) + .json({ error: err.message, code: err.code, sessionId: err.sessionId }); return; } if (err instanceof SessionArchivedError) { diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java index 0410060f32c..a86eb25356e 100644 --- a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java +++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java @@ -434,7 +434,8 @@ private boolean isCurrentSessionNotFound(HttpSupport.Response response) { String responseSessionId = JsonSupport.optionalString(body, "sessionId"); String code = JsonSupport.optionalString(body, "code"); return session.getSessionId().equals(responseSessionId) - && (code == null || "session_not_found".equals(code)); + && (code == null || "session_not_found".equals(code) + || "session_closing".equals(code)); } catch (DaemonProtocolException e) { return false; } diff --git a/packages/web-shell/client/voice/VoiceButton.test.tsx b/packages/web-shell/client/voice/VoiceButton.test.tsx index 5e9ec3dea74..fe252832d1d 100644 --- a/packages/web-shell/client/voice/VoiceButton.test.tsx +++ b/packages/web-shell/client/voice/VoiceButton.test.tsx @@ -662,25 +662,12 @@ describe('VoiceButton', () => { expect(mocks.capture.stop).toHaveBeenCalledOnce(); }); - it('stops a hold that is released while connecting', async () => { - const { root, container } = mount(false); + it('stops a hold released before the connecting render commits', async () => { + const { container } = mount(false); await flush(); - let button = container.querySelector('button'); + const button = container.querySelector('button'); if (!button) throw new Error('VoiceButton did not render'); pointer(button, 'pointerdown'); - - mocks.capture.status = 'connecting'; - act(() => { - root.render( - {}} - target={legacyTarget} - />, - ); - }); - button = container.querySelector('button'); - if (!button) throw new Error('VoiceButton did not render'); pointer(button, 'pointerup'); expect(mocks.capture.stop).toHaveBeenCalledOnce(); diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index 4ff61414432..fcdab30927f 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -350,7 +350,7 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - if (isConnecting || isRecording) stop(); + stop(); }; const handlePointerCancel = ( @@ -363,7 +363,7 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - if (isConnecting || isRecording) abort(); + abort(); }; const label = isRecording diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index fd8dde145b7..adb84c423e2 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -7274,6 +7274,7 @@ describe('DaemonSessionProvider', () => { const closingError = new DaemonHttpError( 404, { + code: 'session_closing', error: 'No session with id "session-b". The session is closing; retry after close completes', sessionId: 'session-b', @@ -7348,6 +7349,7 @@ describe('DaemonSessionProvider', () => { new DaemonHttpError( 404, { + code: 'session_closing', error: 'No session with id "session-b". The session is closing; retry after close completes', sessionId: 'session-b', @@ -7385,6 +7387,7 @@ describe('DaemonSessionProvider', () => { new DaemonHttpError( 404, { + code: 'session_closing', error: 'No session with id "session-b". The session is closing; retry after close completes', sessionId: 'session-b', diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index cfce5d33e4f..26ef1b5d620 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2372,10 +2372,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { pendingLoad?.sessionId === restoreSessionId && error instanceof DaemonHttpError && error.status === 404 && - typeof errorBody?.['error'] === 'string' && - errorBody['error'].endsWith( - 'The session is closing; retry after close completes', - ) + errorBody?.['code'] === 'session_closing' ) { reconnectAttempt += 1; const reconnectConfig = reconnectConfigRef.current; @@ -2551,7 +2548,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setConnection((current) => ({ ...current, status: 'disconnected', - error: `Reconnecting in ${delayMs}ms`, })); await delay(delayMs, abort.signal); } diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index 45ee9d5b602..283f44ad282 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -517,9 +517,13 @@ describe('createDaemonSessionActions', () => { vi.useFakeTimers(); try { const existingSession = createMockSession('session-a'); + const manualSessionClearRef = { current: false }; + const setRestoreSessionId = vi.fn(); const { actions, getConnection } = createActionsHarness({ connection: { status: 'connected', sessionId: 'session-a' }, + manualSessionClearRef, session: existingSession, + setRestoreSessionId, }); const loadPromise = actions.loadSession('session-b'); @@ -541,10 +545,12 @@ describe('createDaemonSessionActions', () => { await expect(loadPromise).rejects.toThrow('Session load timed out'); expect(getConnection()).toMatchObject({ status: 'disconnected', - sessionId: 'session-b', + sessionId: undefined, loadingTranscript: undefined, catchingUp: undefined, }); + expect(manualSessionClearRef.current).toBe(true); + expect(setRestoreSessionId).toHaveBeenLastCalledWith(undefined); } finally { vi.useRealTimers(); } @@ -1024,6 +1030,7 @@ function createActionsHarness( restartEventStream?: ReturnType; session?: ReturnType; setAttachSessionNonce?: ReturnType; + setRestoreSessionId?: ReturnType; setRestoreWorkspaceCwd?: ReturnType; } = {}, ) { @@ -1079,7 +1086,7 @@ function createActionsHarness( connection = typeof update === 'function' ? update(connection) : update; }, setPromptStatus: vi.fn(), - setRestoreSessionId: vi.fn(), + setRestoreSessionId: opts.setRestoreSessionId ?? vi.fn(), setRestoreWorkspaceCwd: opts.setRestoreWorkspaceCwd ?? vi.fn(), setRestoreMode: vi.fn(), setRestoreSessionNonce: vi.fn(), diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 605a5ddcc25..6e999dad46f 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -244,12 +244,15 @@ export function createDaemonSessionActions({ if (pendingSessionLoadRef.current?.id === loadId) { pendingSessionLoadRef.current = undefined; if (sessionRef.current?.sessionId !== sessionId) { + manualSessionClearRef.current = true; + setRestoreSessionId(undefined); setConnection((current) => current.status === 'connecting' && current.sessionId === sessionId ? { ...current, status: 'disconnected', + sessionId: undefined, loadingTranscript: undefined, catchingUp: undefined, } From 5b64ce9af44b5fe04c8c5400b761bc9bf7bd0256 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 01:38:53 +0800 Subject: [PATCH 05/23] fix(desktop): refresh signed runtime checksums --- .github/workflows/desktop-release.yml | 13 +++++++++---- .../desktop-shell/scripts/prepare-runtime.js | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 8e50e2ce9f0..54a712d5fb8 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -316,10 +316,6 @@ jobs: working-directory: 'packages/desktop-shell' run: 'npm run build:runtime' - - name: 'Verify bundled runtime' - working-directory: 'packages/desktop-shell' - run: 'npm run smoke:runtime' - - name: 'Run desktop tests' working-directory: 'packages/desktop-shell' run: 'npm test' @@ -361,6 +357,15 @@ jobs: echo "::warning::Node.js runtime binary not found at $node_bin; no Node.js binary signed." fi + - name: 'Refresh bundled runtime checksums after signing (macOS)' + if: "runner.os == 'macOS' && inputs.dry_run == false" + working-directory: 'packages/desktop-shell' + run: 'node scripts/prepare-runtime.js --refresh-checksums' + + - name: 'Verify bundled runtime' + working-directory: 'packages/desktop-shell' + run: 'npm run smoke:runtime' + - name: 'Build desktop installers' working-directory: 'packages/desktop-shell' shell: 'bash' diff --git a/packages/desktop-shell/scripts/prepare-runtime.js b/packages/desktop-shell/scripts/prepare-runtime.js index 7cc00cb2cbe..ff5b15d8af0 100755 --- a/packages/desktop-shell/scripts/prepare-runtime.js +++ b/packages/desktop-shell/scripts/prepare-runtime.js @@ -18,6 +18,15 @@ const sourceRoot = process.env.QWEN_CODE_ROOT : repoRoot; const runtimeDir = path.join(packageDir, 'runtime'); const packageRoot = path.join(runtimeDir, 'qwen-code'); +const refreshChecksums = process.argv.indexOf('--refresh-checksums'); +if (refreshChecksums !== -1) { + const root = process.argv[refreshChecksums + 1] + ? path.resolve(process.argv[refreshChecksums + 1]) + : packageRoot; + writeChecksums(root); + console.log(`Refreshed desktop runtime checksums at ${root}`); + process.exit(0); +} const libDir = path.join(packageRoot, 'lib'); const nodeDir = path.join(packageRoot, 'node'); const qwenCodeVersion = JSON.parse( @@ -249,10 +258,10 @@ function gitCommit(directory) { }).trim(); } -function writeChecksums() { +function writeChecksums(root = packageRoot) { const checksums = {}; - for (const file of runtimeFiles(packageRoot)) { - const relative = path.relative(packageRoot, file).split(path.sep).join('/'); + for (const file of runtimeFiles(root)) { + const relative = path.relative(root, file).split(path.sep).join('/'); if (relative === 'checksums.json') continue; checksums[relative] = crypto .createHash('sha256') @@ -260,7 +269,7 @@ function writeChecksums() { .digest('hex'); } fs.writeFileSync( - path.join(packageRoot, 'checksums.json'), + path.join(root, 'checksums.json'), `${JSON.stringify(checksums, null, 2)}\n`, ); } From 2d64eae3365f608c7fbd53ddcf2a84a0bea3caf8 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 01:41:30 +0800 Subject: [PATCH 06/23] fix(desktop): close 0.1.1 regression gaps --- .github/workflows/desktop-release.yml | 13 +++++++++---- .../desktop-shell/scripts/prepare-runtime.js | 17 +++++++++++++---- .../client/voice/VoiceButton.test.tsx | 19 +++---------------- .../web-shell/client/voice/VoiceButton.tsx | 4 ++-- .../daemon/session/DaemonSessionProvider.tsx | 1 - 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 8e50e2ce9f0..54a712d5fb8 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -316,10 +316,6 @@ jobs: working-directory: 'packages/desktop-shell' run: 'npm run build:runtime' - - name: 'Verify bundled runtime' - working-directory: 'packages/desktop-shell' - run: 'npm run smoke:runtime' - - name: 'Run desktop tests' working-directory: 'packages/desktop-shell' run: 'npm test' @@ -361,6 +357,15 @@ jobs: echo "::warning::Node.js runtime binary not found at $node_bin; no Node.js binary signed." fi + - name: 'Refresh bundled runtime checksums after signing (macOS)' + if: "runner.os == 'macOS' && inputs.dry_run == false" + working-directory: 'packages/desktop-shell' + run: 'node scripts/prepare-runtime.js --refresh-checksums' + + - name: 'Verify bundled runtime' + working-directory: 'packages/desktop-shell' + run: 'npm run smoke:runtime' + - name: 'Build desktop installers' working-directory: 'packages/desktop-shell' shell: 'bash' diff --git a/packages/desktop-shell/scripts/prepare-runtime.js b/packages/desktop-shell/scripts/prepare-runtime.js index 7cc00cb2cbe..ff5b15d8af0 100755 --- a/packages/desktop-shell/scripts/prepare-runtime.js +++ b/packages/desktop-shell/scripts/prepare-runtime.js @@ -18,6 +18,15 @@ const sourceRoot = process.env.QWEN_CODE_ROOT : repoRoot; const runtimeDir = path.join(packageDir, 'runtime'); const packageRoot = path.join(runtimeDir, 'qwen-code'); +const refreshChecksums = process.argv.indexOf('--refresh-checksums'); +if (refreshChecksums !== -1) { + const root = process.argv[refreshChecksums + 1] + ? path.resolve(process.argv[refreshChecksums + 1]) + : packageRoot; + writeChecksums(root); + console.log(`Refreshed desktop runtime checksums at ${root}`); + process.exit(0); +} const libDir = path.join(packageRoot, 'lib'); const nodeDir = path.join(packageRoot, 'node'); const qwenCodeVersion = JSON.parse( @@ -249,10 +258,10 @@ function gitCommit(directory) { }).trim(); } -function writeChecksums() { +function writeChecksums(root = packageRoot) { const checksums = {}; - for (const file of runtimeFiles(packageRoot)) { - const relative = path.relative(packageRoot, file).split(path.sep).join('/'); + for (const file of runtimeFiles(root)) { + const relative = path.relative(root, file).split(path.sep).join('/'); if (relative === 'checksums.json') continue; checksums[relative] = crypto .createHash('sha256') @@ -260,7 +269,7 @@ function writeChecksums() { .digest('hex'); } fs.writeFileSync( - path.join(packageRoot, 'checksums.json'), + path.join(root, 'checksums.json'), `${JSON.stringify(checksums, null, 2)}\n`, ); } diff --git a/packages/web-shell/client/voice/VoiceButton.test.tsx b/packages/web-shell/client/voice/VoiceButton.test.tsx index 5e9ec3dea74..fe252832d1d 100644 --- a/packages/web-shell/client/voice/VoiceButton.test.tsx +++ b/packages/web-shell/client/voice/VoiceButton.test.tsx @@ -662,25 +662,12 @@ describe('VoiceButton', () => { expect(mocks.capture.stop).toHaveBeenCalledOnce(); }); - it('stops a hold that is released while connecting', async () => { - const { root, container } = mount(false); + it('stops a hold released before the connecting render commits', async () => { + const { container } = mount(false); await flush(); - let button = container.querySelector('button'); + const button = container.querySelector('button'); if (!button) throw new Error('VoiceButton did not render'); pointer(button, 'pointerdown'); - - mocks.capture.status = 'connecting'; - act(() => { - root.render( - {}} - target={legacyTarget} - />, - ); - }); - button = container.querySelector('button'); - if (!button) throw new Error('VoiceButton did not render'); pointer(button, 'pointerup'); expect(mocks.capture.stop).toHaveBeenCalledOnce(); diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index 4ff61414432..fcdab30927f 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -350,7 +350,7 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - if (isConnecting || isRecording) stop(); + stop(); }; const handlePointerCancel = ( @@ -363,7 +363,7 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - if (isConnecting || isRecording) abort(); + abort(); }; const label = isRecording diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index cfce5d33e4f..b192b8f9a1a 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -2551,7 +2551,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setConnection((current) => ({ ...current, status: 'disconnected', - error: `Reconnecting in ${delayMs}ms`, })); await delay(delayMs, abort.signal); } From da2477b94459f3755d983221436d30253a8ebe82 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 02:22:22 +0800 Subject: [PATCH 07/23] fix(webui): make quick voice presses start recording --- .../client/voice/VoiceButton.test.tsx | 26 ++++++++++++++++--- .../web-shell/client/voice/VoiceButton.tsx | 20 ++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/web-shell/client/voice/VoiceButton.test.tsx b/packages/web-shell/client/voice/VoiceButton.test.tsx index fe252832d1d..b01c39ed2f2 100644 --- a/packages/web-shell/client/voice/VoiceButton.test.tsx +++ b/packages/web-shell/client/voice/VoiceButton.test.tsx @@ -136,12 +136,16 @@ const pointer = ( type: 'pointerdown' | 'pointerup' | 'pointercancel', pointerId = 1, mouseButton = 0, + timeStamp?: number, ) => { const event = new MouseEvent(type, { bubbles: true, button: mouseButton, }); Object.defineProperty(event, 'pointerId', { value: pointerId }); + if (timeStamp !== undefined) { + Object.defineProperty(event, 'timeStamp', { value: timeStamp }); + } act(() => { button.dispatchEvent(event); }); @@ -637,7 +641,7 @@ describe('VoiceButton', () => { if (!button) throw new Error('VoiceButton did not render'); const heldButton = button; - pointer(button, 'pointerdown'); + pointer(button, 'pointerdown', 1, 0, 1_000); expect(mocks.capture.start).toHaveBeenCalledOnce(); mocks.capture.status = 'recording'; @@ -656,20 +660,34 @@ describe('VoiceButton', () => { // capture set on pointerdown survives the status change and the release // still lands on this element. expect(button).toBe(heldButton); - pointer(button, 'pointerup'); + pointer(button, 'pointerup', 1, 0, 1_500); click(button); expect(mocks.capture.stop).toHaveBeenCalledOnce(); }); - it('stops a hold released before the connecting render commits', async () => { - const { container } = mount(false); + it('keeps a quick hold active as a tap', async () => { + const { root, container } = mount(false); await flush(); const button = container.querySelector('button'); if (!button) throw new Error('VoiceButton did not render'); pointer(button, 'pointerdown'); pointer(button, 'pointerup'); + click(button); + + expect(mocks.capture.stop).not.toHaveBeenCalled(); + mocks.capture.status = 'recording'; + act(() => { + root.render( + {}} + target={legacyTarget} + />, + ); + }); + click(container.querySelector('button')!); expect(mocks.capture.stop).toHaveBeenCalledOnce(); }); diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index fcdab30927f..6564b0d6c0a 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -25,6 +25,7 @@ import styles from './VoiceButton.module.css'; /** Live waveform bar count in the recording pill. */ const BAR_COUNT = 16; const NOTICE_TIMEOUT_MS = 2_000; +const HOLD_THRESHOLD_MS = 250; export interface VoiceButtonProps { /** Insert the final transcript into the composer (user reviews, then sends). */ @@ -118,6 +119,9 @@ export function VoiceButton({ mode: 'hold', })); const holdPointerIdRef = useRef(null); + const holdStartedAtRef = useRef(0); + const ignoreNextClickRef = useRef(false); + const tapLatchedRef = useRef(false); const targetRef = useRef(target); targetRef.current = target; const requestGenerationRef = useRef(0); @@ -310,7 +314,14 @@ export function VoiceButton({ const canCancel = isRecording || isConnecting; const handleClick = (event: React.MouseEvent) => { - if (voiceGate.mode === 'hold' && event.detail !== 0) return; + if (voiceGate.mode === 'hold' && event.detail !== 0) { + if (ignoreNextClickRef.current) { + ignoreNextClickRef.current = false; + return; + } + if (!tapLatchedRef.current) return; + tapLatchedRef.current = false; + } if (isRecording) { stop(); } else if (isConnecting) { @@ -333,6 +344,7 @@ export function VoiceButton({ } event.preventDefault(); holdPointerIdRef.current = event.pointerId; + holdStartedAtRef.current = event.timeStamp; try { event.currentTarget.setPointerCapture(event.pointerId); } catch { @@ -350,7 +362,10 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - stop(); + ignoreNextClickRef.current = true; + tapLatchedRef.current = + event.timeStamp - holdStartedAtRef.current < HOLD_THRESHOLD_MS; + if (!tapLatchedRef.current) stop(); }; const handlePointerCancel = ( @@ -363,6 +378,7 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; + tapLatchedRef.current = false; abort(); }; From 97c51036bb3d3bfaa491a4d58f7b2a90477e80fe Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 02:22:22 +0800 Subject: [PATCH 08/23] fix(webui): make quick voice presses start recording --- .../client/voice/VoiceButton.test.tsx | 26 ++++++++++++++++--- .../web-shell/client/voice/VoiceButton.tsx | 20 ++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/web-shell/client/voice/VoiceButton.test.tsx b/packages/web-shell/client/voice/VoiceButton.test.tsx index fe252832d1d..b01c39ed2f2 100644 --- a/packages/web-shell/client/voice/VoiceButton.test.tsx +++ b/packages/web-shell/client/voice/VoiceButton.test.tsx @@ -136,12 +136,16 @@ const pointer = ( type: 'pointerdown' | 'pointerup' | 'pointercancel', pointerId = 1, mouseButton = 0, + timeStamp?: number, ) => { const event = new MouseEvent(type, { bubbles: true, button: mouseButton, }); Object.defineProperty(event, 'pointerId', { value: pointerId }); + if (timeStamp !== undefined) { + Object.defineProperty(event, 'timeStamp', { value: timeStamp }); + } act(() => { button.dispatchEvent(event); }); @@ -637,7 +641,7 @@ describe('VoiceButton', () => { if (!button) throw new Error('VoiceButton did not render'); const heldButton = button; - pointer(button, 'pointerdown'); + pointer(button, 'pointerdown', 1, 0, 1_000); expect(mocks.capture.start).toHaveBeenCalledOnce(); mocks.capture.status = 'recording'; @@ -656,20 +660,34 @@ describe('VoiceButton', () => { // capture set on pointerdown survives the status change and the release // still lands on this element. expect(button).toBe(heldButton); - pointer(button, 'pointerup'); + pointer(button, 'pointerup', 1, 0, 1_500); click(button); expect(mocks.capture.stop).toHaveBeenCalledOnce(); }); - it('stops a hold released before the connecting render commits', async () => { - const { container } = mount(false); + it('keeps a quick hold active as a tap', async () => { + const { root, container } = mount(false); await flush(); const button = container.querySelector('button'); if (!button) throw new Error('VoiceButton did not render'); pointer(button, 'pointerdown'); pointer(button, 'pointerup'); + click(button); + + expect(mocks.capture.stop).not.toHaveBeenCalled(); + mocks.capture.status = 'recording'; + act(() => { + root.render( + {}} + target={legacyTarget} + />, + ); + }); + click(container.querySelector('button')!); expect(mocks.capture.stop).toHaveBeenCalledOnce(); }); diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index fcdab30927f..6564b0d6c0a 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -25,6 +25,7 @@ import styles from './VoiceButton.module.css'; /** Live waveform bar count in the recording pill. */ const BAR_COUNT = 16; const NOTICE_TIMEOUT_MS = 2_000; +const HOLD_THRESHOLD_MS = 250; export interface VoiceButtonProps { /** Insert the final transcript into the composer (user reviews, then sends). */ @@ -118,6 +119,9 @@ export function VoiceButton({ mode: 'hold', })); const holdPointerIdRef = useRef(null); + const holdStartedAtRef = useRef(0); + const ignoreNextClickRef = useRef(false); + const tapLatchedRef = useRef(false); const targetRef = useRef(target); targetRef.current = target; const requestGenerationRef = useRef(0); @@ -310,7 +314,14 @@ export function VoiceButton({ const canCancel = isRecording || isConnecting; const handleClick = (event: React.MouseEvent) => { - if (voiceGate.mode === 'hold' && event.detail !== 0) return; + if (voiceGate.mode === 'hold' && event.detail !== 0) { + if (ignoreNextClickRef.current) { + ignoreNextClickRef.current = false; + return; + } + if (!tapLatchedRef.current) return; + tapLatchedRef.current = false; + } if (isRecording) { stop(); } else if (isConnecting) { @@ -333,6 +344,7 @@ export function VoiceButton({ } event.preventDefault(); holdPointerIdRef.current = event.pointerId; + holdStartedAtRef.current = event.timeStamp; try { event.currentTarget.setPointerCapture(event.pointerId); } catch { @@ -350,7 +362,10 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - stop(); + ignoreNextClickRef.current = true; + tapLatchedRef.current = + event.timeStamp - holdStartedAtRef.current < HOLD_THRESHOLD_MS; + if (!tapLatchedRef.current) stop(); }; const handlePointerCancel = ( @@ -363,6 +378,7 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; + tapLatchedRef.current = false; abort(); }; From a929e888c0005864adfa87f03f83a4e66552b67c Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 03:42:56 +0800 Subject: [PATCH 09/23] fix(web-shell): keep voice clicks responsive --- .../web-shell/client/voice/VoiceButton.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index 6564b0d6c0a..57f33e8fddc 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -121,7 +121,6 @@ export function VoiceButton({ const holdPointerIdRef = useRef(null); const holdStartedAtRef = useRef(0); const ignoreNextClickRef = useRef(false); - const tapLatchedRef = useRef(false); const targetRef = useRef(target); targetRef.current = target; const requestGenerationRef = useRef(0); @@ -314,13 +313,10 @@ export function VoiceButton({ const canCancel = isRecording || isConnecting; const handleClick = (event: React.MouseEvent) => { - if (voiceGate.mode === 'hold' && event.detail !== 0) { - if (ignoreNextClickRef.current) { - ignoreNextClickRef.current = false; - return; - } - if (!tapLatchedRef.current) return; - tapLatchedRef.current = false; + if (voiceGate.mode === 'hold') { + const ignore = event.detail !== 0 && ignoreNextClickRef.current; + ignoreNextClickRef.current = false; + if (ignore) return; } if (isRecording) { stop(); @@ -333,6 +329,7 @@ export function VoiceButton({ }; const handlePointerDown = (event: React.PointerEvent) => { + if (voiceGate.mode === 'hold') ignoreNextClickRef.current = false; if ( voiceGate.mode !== 'hold' || event.button !== 0 || @@ -345,6 +342,7 @@ export function VoiceButton({ event.preventDefault(); holdPointerIdRef.current = event.pointerId; holdStartedAtRef.current = event.timeStamp; + ignoreNextClickRef.current = true; try { event.currentTarget.setPointerCapture(event.pointerId); } catch { @@ -362,10 +360,12 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - ignoreNextClickRef.current = true; - tapLatchedRef.current = - event.timeStamp - holdStartedAtRef.current < HOLD_THRESHOLD_MS; - if (!tapLatchedRef.current) stop(); + if ( + event.timeStamp - holdStartedAtRef.current >= HOLD_THRESHOLD_MS && + (isConnecting || isRecording) + ) { + stop(); + } }; const handlePointerCancel = ( @@ -378,7 +378,7 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - tapLatchedRef.current = false; + ignoreNextClickRef.current = false; abort(); }; From f57a9bd0401534ea707eccfd9031ed3d41539ed9 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 03:42:56 +0800 Subject: [PATCH 10/23] fix(webui): settle timed-out session loads cleanly --- .../daemon/session/DaemonSessionProvider.tsx | 5 +++- packages/webui/src/daemon/session/actions.ts | 25 ++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 26ef1b5d620..e6ae14d84da 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -811,7 +811,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { | Awaited> | undefined; let reconnectSessionId = restoreSessionId; - let shouldCreateFreshSession = !restoreSessionId && newSessionNonce > 0; + let shouldCreateFreshSession = + !manualSessionClearRef.current && + !restoreSessionId && + newSessionNonce > 0; let reconnectAttempt = 0; let nextSseConnectReason: DaemonSseConnectReason | undefined; let skipMetadataRefresh = false; diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 6e999dad46f..3ba3d40f48d 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -246,18 +246,19 @@ export function createDaemonSessionActions({ if (sessionRef.current?.sessionId !== sessionId) { manualSessionClearRef.current = true; setRestoreSessionId(undefined); - setConnection((current) => - current.status === 'connecting' && - current.sessionId === sessionId - ? { - ...current, - status: 'disconnected', - sessionId: undefined, - loadingTranscript: undefined, - catchingUp: undefined, - } - : current, - ); + setRestoreWorkspaceCwd(undefined); + setConnection((current) => { + if ( + current.status !== 'connecting' || + current.sessionId !== sessionId + ) { + return current; + } + return { + ...getConnectionAfterSessionClear(current, sessionId), + status: 'disconnected', + }; + }); } reject( dispatchActionError( From 81e5eb7b5a83815d3fda2296b829133e8a898358 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 03:42:56 +0800 Subject: [PATCH 11/23] fix(web-shell): keep voice clicks responsive --- .../web-shell/client/voice/VoiceButton.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index 6564b0d6c0a..57f33e8fddc 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -121,7 +121,6 @@ export function VoiceButton({ const holdPointerIdRef = useRef(null); const holdStartedAtRef = useRef(0); const ignoreNextClickRef = useRef(false); - const tapLatchedRef = useRef(false); const targetRef = useRef(target); targetRef.current = target; const requestGenerationRef = useRef(0); @@ -314,13 +313,10 @@ export function VoiceButton({ const canCancel = isRecording || isConnecting; const handleClick = (event: React.MouseEvent) => { - if (voiceGate.mode === 'hold' && event.detail !== 0) { - if (ignoreNextClickRef.current) { - ignoreNextClickRef.current = false; - return; - } - if (!tapLatchedRef.current) return; - tapLatchedRef.current = false; + if (voiceGate.mode === 'hold') { + const ignore = event.detail !== 0 && ignoreNextClickRef.current; + ignoreNextClickRef.current = false; + if (ignore) return; } if (isRecording) { stop(); @@ -333,6 +329,7 @@ export function VoiceButton({ }; const handlePointerDown = (event: React.PointerEvent) => { + if (voiceGate.mode === 'hold') ignoreNextClickRef.current = false; if ( voiceGate.mode !== 'hold' || event.button !== 0 || @@ -345,6 +342,7 @@ export function VoiceButton({ event.preventDefault(); holdPointerIdRef.current = event.pointerId; holdStartedAtRef.current = event.timeStamp; + ignoreNextClickRef.current = true; try { event.currentTarget.setPointerCapture(event.pointerId); } catch { @@ -362,10 +360,12 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - ignoreNextClickRef.current = true; - tapLatchedRef.current = - event.timeStamp - holdStartedAtRef.current < HOLD_THRESHOLD_MS; - if (!tapLatchedRef.current) stop(); + if ( + event.timeStamp - holdStartedAtRef.current >= HOLD_THRESHOLD_MS && + (isConnecting || isRecording) + ) { + stop(); + } }; const handlePointerCancel = ( @@ -378,7 +378,7 @@ export function VoiceButton({ return; } holdPointerIdRef.current = null; - tapLatchedRef.current = false; + ignoreNextClickRef.current = false; abort(); }; From 51c9d48bac08ed816ca7e7e4b5687439497c37bb Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 03:45:30 +0800 Subject: [PATCH 12/23] fix(desktop): finalize interaction regressions --- packages/web-shell/client/voice/VoiceButton.tsx | 4 +++- packages/webui/src/daemon/session/actions.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index 57f33e8fddc..132a487b649 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -316,7 +316,9 @@ export function VoiceButton({ if (voiceGate.mode === 'hold') { const ignore = event.detail !== 0 && ignoreNextClickRef.current; ignoreNextClickRef.current = false; - if (ignore) return; + if (ignore || (event.detail !== 0 && !isRecording && !isConnecting)) { + return; + } } if (isRecording) { stop(); diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 3ba3d40f48d..45a0ec4f88b 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -257,6 +257,7 @@ export function createDaemonSessionActions({ return { ...getConnectionAfterSessionClear(current, sessionId), status: 'disconnected', + sessionId: undefined, }; }); } From 11b2a30c4cbc9c53e3847609e383d5cea132a3dc Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 03:45:30 +0800 Subject: [PATCH 13/23] fix(web-shell): preserve hold click semantics --- packages/web-shell/client/voice/VoiceButton.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index 57f33e8fddc..132a487b649 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -316,7 +316,9 @@ export function VoiceButton({ if (voiceGate.mode === 'hold') { const ignore = event.detail !== 0 && ignoreNextClickRef.current; ignoreNextClickRef.current = false; - if (ignore) return; + if (ignore || (event.detail !== 0 && !isRecording && !isConnecting)) { + return; + } } if (isRecording) { stop(); From 59ab9be8f3aa378a49ce865aaffd1324476ee4af Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 04:53:41 +0800 Subject: [PATCH 14/23] fix(desktop): reject stale packaged runtimes --- .../desktop-shell/scripts/smoke-packaged.js | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/desktop-shell/scripts/smoke-packaged.js b/packages/desktop-shell/scripts/smoke-packaged.js index e7ba57ca0d5..cae5ed7bfed 100755 --- a/packages/desktop-shell/scripts/smoke-packaged.js +++ b/packages/desktop-shell/scripts/smoke-packaged.js @@ -11,12 +11,14 @@ const packageDir = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', ); +const repoRoot = path.resolve(packageDir, '../..'); const executable = process.argv[2]; if (!executable) throw new Error('Usage: node scripts/smoke-packaged.js '); if (!fs.statSync(executable, { throwIfNoEntry: false })?.isFile()) { throw new Error(`Packaged executable is missing: ${executable}`); } +verifyMacRuntimeCommit(); const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-desktop-smoke-')); const isolatedHome = path.join(workspace, 'home'); @@ -208,3 +210,23 @@ function terminate(pid) { // The process may already have exited after the smoke succeeded or failed. } } + +function verifyMacRuntimeCommit() { + if (process.platform !== 'darwin') return; + const manifestPath = path.resolve( + path.dirname(executable), + '../Resources/runtime/qwen-code/manifest.json', + ); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const expected = + process.env.QWEN_CODE_COMMIT || + execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: process.env.QWEN_CODE_ROOT || repoRoot, + encoding: 'utf8', + }).trim(); + if (manifest.qwenCodeCommit !== expected) { + throw new Error( + `Packaged runtime commit mismatch: expected ${expected}, found ${manifest.qwenCodeCommit || 'missing'}`, + ); + } +} From 400fe537e487e1912b3e6e967aa51b33cf01370c Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 06:19:40 +0800 Subject: [PATCH 15/23] fix(serve): avoid cancelling closed sessions --- packages/acp-bridge/src/bridge.test.ts | 18 +++++++++ packages/acp-bridge/src/bridge.ts | 54 +++++++++++++++----------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 5892db8d820..f1ee6ed2a0b 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -20766,6 +20766,24 @@ describe('session idle reaper', () => { await bridge.shutdown(); }); + it('does not cancel a session the agent already closed', async () => { + const handle = makeChannel({ + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionClose + ? { closed: true } + : {}, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.closeSession(session.sessionId); + + expect(handle.agent.cancelCalls).toEqual([]); + await bridge.shutdown(); + }); + it('reaps multiple orphaned sessions in one tick', async () => { vi.useFakeTimers(); try { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 6ee38b0dcf1..d28a1d885b3 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4201,7 +4201,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { requireFlush?: boolean; timeoutMs?: number; }, - ): Promise => { + ): Promise => { if (!ci || ci.channel !== entry.channel) { if (opts?.throwOnFailure === true) { writeStderrLine( @@ -4212,7 +4212,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { `ACP session close channel unavailable for ${entry.sessionId}`, ); } - return; + return false; } try { const closeRequest = entry.connection.extMethod( @@ -4226,7 +4226,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const observedCloseRequest = opts?.timeoutMs ? withTimeout(closeRequest, opts.timeoutMs, label) : closeRequest; - await Promise.race([ + const response = await Promise.race([ opts?.throwOnFailure === true ? observedCloseRequest : withTimeout( @@ -4236,6 +4236,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ), getTransportClosedReject(entry), ]); + return response['closed'] === true; } catch (err) { writeStderrLine( `qwen serve: ${label} ACP session close notification failed ` + @@ -4246,6 +4247,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (opts?.throwOnFailure === true) { throw err; } + return false; } }; @@ -5859,19 +5861,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { `for session ${JSON.stringify(sessionId)} — channel cleanup skipped (entry's channel already torn down)`, ); } + let agentSessionClosed = false; try { // Resolve permission waits before asking the agent to drain active turns; // otherwise a turn blocked in requestPermission can deadlock close. permissionMediator.forgetSession(sessionId); entry.pendingPermissionIds.clear(); entry.pendingInteractions.clear(); - await notifyAgentSessionClose(entry, ci, 'closeSession', { - throwOnFailure: true, - requireFlush: closeOpts?.requireAgentClose === true, - ...(closeOpts?.agentCloseTimeoutMs !== undefined - ? { timeoutMs: closeOpts.agentCloseTimeoutMs } - : {}), - }); + agentSessionClosed = await notifyAgentSessionClose( + entry, + ci, + 'closeSession', + { + throwOnFailure: true, + requireFlush: closeOpts?.requireAgentClose === true, + ...(closeOpts?.agentCloseTimeoutMs !== undefined + ? { timeoutMs: closeOpts.agentCloseTimeoutMs } + : {}), + }, + ); } catch (error) { // A child RequestError is a definitive close refusal: the child kept // the session live, so a retry is safe. A transport failure has an @@ -5942,18 +5950,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // `session_closed` is terminal. Close the bus before ACP cancel so any // late cancellation frames from the agent are intentionally dropped. entry.events.close(); - try { - await telemetry.withSpan( - 'session.close.cancel_active_prompt', - { - 'qwen-code.daemon.bridge.operation': - 'session.close.cancel_active_prompt', - 'session.id': sessionId, - }, - async () => await entry.connection.cancel({ sessionId }), - ); - } catch { - /* no active prompt or session already torn down */ + if (!agentSessionClosed) { + try { + await telemetry.withSpan( + 'session.close.cancel_active_prompt', + { + 'qwen-code.daemon.bridge.operation': + 'session.close.cancel_active_prompt', + 'session.id': sessionId, + }, + async () => await entry.connection.cancel({ sessionId }), + ); + } catch { + /* no active prompt or session already torn down */ + } } if (ci && hasNoChannelWork(ci)) { await reapPendingEmptyChannel(ci); From 4baaa3c145df382d0545cd2807998287f79ae397 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 10:58:27 +0800 Subject: [PATCH 16/23] fix(web-shell): preserve hold click suppression --- .../client/voice/VoiceButton.test.tsx | 27 +++++++++++++++++++ .../web-shell/client/voice/VoiceButton.tsx | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/voice/VoiceButton.test.tsx b/packages/web-shell/client/voice/VoiceButton.test.tsx index b01c39ed2f2..98a2926a27c 100644 --- a/packages/web-shell/client/voice/VoiceButton.test.tsx +++ b/packages/web-shell/client/voice/VoiceButton.test.tsx @@ -666,6 +666,33 @@ describe('VoiceButton', () => { expect(mocks.capture.stop).toHaveBeenCalledOnce(); }); + it('keeps the hold click suppressed after a rejected pointerdown', async () => { + const { root, container } = mount(false); + await flush(); + let button = container.querySelector('button'); + if (!button) throw new Error('VoiceButton did not render'); + + pointer(button, 'pointerdown', 1, 0, 1_000); + mocks.capture.status = 'connecting'; + act(() => { + root.render( + {}} + target={legacyTarget} + />, + ); + }); + button = container.querySelector('button'); + if (!button) throw new Error('VoiceButton did not render'); + pointer(button, 'pointerdown', 2, 0, 1_100); + pointer(button, 'pointerup', 1, 0, 1_500); + click(button); + + expect(mocks.capture.stop).toHaveBeenCalledOnce(); + expect(mocks.capture.abort).not.toHaveBeenCalled(); + }); + it('keeps a quick hold active as a tap', async () => { const { root, container } = mount(false); await flush(); diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index 132a487b649..72e97bae4ad 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -331,7 +331,6 @@ export function VoiceButton({ }; const handlePointerDown = (event: React.PointerEvent) => { - if (voiceGate.mode === 'hold') ignoreNextClickRef.current = false; if ( voiceGate.mode !== 'hold' || event.button !== 0 || @@ -341,6 +340,7 @@ export function VoiceButton({ ) { return; } + ignoreNextClickRef.current = false; event.preventDefault(); holdPointerIdRef.current = event.pointerId; holdStartedAtRef.current = event.timeStamp; From e727b570a370a917ad2ff3c9038e098cb72ec036 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 11:51:36 +0800 Subject: [PATCH 17/23] fix(web-shell): preserve hold click suppression --- packages/web-shell/client/voice/VoiceButton.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index b8264bc40b3..72e97bae4ad 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -331,7 +331,6 @@ export function VoiceButton({ }; const handlePointerDown = (event: React.PointerEvent) => { - if (voiceGate.mode === 'hold') ignoreNextClickRef.current = false; if ( voiceGate.mode !== 'hold' || event.button !== 0 || From b30e12a11f6f523838d5c7f10ba21d559ccf9a0d Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 12:36:23 +0800 Subject: [PATCH 18/23] fix(desktop): center and localize local control --- .../bootstrap/local-control.html | 20 ++++--- .../desktop-shell/bootstrap/local-control.js | 59 ++++++++++++++++--- packages/desktop-shell/src-tauri/src/main.rs | 2 + 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/packages/desktop-shell/bootstrap/local-control.html b/packages/desktop-shell/bootstrap/local-control.html index 33170abb041..1415e86476e 100644 --- a/packages/desktop-shell/bootstrap/local-control.html +++ b/packages/desktop-shell/bootstrap/local-control.html @@ -148,27 +148,33 @@
-

Local Control

-

Continue this session from your phone.

+

Local Control

+

Continue this session from your phone.

- Off + Off
-

Turn this on, then scan from a phone on the same trusted Wi-Fi.

-
+

+ Turn this on, then scan from a phone on the same trusted Wi-Fi. +

+
Uses unencrypted HTTP. Phone access stays closed until enabled.
- +
diff --git a/packages/desktop-shell/bootstrap/local-control.js b/packages/desktop-shell/bootstrap/local-control.js index b67179bbeda..0982474eff7 100644 --- a/packages/desktop-shell/bootstrap/local-control.js +++ b/packages/desktop-shell/bootstrap/local-control.js @@ -11,23 +11,68 @@ const sleep = document.querySelector('#sleep'); const error = document.querySelector('#error'); const toggle = document.querySelector('#toggle'); +const messages = { + en: { + title: 'Local Control', + heading: 'Local Control', + subtitle: 'Continue this session from your phone.', + off: 'Off', + 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.', + qrLabel: 'Local Control QR code', + turnOn: 'Turn on Local Control', + disconnect: 'Disconnect phone access', + awake: 'Trusted Wi-Fi · Unencrypted · Re-enable after network changes', + maySleep: + 'Trusted Wi-Fi · Unencrypted · May sleep · Re-enable after network changes', + bridgeUnavailable: 'The Desktop bridge is unavailable.', + }, + 'zh-CN': { + title: '本地控制', + heading: '本地控制', + subtitle: '在手机上继续当前会话。', + off: '关闭', + on: '已开启', + inactiveCopy: '开启后,使用同一受信任 Wi-Fi 中的手机扫码。', + inactiveNotice: '使用未加密 HTTP。开启前,手机访问保持关闭。', + qrLabel: '本地控制二维码', + turnOn: '开启本地控制', + disconnect: '断开手机访问', + awake: '受信任 Wi-Fi · 未加密 · 网络变化后需重新开启', + maySleep: '受信任 Wi-Fi · 未加密 · 可能休眠 · 网络变化后需重新开启', + bridgeUnavailable: '桌面端桥接不可用。', + }, +}; + +const language = navigator.language.toLowerCase().startsWith('zh') + ? 'zh-CN' + : 'en'; +const t = (key) => messages[language][key]; + +document.documentElement.lang = language; +document.title = `Qwen Code ${t('title')}`; +document.querySelectorAll('[data-i18n]').forEach((element) => { + element.textContent = t(element.dataset.i18n); +}); +qr.setAttribute('aria-label', t('qrLabel')); + let enabled = false; function render(state) { enabled = state.active; - badge.textContent = enabled ? 'On' : 'Off'; + badge.textContent = enabled ? t('on') : t('off'); badge.className = `badge${enabled ? ' on' : ''}`; inactive.hidden = enabled; active.hidden = !enabled; - toggle.textContent = enabled - ? 'Disconnect phone access' - : 'Turn on Local Control'; + toggle.textContent = enabled ? t('disconnect') : t('turnOn'); toggle.className = enabled ? 'stop' : ''; qr.innerHTML = enabled ? state.qrSvg || '' : ''; url.textContent = enabled ? state.url || '' : ''; sleep.textContent = state.sleepInhibited - ? 'Trusted Wi-Fi · Unencrypted · Re-enable after network changes' - : 'Trusted Wi-Fi · Unencrypted · May sleep · Re-enable after network changes'; + ? t('awake') + : t('maySleep'); error.hidden = true; error.textContent = ''; } @@ -54,7 +99,7 @@ toggle.addEventListener('click', toggleLocalControl); async function initialize() { if (!invoke || !listen) { - throw new Error('The Desktop bridge is unavailable.'); + throw new Error(t('bridgeUnavailable')); } await listen('local-control-changed', ({ payload }) => render(payload)); render(await invoke('local_control_status')); diff --git a/packages/desktop-shell/src-tauri/src/main.rs b/packages/desktop-shell/src-tauri/src/main.rs index fce2c09aef8..2b66d804ce2 100755 --- a/packages/desktop-shell/src-tauri/src/main.rs +++ b/packages/desktop-shell/src-tauri/src/main.rs @@ -747,6 +747,7 @@ fn should_restore_main_window(has_visible_windows: bool, main_needs_restore: boo fn show_local_control_window(app: &AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("local-control") { + window.center().map_err(|error| error.to_string())?; window.show().map_err(|error| error.to_string())?; window.set_focus().map_err(|error| error.to_string())?; return Ok(()); @@ -760,6 +761,7 @@ fn show_local_control_window(app: &AppHandle) -> Result<(), String> { .inner_size(440.0, 500.0) .min_inner_size(400.0, 500.0) .resizable(false) + .center() .build() .map(|_| ()) .map_err(|error| format!("Failed to open Local Control: {error}")) From 6f7be7f89661e4dd3863372d6787432fe8f3af68 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 12:58:49 +0800 Subject: [PATCH 19/23] test(desktop): pin interaction regressions --- .../web-shell/client/voice/VoiceButton.test.tsx | 16 +++++++++------- .../session/DaemonSessionProvider.test.tsx | 3 +++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/web-shell/client/voice/VoiceButton.test.tsx b/packages/web-shell/client/voice/VoiceButton.test.tsx index 98a2926a27c..7bc9b5fb9dc 100644 --- a/packages/web-shell/client/voice/VoiceButton.test.tsx +++ b/packages/web-shell/client/voice/VoiceButton.test.tsx @@ -696,13 +696,9 @@ describe('VoiceButton', () => { it('keeps a quick hold active as a tap', async () => { const { root, container } = mount(false); await flush(); - const button = container.querySelector('button'); + let button = container.querySelector('button'); if (!button) throw new Error('VoiceButton did not render'); - pointer(button, 'pointerdown'); - pointer(button, 'pointerup'); - click(button); - - expect(mocks.capture.stop).not.toHaveBeenCalled(); + pointer(button, 'pointerdown', 1, 0, 1_000); mocks.capture.status = 'recording'; act(() => { @@ -714,7 +710,13 @@ describe('VoiceButton', () => { />, ); }); - click(container.querySelector('button')!); + button = container.querySelector('button'); + if (!button) throw new Error('VoiceButton did not render'); + pointer(button, 'pointerup', 1, 0, 1_100); + click(button); + + expect(mocks.capture.stop).not.toHaveBeenCalled(); + click(button); expect(mocks.capture.stop).toHaveBeenCalledOnce(); }); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index adb84c423e2..6044397595c 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -3654,9 +3654,11 @@ describe('DaemonSessionProvider', () => { const session = createMockSession({ events }); sdkMocks.sessions.push(session); let blocks: readonly DaemonTranscriptBlock[] = []; + let connection: DaemonConnectionState | undefined; function Harness() { blocks = useDaemonTranscriptBlocks(); + connection = useDaemonConnection(); return null; } @@ -3675,6 +3677,7 @@ describe('DaemonSessionProvider', () => { expect(events.mock.calls[1]?.[0]).toMatchObject({ sseConnectReason: 'stream_end', }); + expect(connection?.error).toBeUndefined(); expect(blocks).toMatchObject([{ kind: 'assistant', text: 'hello' }]); }); From 76e1c397b9b0a54fbcfbc5fc42922935932b3f72 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 14:34:44 +0800 Subject: [PATCH 20/23] fix: close network and session race gaps --- packages/acp-bridge/src/bridge.test.ts | 4 +++- packages/acp-bridge/src/bridge.ts | 12 ++++++++---- .../desktop-shell/src-tauri/src/local_control.rs | 15 +++++++++++++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index f1ee6ed2a0b..450809c8731 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4307,7 +4307,9 @@ describe('createAcpSessionBridge', () => { hasMore: false, }); - await expect(refresh).rejects.toBeInstanceOf(SessionNotFoundError); + await expect(refresh).rejects.toMatchObject({ + code: 'session_closing', + }); closeResult.resolve({}); await close; await bridge.shutdown(); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index d28a1d885b3..27d65c7c0dc 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5080,12 +5080,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { action === 'load' ? await resolveHistoryAnchorRecordId(existing, replayFields) : undefined; - if ( - byId.get(req.sessionId) !== existing || - isClosingOrAuthorizingClose(existing) - ) { + if (byId.get(req.sessionId) !== existing) { throw new SessionNotFoundError(req.sessionId); } + if (isClosingOrAuthorizingClose(existing)) { + throw new SessionNotFoundError( + req.sessionId, + 'The session is closing; retry after close completes', + 'session_closing', + ); + } existing.attachCount++; const clientId = registerClient(existing, req.clientId); recordAttachRef(existing, clientId); diff --git a/packages/desktop-shell/src-tauri/src/local_control.rs b/packages/desktop-shell/src-tauri/src/local_control.rs index ddaa73009f3..dfd1372ff98 100644 --- a/packages/desktop-shell/src-tauri/src/local_control.rs +++ b/packages/desktop-shell/src-tauri/src/local_control.rs @@ -505,8 +505,8 @@ fn choose_lan_ipv4( routed: Option, mut physical: Vec, ) -> Result { - physical.sort_unstable(); - physical.dedup(); + physical.sort_unstable_by_key(|network| (network.address, std::cmp::Reverse(network.netmask))); + physical.dedup_by_key(|network| network.address); if let Some(network) = routed.and_then(|routed| { physical .iter() @@ -648,6 +648,17 @@ mod tests { .address, routed ); + assert_eq!( + choose_lan_ipv4( + Some(routed), + vec![ + network("192.168.1.20", "255.255.0.0"), + network("192.168.1.20", "255.255.255.0"), + ], + ) + .expect("narrowest duplicate"), + network("192.168.1.20", "255.255.255.0"), + ); } #[test] From f3dd3ab774c7b3b644a7482842bca873b73dc0de Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 19:04:41 +0800 Subject: [PATCH 21/23] fix(desktop): remove duplicate network.contains check in spawn_proxy --- packages/desktop-shell/src-tauri/src/local_control.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/desktop-shell/src-tauri/src/local_control.rs b/packages/desktop-shell/src-tauri/src/local_control.rs index 96fb58ea958..262e5154a18 100644 --- a/packages/desktop-shell/src-tauri/src/local_control.rs +++ b/packages/desktop-shell/src-tauri/src/local_control.rs @@ -170,10 +170,6 @@ fn spawn_proxy( let _ = client.shutdown(Shutdown::Both); break; } - if !network.contains(peer.ip()) { - let _ = write_rejection(&mut client, 403, "Forbidden"); - continue; - } if client.set_nonblocking(false).is_err() { continue; } From ef1f96b723424dbe4db9e673d7117aedf07718a7 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 19:24:39 +0800 Subject: [PATCH 22/23] fix(desktop): clear stale click suppression and exclude virtual interfaces - Reset ignoreNextClickRef on pointerdown when no active hold to fix release-outside click suppression leak - Add regression test for release-outside stop click - Add abort assertion to quick-hold-as-tap test - Exclude virtual interfaces (utun, docker, vEthernet, etc.) from Local Control LAN selection to prevent VPN adapters bypassing the physical-LAN boundary - Add regression test for virtual interface exclusion --- .../src-tauri/src/local_control.rs | 66 +++++++++++++++++++ .../client/voice/VoiceButton.test.tsx | 35 ++++++++++ .../web-shell/client/voice/VoiceButton.tsx | 4 +- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/desktop-shell/src-tauri/src/local_control.rs b/packages/desktop-shell/src-tauri/src/local_control.rs index 262e5154a18..2e237ea2130 100644 --- a/packages/desktop-shell/src-tauri/src/local_control.rs +++ b/packages/desktop-shell/src-tauri/src/local_control.rs @@ -468,6 +468,43 @@ fn primary_lan_ipv4() -> Result { select_lan_ipv4(routed_ipv4().ok(), NetworkInterface::show().ok()) } +fn is_virtual_interface(name: &str) -> bool { + #[cfg(target_os = "macos")] + { + name.starts_with("utun") + || name.starts_with("llw") + || name.starts_with("awdl") + || name.starts_with("bridge") + || name.starts_with("gif") + || name.starts_with("stf") + || name.starts_with("ap") + || name.starts_with("XHC") + || name.starts_with("pdp_ip") + || name.contains("VPN") + || name.contains("TAP") + } + #[cfg(target_os = "linux")] + { + name.starts_with("docker") + || name.starts_with("veth") + || name.starts_with("br-") + || name.starts_with("virbr") + || name.contains("tun") + || name.contains("tap") + } + #[cfg(target_os = "windows")] + { + name.contains("Hyper-V") + || name.starts_with("vEthernet") + || name.contains("VPN") + || name.contains("TAP") + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + false + } +} + fn select_lan_ipv4( routed: Option, interfaces: Option>, @@ -484,6 +521,7 @@ fn select_lan_ipv4( .mac_addr .as_deref() .is_some_and(|mac| mac != "00:00:00:00:00:00") + && !is_virtual_interface(&interface.name) }) .flat_map(|interface| interface.addr) .filter_map(|address| match address { @@ -987,4 +1025,32 @@ mod tests { ); assert!(runtime_socket_addr(&Url::parse("http://0.0.0.0:4170/").expect("url")).is_err()); } + + #[test] + fn excludes_virtual_interfaces() { + let routed = Ipv4Addr::new(192, 168, 1, 20); + let interface = |name, address, netmask| { + NetworkInterface::new_afinet(name, address, netmask, Some(address), 1, false) + .with_mac_addr(Some("00:11:22:33:44:55".to_string())) + }; + let en0 = interface( + "en0", + routed, + Some(Ipv4Addr::new(255, 255, 255, 0)), + ); + // A virtual VPN adapter with the same routed address must not win + // over the physical LAN. + let utun = interface( + "utun3", + Ipv4Addr::new(100, 64, 0, 10), + Some(Ipv4Addr::new(255, 192, 0, 0)), + ); + let result = select_lan_ipv4( + Some(Ipv4Addr::new(100, 64, 0, 10)), + Some(vec![en0, utun]), + ) + .expect("physical LAN"); + assert_eq!(result.address, routed); + assert_eq!(result.netmask, Ipv4Addr::new(255, 255, 255, 0)); + } } diff --git a/packages/web-shell/client/voice/VoiceButton.test.tsx b/packages/web-shell/client/voice/VoiceButton.test.tsx index 7bc9b5fb9dc..2a8653d0907 100644 --- a/packages/web-shell/client/voice/VoiceButton.test.tsx +++ b/packages/web-shell/client/voice/VoiceButton.test.tsx @@ -716,6 +716,41 @@ describe('VoiceButton', () => { click(button); expect(mocks.capture.stop).not.toHaveBeenCalled(); + expect(mocks.capture.abort).not.toHaveBeenCalled(); + click(button); + expect(mocks.capture.stop).toHaveBeenCalledOnce(); + }); + + it('honours a stop click after a quick hold released outside', async () => { + const { root, container } = mount(false); + await flush(); + let button = container.querySelector('button'); + if (!button) throw new Error('VoiceButton did not render'); + pointer(button, 'pointerdown', 1, 0, 1_000); + + mocks.capture.status = 'recording'; + act(() => { + root.render( + {}} + target={legacyTarget} + />, + ); + }); + button = container.querySelector('button'); + if (!button) throw new Error('VoiceButton did not render'); + // Release outside: pointer capture delivers pointerup to the button, but + // the trailing click lands elsewhere — ignoreNextClickRef leaks. + mocks.capture.stop.mockClear(); + pointer(button, 'pointerup', 1, 0, 1_100); + const outside = document.createElement('button'); + click(outside); + + // A real click on the button must still stop the recording. + button = container.querySelector('button'); + if (!button) throw new Error('VoiceButton did not render'); + pointer(button, 'pointerdown', 1, 0, 2_000); click(button); expect(mocks.capture.stop).toHaveBeenCalledOnce(); }); diff --git a/packages/web-shell/client/voice/VoiceButton.tsx b/packages/web-shell/client/voice/VoiceButton.tsx index 72e97bae4ad..5de04cdfc92 100644 --- a/packages/web-shell/client/voice/VoiceButton.tsx +++ b/packages/web-shell/client/voice/VoiceButton.tsx @@ -331,6 +331,9 @@ export function VoiceButton({ }; const handlePointerDown = (event: React.PointerEvent) => { + if (voiceGate.mode === 'hold' && holdPointerIdRef.current === null) { + ignoreNextClickRef.current = false; + } if ( voiceGate.mode !== 'hold' || event.button !== 0 || @@ -340,7 +343,6 @@ export function VoiceButton({ ) { return; } - ignoreNextClickRef.current = false; event.preventDefault(); holdPointerIdRef.current = event.pointerId; holdStartedAtRef.current = event.timeStamp; From 01c4ff2f70183398eb96d626d163eec89ebe2650 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 11 Aug 2026 20:26:17 +0800 Subject: [PATCH 23/23] test(desktop): cover cross-platform close fallbacks --- packages/acp-bridge/src/bridge.test.ts | 20 +++++++++++++++++++ .../src-tauri/src/local_control.rs | 13 +++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index cf33cc4eb19..7e124032744 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -21007,6 +21007,26 @@ describe('session idle reaper', () => { await bridge.shutdown(); }); + it('cancels a session the agent did not close', async () => { + const handle = makeChannel({ + extMethodImpl: (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionClose + ? { closed: false } + : {}, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.closeSession(session.sessionId); + + expect(handle.agent.cancelCalls).toEqual([ + { sessionId: session.sessionId }, + ]); + await bridge.shutdown(); + }); + it('reaps multiple orphaned sessions in one tick', async () => { vi.useFakeTimers(); try { diff --git a/packages/desktop-shell/src-tauri/src/local_control.rs b/packages/desktop-shell/src-tauri/src/local_control.rs index 2e237ea2130..4f453081a16 100644 --- a/packages/desktop-shell/src-tauri/src/local_control.rs +++ b/packages/desktop-shell/src-tauri/src/local_control.rs @@ -1040,14 +1040,21 @@ mod tests { ); // A virtual VPN adapter with the same routed address must not win // over the physical LAN. - let utun = interface( - "utun3", + let virtual_name = if cfg!(target_os = "macos") { + "utun3" + } else if cfg!(target_os = "windows") { + "vEthernet (Default Switch)" + } else { + "tun0" + }; + let virtual_interface = interface( + virtual_name, Ipv4Addr::new(100, 64, 0, 10), Some(Ipv4Addr::new(255, 192, 0, 0)), ); let result = select_lan_ipv4( Some(Ipv4Addr::new(100, 64, 0, 10)), - Some(vec![en0, utun]), + Some(vec![en0, virtual_interface]), ) .expect("physical LAN"); assert_eq!(result.address, routed);