You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/27 13:58:55 UTC

(maka) branch main updated: fix(desktop): observe the first turn before admission (#4018)

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new e0be705c0 fix(desktop): observe the first turn before admission (#4018)
e0be705c0 is described below

commit e0be705c015f3af973408da4b5c69845983e576e
Author: Wang <m4...@gmail.com>
AuthorDate: Thu Aug 27 21:58:50 2026 +0800

    fix(desktop): observe the first turn before admission (#4018)
    
    * fix(desktop): observe the first turn before admission
    
    Generated-by: OpenAI Codex
    
    * fix(desktop): bound first-send observation wait
    
    Generated-by: OpenAI Codex
---
 apps/desktop/e2e/streaming-remount.spec.ts         | 31 +++++++-
 .../app-shell-busy-race-settlement.test.ts         |  8 +-
 .../__tests__/app-shell-chat-actions-fixture.ts    |  7 +-
 .../__tests__/app-shell-first-send-cleanup.test.ts | 91 ++++++++++++++++++++++
 apps/desktop/src/preload/preload.ts                | 31 ++++++++
 .../desktop/src/renderer/app-shell-chat-actions.ts | 52 +++++++------
 apps/desktop/src/renderer/app-shell-effects.ts     | 61 +++++++++++----
 apps/desktop/src/renderer/app-shell.tsx            | 57 +++++++++++++-
 8 files changed, 298 insertions(+), 40 deletions(-)

diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts
index 909e2ce01..998cd3c6b 100644
--- a/apps/desktop/e2e/streaming-remount.spec.ts
+++ b/apps/desktop/e2e/streaming-remount.spec.ts
@@ -25,6 +25,13 @@ import {
 import type { Locator } from '@playwright/test';
 import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures';
 
+interface SessionObservationLatchWindow extends Window {
+  /** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */
+  makaE2eLatch?: {
+    rejectNextSessionObservation(message: string): void;
+  };
+}
+
 function sessionRow(sidebar: Locator, sessionId: string): Locator {
   return sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`);
 }
@@ -36,6 +43,28 @@ async function steerActiveTurn(composer: Locator, text: string): Promise<void> {
   await composer.press('Shift+Enter');
 }
 
+test('a failed first observation seed reconnects to the live Turn', async ({ window: page }) => {
+  const latchInstalled = await page.evaluate(() => {
+    const latch = (window as SessionObservationLatchWindow).makaE2eLatch;
+    if (!latch) return false;
+    latch.rejectNextSessionObservation('forced first observation failure');
+    return true;
+  });
+  expect(latchInstalled, 'the preload E2E latch is installed').toBe(true);
+
+  const composer = page.locator(COMPOSER_INPUT);
+  await composer.fill(FAKE_HOLD_OPEN_PROMPT);
+  await composer.press('Enter');
+
+  await expect(page.locator('.maka-bubble-streaming')).toContainText(
+    'Fake backend waiting',
+  );
+  await page.getByRole('button', { name: '停止' }).click();
+  await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
+    timeout: 20_000,
+  });
+});
+
 test('remounting a live surface leaves accumulated output settled', async ({
   window: page,
 }) => {
@@ -130,7 +159,7 @@ test('keeps a completed reply after an interrupted turn and conversation remount
   await composer.fill(FAKE_HOLD_OPEN_PROMPT);
   await composer.press('Enter');
   await expect(page.locator('.maka-bubble-streaming')).toContainText(
-    'Fake backend waiting for the test to stop the Turn.',
+    'Fake backend waiting',
   );
   const originalSessionId = await sidebar
     .locator('[data-session-id]:has([aria-current="page"])')
diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts
index b4661d00d..f74ee3687 100644
--- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts
@@ -459,8 +459,11 @@ describe('busy-raced send settlement', () => {
       const actions = createAppShellChatActions({
         ...createActionsDeps(),
         activeIdRef,
+        activateSessionForFirstSend: async (sessionId) => {
+          activated.push(sessionId);
+          activeIdRef.current = sessionId;
+        },
         setActiveId: (sessionId: string | undefined) => {
-          if (sessionId !== undefined) activated.push(sessionId);
           activeIdRef.current = sessionId;
         },
         setLiveTurnBySession: turnState.setLiveTurnBySession,
@@ -500,6 +503,9 @@ describe('busy-raced send settlement', () => {
       const actions = createAppShellChatActions({
         ...createActionsDeps(),
         activeIdRef,
+        activateSessionForFirstSend: async (sessionId) => {
+          activeIdRef.current = sessionId;
+        },
         setActiveId: (sessionId: string | undefined) => {
           activeIdRef.current = sessionId;
         },
diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts
index 19c8e86b7..f7f2bbc22 100644
--- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts
@@ -92,9 +92,10 @@ export function createTransientState() {
 }
 
 export function createActionsDeps() {
+  const activeIdRef = { current: undefined as string | undefined };
   return {
     uiLocale: 'en' as const,
-    activeIdRef: { current: undefined as string | undefined },
+    activeIdRef,
     addPendingSessionAction: () => true,
     captureComposerImportOwner: () => ({
       sessionId: undefined,
@@ -107,6 +108,9 @@ export function createActionsDeps() {
     markSessionReadLocally: () => undefined,
     messageRetryPendingRef: { current: new Set<string>() },
     refreshSessions: async () => [],
+    activateSessionForFirstSend: async (sessionId: string) => {
+      activeIdRef.current = sessionId;
+    },
     setActiveId: () => undefined,
     setMessageLoadErrorBySession: () => undefined,
     setMessageRetryPendingBySession: () => undefined,
@@ -115,7 +119,6 @@ export function createActionsDeps() {
     updateTransientMessage: () => undefined,
     removeTransientMessage: () => undefined,
     transcriptRangeRef: { current: undefined },
-    setNavSelection: () => undefined,
     setLiveTurnBySession: () => undefined,
     setInteractionBySession: () => undefined,
     showModelSetupToast: () => undefined,
diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts
index bdaa74c16..2f688e976 100644
--- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts
@@ -301,6 +301,97 @@ describe('composer first-send cleanup', () => {
     assert.deepEqual(removed, []);
   });
 
+  it('waits for the new session observation before submitting its first message', async () => {
+    const observation = deferred<void>();
+    const order: string[] = [];
+    const activeIdRef = { current: undefined as string | undefined };
+    const restoreWindow = installWindow({
+      newTasks: {
+        create: async () => {
+          order.push('create');
+          return { id: 'session-1' };
+        },
+      },
+      sessions: {
+        submitMessage: async () => {
+          order.push('submit');
+          return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } };
+        },
+      },
+    });
+
+    try {
+      const sending = createAppShellChatActions({
+        ...createActionsDeps(),
+        activeIdRef,
+        activateSessionForFirstSend: async (sessionId) => {
+          order.push('observe');
+          activeIdRef.current = sessionId;
+          await observation.promise;
+          order.push('seeded');
+        },
+      }).send('hello');
+      await new Promise((resolve) => setImmediate(resolve));
+      assert.deepEqual(order, ['create', 'observe']);
+
+      observation.resolve();
+      assert.equal(await sending, true);
+      assert.deepEqual(order, ['create', 'observe', 'seeded', 'submit']);
+    } finally {
+      restoreWindow();
+    }
+  });
+
+  it('removes the unsent session and surfaces an observation barrier failure', async () => {
+    const activeIdRef = { current: undefined as string | undefined };
+    const removed: string[] = [];
+    const errors: string[] = [];
+    let submissions = 0;
+    const restoreWindow = installWindow({
+      newTasks: { create: async () => ({ id: 'session-1' }) },
+      sessions: {
+        submitMessage: async () => {
+          submissions += 1;
+          return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } };
+        },
+        remove: async (sessionId: string) => {
+          removed.push(sessionId);
+        },
+      },
+    });
+
+    try {
+      const actions = createAppShellChatActions({
+        ...createActionsDeps(),
+        activeIdRef,
+        activateSessionForFirstSend: async (sessionId) => {
+          activeIdRef.current = sessionId;
+          throw new Error('Timed out while preparing the new Session event stream');
+        },
+        setActiveId: (sessionId) => {
+          activeIdRef.current = sessionId;
+        },
+        isNewChatSendSurfaceActive: () => activeIdRef.current === undefined,
+        isShellSurfaceOwnerActive: (owner) =>
+          owner.navSection === 'sessions' && owner.sessionId === activeIdRef.current,
+        toastApi: {
+          error: (_title, description) => {
+            if (description) errors.push(description);
+          },
+          info: () => undefined,
+        },
+      });
+      assert.equal(await actions.send('hello'), false);
+    } finally {
+      restoreWindow();
+    }
+
+    assert.equal(submissions, 0);
+    assert.deepEqual(removed, ['session-1']);
+    assert.equal(activeIdRef.current, undefined);
+    assert.deepEqual(errors, ['The message could not be sent. Try again later.']);
+  });
+
   it('leaves an EXISTING session alone when its send rejects', async () => {
     // Only the session this send created is disposable. A send that fails in
     // an open conversation must not delete the conversation.
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 18ad2260e..e5550f015 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -3320,6 +3320,7 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) {
   type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list' | 'settings.chunk';
   const gates = new Map<LatchKey, { promise: Promise<void>; oneShot: boolean }>();
   const releases = new Map<LatchKey, { resolve: () => void; reject: (error: Error) => void }>();
+  let nextSessionObservationError: Error | undefined;
   const invocableSkillsWaiters = new Map<string, Array<() => void>>();
   const waitForLatch = async (key: LatchKey): Promise<void> => {
     const gate = gates.get(key);
@@ -3342,6 +3343,33 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) {
     makaBridge.sessions.list.bind(makaBridge.sessions),
     'sessions.list',
   );
+  const subscribeSessionEvents = makaBridge.sessions.subscribeEvents.bind(makaBridge.sessions);
+  makaBridge.sessions.subscribeEvents = (
+    sessionId,
+    handler,
+    onSeeded,
+    onObservationSeed,
+    onSeedError,
+  ) => {
+    const nextError = nextSessionObservationError;
+    nextSessionObservationError = undefined;
+    if (!nextError) {
+      return subscribeSessionEvents(
+        sessionId,
+        handler,
+        onSeeded,
+        onObservationSeed,
+        onSeedError,
+      );
+    }
+    let disposed = false;
+    void Promise.resolve().then(() => {
+      if (!disposed) onSeedError?.(nextError);
+    });
+    return () => {
+      disposed = true;
+    };
+  };
   const listInvocableSkills = makaBridge.skills.listInvocable.bind(makaBridge.skills);
   makaBridge.skills.listInvocable = async (...args) => {
     try {
@@ -3381,6 +3409,9 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) {
         invocableSkillsWaiters.set(sessionId, waiters);
       });
     },
+    rejectNextSessionObservation(message: string) {
+      nextSessionObservationError = new Error(message);
+    },
     release(key: LatchKey) {
       releases.get(key)?.resolve();
       releases.delete(key);
diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts
index 69373f745..005013b32 100644
--- a/apps/desktop/src/renderer/app-shell-chat-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts
@@ -157,6 +157,7 @@ export function createAppShellChatActions(deps: {
   isShellSurfaceOwnerActive: (owner: ComposerImportOwner) => boolean;
   messageRetryPendingRef: RefBox<Set<string>>;
   refreshSessions: () => Promise<DesktopSessionSummary[]>;
+  activateSessionForFirstSend: (sessionId: string) => Promise<void>;
   setActiveId: (sessionId: string | undefined) => void;
   setMessageLoadErrorBySession: MessageLoadErrorUpdater;
   setMessageRetryPendingBySession: BooleanRecordUpdater;
@@ -171,7 +172,6 @@ export function createAppShellChatActions(deps: {
   ) => void;
   removeTransientMessage: (sessionId: string, messageId: string) => void;
   transcriptRangeRef: RefBox<DesktopTranscriptRangeController | undefined>;
-  setNavSelection: (selection: NavSelection) => void;
   /** #646: arm the "正在处理…" indicator locally at send() — the model-wait
    * window opens before any SessionEvent arrives (turn_started is not one). */
   setLiveTurnBySession: LiveTurnRecordUpdater;
@@ -214,6 +214,7 @@ export function createAppShellChatActions(deps: {
     isShellSurfaceOwnerActive,
     messageRetryPendingRef,
     refreshSessions,
+    activateSessionForFirstSend,
     setActiveId,
     setMessageLoadErrorBySession,
     setMessageRetryPendingBySession,
@@ -222,7 +223,6 @@ export function createAppShellChatActions(deps: {
     updateTransientMessage,
     removeTransientMessage,
     transcriptRangeRef,
-    setNavSelection,
     setLiveTurnBySession,
     setInteractionBySession,
     onInteractionChanged,
@@ -449,6 +449,7 @@ export function createAppShellChatActions(deps: {
       unsentSessionId = undefined;
       try {
         await window.maka.sessions.remove(sessionId);
+        if (activeIdRef.current === sessionId) setActiveId(undefined);
         await refreshSessions();
       } catch {
         // Best-effort: a failed cleanup must not replace the real error.
@@ -473,10 +474,19 @@ export function createAppShellChatActions(deps: {
           orchestrationMode: newChatOrchestrationMode,
         });
         unsentSessionId = session.id;
+        optimisticSessionId = session.id;
         // Consumed: the choice is now the created Session's, not the next
         // draft's. A failed create leaves it in place so a retry keeps it.
         if (newChatPermissionChoice) clearNewChatPermissionChoice();
-        optimisticSessionId = session.id;
+        // Active-stream snapshots can only restore assistant segments that
+        // are still streaming. Wait until the observer is ready before the
+        // first admission so a completed segment in a still-running Turn
+        // cannot become durable text without live identity.
+        await activateSessionForFirstSend(session.id);
+        if (activeIdRef.current !== session.id) {
+          await discardUnsentSession();
+          return false;
+        }
         optimisticMessageId = messageId;
         showTransientUserMessage(
           session.id,
@@ -522,8 +532,7 @@ export function createAppShellChatActions(deps: {
           ...(options.displayText ? { displayText: options.displayText } : {}),
           ...(quotes && quotes.length > 0 ? { quotes } : {}),
           exactTurn,
-          isSurfaceVisible: () =>
-            Boolean(newChatOwner && isNewChatSendSurfaceActive(newChatOwner)),
+          isSurfaceVisible: () => activeIdRef.current === session.id,
         });
         if (submitted.kind === 'refused') {
           await discardUnsentSession();
@@ -531,10 +540,6 @@ export function createAppShellChatActions(deps: {
         }
         unsentSessionId = undefined;
         options.onSessionResolved?.(session.id);
-        if (newChatOwner && isNewChatSendSurfaceActive(newChatOwner)) {
-          setNavSelection({ section: 'sessions' });
-          setActiveId(session.id);
-        }
         await refreshSessions();
         return true;
       }
@@ -607,6 +612,22 @@ export function createAppShellChatActions(deps: {
       options.onSessionResolved?.(sessionId);
       return true;
     } catch (error) {
+      // Capture ownership before cleanup clears the optimistic Session. A
+      // barrier timeout belongs to the surface that was waiting for it, while
+      // navigation away still suppresses feedback.
+      const feedbackSessionId = optimisticSessionId ?? initialSessionId;
+      const diagnosticTarget = feedbackSessionId
+        ? { sessionId: feedbackSessionId }
+        : initialNewTaskTarget
+          ? { profileId: initialNewTaskTarget.profileId }
+          : undefined;
+      const sendStillOwnsCurrentSurface =
+        (feedbackSessionId !== undefined &&
+          isShellSurfaceOwnerActive({
+            ...sendOwner,
+            sessionId: feedbackSessionId,
+          })) ||
+        (newChatOwner !== null && isNewChatSendSurfaceActive(newChatOwner));
       await discardUnsentSession();
       if (optimisticSessionId && optimisticMessageId) {
         removeOptimisticUserMessage(optimisticSessionId, optimisticMessageId);
@@ -630,19 +651,6 @@ export function createAppShellChatActions(deps: {
       // The owner MOVES on an optimistic create: the send began on the new-chat
       // surface and the app is now on the session it just made, so the id is
       // taken from the flight and only the section comes from the capture.
-      const feedbackSessionId = optimisticSessionId ?? initialSessionId;
-      const diagnosticTarget = feedbackSessionId
-        ? { sessionId: feedbackSessionId }
-        : initialNewTaskTarget
-          ? { profileId: initialNewTaskTarget.profileId }
-          : undefined;
-      const sendStillOwnsCurrentSurface =
-        (feedbackSessionId !== undefined &&
-          isShellSurfaceOwnerActive({
-            ...sendOwner,
-            sessionId: feedbackSessionId,
-          })) ||
-        (newChatOwner !== null && isNewChatSendSurfaceActive(newChatOwner));
       if (!sendStillOwnsCurrentSurface) return false;
       if (isNoRealConnectionError(error)) {
         const reason = noRealConnectionReasonFromError(error);
diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts
index 20c83217a..6277ff143 100644
--- a/apps/desktop/src/renderer/app-shell-effects.ts
+++ b/apps/desktop/src/renderer/app-shell-effects.ts
@@ -425,8 +425,11 @@ export function useActiveSessionEvents(options: {
 
   useLayoutEffect(() => {
     if (!activeId) return;
-    const observationGeneration = beginObservationSeed(activeId);
     let disposed = false;
+    let observationAttempt = 0;
+    let observationFailures = 0;
+    let observationRetryTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
+    let unsubscribeSessionEvents = () => {};
     const transcript = new DesktopTranscriptRangeStore(activeId);
     const subscribedAt = Date.now();
     options.setMessageLoadErrorBySession((current) => {
@@ -465,24 +468,56 @@ export function useActiveSessionEvents(options: {
       applyReadError(activeId, error, () => disposed);
     });
     options.transcriptRangeRef.current = controller;
-    const unsubscribe = window.maka.sessions.subscribeEvents(
-      activeId,
-      (event) => {
-        handleSessionEvent(activeId, event);
-      },
-      () => completeObservationSeed(activeId, observationGeneration),
-      (phase) => {
-        if (phase === 'pending') beginObservationSeed(activeId);
-        else completeObservationSeed(activeId);
-      },
-    );
+    const subscribeSessionEvents = () => {
+      const attempt = ++observationAttempt;
+      const observationGeneration = beginObservationSeed(activeId);
+      let unsubscribeRequested = false;
+      let unsubscribeCurrent = () => {
+        unsubscribeRequested = true;
+      };
+      const unsubscribe = window.maka.sessions.subscribeEvents(
+        activeId,
+        (event) => {
+          if (attempt !== observationAttempt) return;
+          handleSessionEvent(activeId, event);
+        },
+        () => {
+          if (attempt !== observationAttempt) return;
+          observationFailures = 0;
+          completeObservationSeed(activeId, observationGeneration);
+        },
+        (phase) => {
+          if (attempt !== observationAttempt) return;
+          if (phase === 'pending') beginObservationSeed(activeId);
+          else completeObservationSeed(activeId);
+        },
+        () => {
+          if (disposed || attempt !== observationAttempt) return;
+          unsubscribeCurrent();
+          observationFailures += 1;
+          const retryDelayMs = Math.min(100 * (2 ** (observationFailures - 1)), 2_000);
+          observationRetryTimer = globalThis.setTimeout(() => {
+            observationRetryTimer = undefined;
+            if (!disposed && attempt === observationAttempt) subscribeSessionEvents();
+          }, retryDelayMs);
+        },
+      );
+      unsubscribeCurrent = unsubscribe;
+      unsubscribeSessionEvents = unsubscribe;
+      if (unsubscribeRequested) unsubscribe();
+    };
+    subscribeSessionEvents();
     return () => {
       disposed = true;
+      observationAttempt += 1;
+      if (observationRetryTimer !== undefined) {
+        globalThis.clearTimeout(observationRetryTimer);
+      }
       if (options.transcriptRangeRef.current?.store === transcript) {
         options.transcriptRangeRef.current = undefined;
       }
       void controller.close();
-      unsubscribe();
+      unsubscribeSessionEvents();
       markSessionEventStreamClosed(activeId);
     };
   }, [activeId]);
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 7a8fc2e9d..9b91c73ea 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -258,6 +258,13 @@ function newTaskDraftKey(target: {
  * assistant stream slot when the primary post-commit signal is missed.
  */
 const SETTLE_FALLBACK_GRACE_MS = 1000;
+const FIRST_SEND_OBSERVATION_TIMEOUT_MS = 30_000;
+type FirstSendObservationWaiter = {
+  promise: Promise<void>;
+  resolve: () => void;
+  reject: (error: Error) => void;
+  timeoutId: ReturnType<typeof globalThis.setTimeout>;
+};
 /**
  * Module surfaces that own their whole column and render no workspace toolbar.
  * This used to be a `display: none` rule keyed on the detail panel's
@@ -1744,6 +1751,48 @@ function AppShellContent({
     [sessions, localProjects, sessionNavigation.commands],
   );
 
+  const firstSendObservationWaitersRef = useRef(
+    new Map<string, FirstSendObservationWaiter>(),
+  );
+  const activateSessionForFirstSend = useCallback((sessionId: string): Promise<void> => {
+    let waiter = firstSendObservationWaitersRef.current.get(sessionId);
+    if (!waiter) {
+      let resolve!: () => void;
+      let reject!: (error: Error) => void;
+      const promise = new Promise<void>((resolvePromise, rejectPromise) => {
+        resolve = resolvePromise;
+        reject = rejectPromise;
+      });
+      let created!: FirstSendObservationWaiter;
+      const timeoutId = globalThis.setTimeout(() => {
+        if (firstSendObservationWaitersRef.current.get(sessionId) !== created) return;
+        firstSendObservationWaitersRef.current.delete(sessionId);
+        created.reject(new Error('Timed out while preparing the new Session event stream'));
+      }, FIRST_SEND_OBSERVATION_TIMEOUT_MS);
+      created = { promise, resolve, reject, timeoutId };
+      waiter = created;
+      firstSendObservationWaitersRef.current.set(sessionId, waiter);
+    }
+    setNavSelection({ section: 'sessions' });
+    setActiveId(sessionId);
+    return waiter.promise;
+  }, [setActiveId, setNavSelection]);
+  useEffect(() => {
+    for (const [sessionId, waiter] of firstSendObservationWaitersRef.current) {
+      if (sessionId === activeId) continue;
+      firstSendObservationWaitersRef.current.delete(sessionId);
+      globalThis.clearTimeout(waiter.timeoutId);
+      waiter.reject(new Error('The new Session was left before its event stream became ready'));
+    }
+  }, [activeId]);
+  useEffect(() => () => {
+    for (const [sessionId, waiter] of firstSendObservationWaitersRef.current) {
+      firstSendObservationWaitersRef.current.delete(sessionId);
+      globalThis.clearTimeout(waiter.timeoutId);
+      waiter.reject(new Error('The app closed before the new Session event stream became ready'));
+    }
+  }, []);
+
   const { applyE2eFixture } = useStableActions(createAppShellE2eFixtureActions, {
     openSettingsSection,
     refreshSessions,
@@ -1778,6 +1827,7 @@ function AppShellContent({
     isShellSurfaceOwnerActive,
     messageRetryPendingRef,
     refreshSessions,
+    activateSessionForFirstSend,
     setActiveId,
     setMessageLoadErrorBySession,
     setMessageRetryPendingBySession,
@@ -1786,7 +1836,6 @@ function AppShellContent({
     updateTransientMessage,
     removeTransientMessage,
     transcriptRangeRef,
-    setNavSelection,
     setLiveTurnBySession,
     setInteractionBySession,
     onInteractionChanged: markInteractionChanged,
@@ -2312,6 +2361,12 @@ function AppShellContent({
     const next = completeLiveContentSeed(current, sessionId, expected);
     activeEventSeedRef.current = next;
     setActiveEventSeed(next);
+    const firstSendWaiter = firstSendObservationWaitersRef.current.get(sessionId);
+    if (firstSendWaiter) {
+      firstSendObservationWaitersRef.current.delete(sessionId);
+      globalThis.clearTimeout(firstSendWaiter.timeoutId);
+      firstSendWaiter.resolve();
+    }
     void retireCancelledTransientMessages(sessionId);
   };
   useActiveSessionEvents({


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org