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;