From 8f5471304c8c9c6c9deb791cb63db9898b48196d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Tue, 25 Aug 2026 11:45:46 +0800 Subject: [PATCH] fix(webui): hydrate goal state independently --- .../session/DaemonSessionProvider.test.tsx | 84 +++++++++++++++++-- .../daemon/session/DaemonSessionProvider.tsx | 80 ++++++++++++------ 2 files changed, 134 insertions(+), 30 deletions(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index b55fcdbdd67..8c8bd3aefe3 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -1902,11 +1902,9 @@ describe('DaemonSessionProvider', () => { expect(sessionId).toBeDefined(); await act(async () => { actions?.applyGoalSnapshot(sessionId!, created); - }); - expect(connection?.goalState).toBe(created); - - pendingGoal.resolve({ snapshot: { v: 2, goal: null, activity: 'idle' } }); - await act(async () => { + pendingGoal.resolve({ + snapshot: { v: 2, goal: null, activity: 'idle' }, + }); await flushPromises(); }); @@ -8572,6 +8570,81 @@ describe('DaemonSessionProvider', () => { expect(loadCalls[1]?.[3]).toBe('client-a'); }); + it('hydrates Goal state without waiting for session metadata after a switch', async () => { + sdkMocks.sessions.push(createMockSession({ sessionId: 'session-a' })); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await flushPromises(); + }); + + const providers = createDeferred(); + const commands = + createDeferred>>(); + const context = + createDeferred>>(); + sdkMocks.workspaceProviders.mockReturnValueOnce(providers.promise); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-b', + supportedCommands: vi.fn(() => commands.promise), + context: vi.fn(() => context.promise), + }), + ); + + let loadSession: Promise | undefined; + act(() => { + loadSession = requireActions(actions).loadSession('session-b'); + }); + await act(async () => { + await wait(5); + await loadSession; + await flushPromises(); + }); + const goalStateBeforeMetadata = connection?.goalState; + + providers.resolve({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + providers: [], + }); + commands.resolve({ + v: 1, + sessionId: 'session-b', + availableCommands: [], + availableSkills: [], + }); + context.resolve({ + v: 1, + sessionId: 'session-b', + workspaceCwd: '/mock-workspace', + state: {}, + }); + await act(async () => { + await flushPromises(); + }); + + expect(goalStateBeforeMetadata).toEqual({ + v: 2, + goal: null, + activity: 'idle', + }); + expect(connection?.goalState).toEqual({ + v: 2, + goal: null, + activity: 'idle', + }); + }); + it('retries a session switch while the target session is closing', async () => { const firstSession = createMockSession({ sessionId: 'session-a' }); const secondSession = createMockSession({ sessionId: 'session-b' }); @@ -9113,6 +9186,7 @@ describe('DaemonSessionProvider', () => { expect(connection).toMatchObject({ sessionId: 'session-1', displayName: 'Updated session', + goalState: { v: 2, goal: null, activity: 'idle' }, }); }); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index ea72adb1eb8..760e513ce84 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -39,7 +39,6 @@ import { type DaemonTurnCompleteData, type DaemonUiEvent, type DaemonUnrecognizedDiagnostic, - type GoalSnapshotV2, } from '@qwen-code/sdk/daemon'; import { createDaemonSessionActions, @@ -2280,13 +2279,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { : activeSession.workspaceCwd ? client.workspaceByCwd(activeSession.workspaceCwd).workspaceGit() : client.workspaceGit(); - const [ - providerResult, - commandResult, - contextResult, - gitResult, - goalResult, - ] = await Promise.allSettled([ + const metadataPromise = Promise.allSettled([ canReuseSessionMetadata ? Promise.resolve(undefined) : client.workspaceProviders(), @@ -2297,8 +2290,55 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ? Promise.resolve(undefined) : activeSession.context(), gitPromise, - activeSession.goal(), ]); + // Hydrate Goal ownership independently so unrelated metadata cannot + // leave Slash commands blocked. Reconcile against any Goal frame + // that landed while the read was in flight. + const goalPromise = activeSession + .goal() + .then( + (response) => response.snapshot, + () => undefined, + ) + .then((goalState) => { + if ( + disposed || + abort.signal.aborted || + sessionRef.current !== activeSession + ) { + return goalState; + } + setConnection((current) => { + if ( + sessionRef.current !== activeSession || + current.sessionId !== activeSession.sessionId + ) { + return current; + } + if (!goalState && goalStateAtLoadStart !== undefined) { + return current; + } + return { + ...current, + goalState: goalState + ? selectGoalStateFromRead( + current.goalState, + goalState, + goalStateAtLoadStart?.goal?.goalId, + ) + : (current.goalState ?? { + v: 2, + goal: null, + activity: 'idle', + }), + }; + }); + return goalState; + }); + const [ + [providerResult, commandResult, contextResult, gitResult], + goalState, + ] = await Promise.all([metadataPromise, goalPromise]); if ( disposed || abort.signal.aborted || @@ -2322,22 +2362,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { gitResult?.status === 'fulfilled' ? (gitResult.value.branch ?? undefined) : undefined; - const goalState = - goalResult.status === 'fulfilled' - ? goalResult.value.snapshot - : undefined; - // A failed goal fetch on a session with no known state still needs a - // snapshot so consumers stop treating the state as hydrating; it must - // never reconcile against a state a frame installed meanwhile. const goalStateFallback = - goalResult.status === 'fulfilled' || - goalStateAtLoadStart !== undefined - ? undefined - : ({ - v: 2, - goal: null, - activity: 'idle', - } satisfies GoalSnapshotV2); + goalState === undefined && goalStateAtLoadStart === undefined + ? ({ v: 2, goal: null, activity: 'idle' } as const) + : undefined; const loadWarningTexts = [ providerResult?.status === 'rejected' ? loadWarningsRef.current?.models @@ -2375,7 +2403,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setConnection((current) => { if ( - sessionRef.current !== activeSession || + abort.signal.aborted || + (sessionRef.current !== undefined && + sessionRef.current !== activeSession) || current.sessionId !== activeSession.sessionId ) { return current;