diff --git a/surfaces/gui/e2e/boot.spec.ts b/surfaces/gui/e2e/boot.spec.ts index c5000875..53743b95 100644 --- a/surfaces/gui/e2e/boot.spec.ts +++ b/surfaces/gui/e2e/boot.spec.ts @@ -42,3 +42,34 @@ test("model picker recovers when settings fetches die during sidecar boot", asyn }); await expect(page.getByTestId("models-loading")).toHaveCount(0); }); + +test("coworker picker recovers when the persona fetch dies during sidecar boot", async ({ + page, +}) => { + // Same cold-start shape as above, for /v1/personas (owner-hit 2026-08-13, packaged app): + // the mount-time fetch loses to the sidecar boot and its only other trigger is + // PERSONAS_CHANGED, so the composer's picker stayed empty for the WHOLE session — while + // Settings ▸ Coworkers (mounted later) listed everything and looked healthy. + let sidecarUp = false; + await page.route("**/v1/health", async (route) => { + await new Promise((r) => setTimeout(r, 700)); + sidecarUp = true; + await route.fallback(); + }); + await page.route("**/v1/personas", async (route) => { + if (route.request().method() === "GET" && !sidecarUp) { + await route.abort(); + return; + } + await route.fallback(); + }); + + await page.goto("/"); + await page.getByText("New session").first().click(); + await page.getByTestId("coworker-chip").click(); + + // The menu must list real coworkers, not just its Import/Manage footer. + const menu = page.locator(".setup-menu"); + await expect(menu.getByText("Code Coworker")).toBeVisible({ timeout: 10_000 }); + await expect(menu.getByTestId("import-coworker")).toBeVisible(); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index bf11dc57..006b3d07 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -338,14 +338,16 @@ export function App() { // Persona metadata drives workspace behavior by FAMILY, not by hardcoded id (so a DevOps/SecOps // code-family persona gates a folder like Code, and a knowledge persona starts orphan like Cowork). const [personas, setPersonas] = useState(null); + const loadPersonas = useCallback(() => { + getPersonas().then(setPersonas).catch(() => {}); + }, []); useEffect(() => { - const load = () => getPersonas().then(setPersonas).catch(() => {}); - load(); + loadPersonas(); // The composer's coworker picker is always mounted on a fresh session — refetch on // mutations (enable/install from Settings) instead of going stale. - window.addEventListener(PERSONAS_CHANGED, load); - return () => window.removeEventListener(PERSONAS_CHANGED, load); - }, []); + window.addEventListener(PERSONAS_CHANGED, loadPersonas); + return () => window.removeEventListener(PERSONAS_CHANGED, loadPersonas); + }, [loadPersonas]); const personaOf = (a: string) => personas?.find((p) => p.id === a); // Pending Inbox items for the ACTIVE session — surfaced inline above the composer so an @@ -505,6 +507,11 @@ export function App() { // on a cold start that left "Loading models…" stuck until the user visited // Settings (owner-hit 2026-07-23). Health just answered, so this one lands. loadSettings(); + // Same race, same fix: the mount-time persona fetch loses to the sidecar boot in + // the packaged app, and its only other trigger is PERSONAS_CHANGED — so the + // composer's coworker picker stayed empty for the whole session while Settings + // (mounted later) looked fine (owner-hit 2026-08-13). + loadPersonas(); if (!cancelled) setBooting(false); }) .catch(() => {