diff --git a/src/state/useCrewController.test.tsx b/src/state/useCrewController.test.tsx index 722c3da..b582a28 100644 --- a/src/state/useCrewController.test.tsx +++ b/src/state/useCrewController.test.tsx @@ -19,6 +19,16 @@ describe("useCrewController", () => { expect(listModelProviders).toHaveBeenCalledTimes(2); }); + it("discovers subscriptions added in the browser when the app regains focus", async () => { + let providers: Awaited>["providers"] = []; + const client = { listModelProviders: async () => ({ organizationId: "org-test", providers }), listAgents: async () => [] } as unknown as CloudAgentsClient; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(result.current.loading).toBe(false)); + providers = [{ id: "codex-1", name: "Codex subscription", protocol: "openai_responses" }]; + await act(async () => { window.dispatchEvent(new Event("focus")); }); + await waitFor(() => expect(result.current.modelProviders).toEqual(providers)); + }); + it("does not start Cloud Agents requests until authentication is enabled", async () => { const listAgents = vi.fn(); const listModelProviders = vi.fn(); const client = { listAgents, listModelProviders } as unknown as CloudAgentsClient; diff --git a/src/state/useCrewController.ts b/src/state/useCrewController.ts index fd5b0a3..4a60efd 100644 --- a/src/state/useCrewController.ts +++ b/src/state/useCrewController.ts @@ -77,13 +77,13 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { if (!enabled) return; const refreshWhenActive = () => { if (document.visibilityState === "hidden" || !navigator.onLine) return; - void refreshAgents().then(() => setError((current) => isTransientGatewayError(current) ? undefined : current)).catch(() => undefined); + void Promise.all([refreshAgents(), refreshModelProviders()]).then(() => setError((current) => isTransientGatewayError(current) ? undefined : current)).catch(() => undefined); }; const onVisibilityChange = () => { if (document.visibilityState === "visible") refreshWhenActive(); }; const timer = window.setInterval(refreshWhenActive, AGENT_FALLBACK_REFRESH_MS); window.addEventListener("focus", refreshWhenActive); window.addEventListener("online", refreshWhenActive); document.addEventListener("visibilitychange", onVisibilityChange); return () => { window.clearInterval(timer); window.removeEventListener("focus", refreshWhenActive); window.removeEventListener("online", refreshWhenActive); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [enabled, refreshAgents]); + }, [enabled, refreshAgents, refreshModelProviders]); useEffect(() => { if (!enabled) return; const cached = snapshots.current.get(selectedAgentId); diff --git a/src/ui/App.test.tsx b/src/ui/App.test.tsx index c4ea8dd..67fa7d9 100644 --- a/src/ui/App.test.tsx +++ b/src/ui/App.test.tsx @@ -10,6 +10,32 @@ import { SettingsDialog } from "./components/Dialogs"; import type { AppSettings, DesktopBridge } from "@/shared/desktop"; describe("Errand authentication surfaces", () => { + it("refreshes an open provider picker even when creation started outside Errand", async () => { + const user = userEvent.setup(); + let connected = false; + const openExternal = vi.fn(); + window.runtaCrew = { + openExternal, + settings: { get: async () => ({ endpoint: "https://api.forge", dashboardUrl: "https://app.forge", theme: "light", notifications: true }), set: async (settings: AppSettings) => settings }, + credentials: { has: async () => true }, + cloud: { request: async ({ path }: { path: string }) => ({ status: 200, body: path === "/v2/me" ? { data: { display_name: "Tester" } } : path === "/v2/model-providers" ? { organization_id: "org-test", model_providers: connected ? [{ id: "codex-1", display_name: "Codex subscription", protocol: "openai_responses", base_url: "https://chatgpt.com/backend-api/codex" }] : [] } : { agents: [] } }) }, + notifications: { setBadge: async () => undefined }, + deepLinks: { onOpenAgent: () => () => undefined }, + } as unknown as DesktopBridge; + const view = render(); + try { + await user.click(await screen.findByRole("button", { name: "New agent" })); + expect(await screen.findByRole("button", { name: "Add model provider" })).toBeInTheDocument(); + connected = true; + await user.click(await screen.findByRole("button", { name: "Model provider" }, { timeout: 4000 })); + expect(screen.getByRole("option", { name: "Codex subscription" })).toBeInTheDocument(); + expect(openExternal).not.toHaveBeenCalled(); + } finally { + view.unmount(); + delete window.runtaCrew; + } + }); + it("opens the dashboard add-provider page when no providers exist", async () => { const openExternal = vi.fn(async () => undefined); const user = userEvent.setup(); window.runtaCrew = { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 429fe8d..8355f01 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -49,7 +49,7 @@ export function App() { const [client, setClient] = useState(() => new RuntaCloudAgentsClient()); const crew = useCrewController(client, signedIn); const refreshModelProviders = crew.refreshModelProviders; const crewAgents = crew.agents; const selectAgent = crew.setSelectedAgentId; - const [search, setSearch] = useState(""); const [detailsOpen, setDetailsOpen] = useState(false); const [detailsMounted, setDetailsMounted] = useState(false); const [detailPanelWidth, setDetailPanelWidth] = useState(340); const [creatingAgentName, setCreatingAgentName] = useState(); const [creatingAgentPhase, setCreatingAgentPhase] = useState<"creating" | "typing">("creating"); const [creatingAgentBaselineIds, setCreatingAgentBaselineIds] = useState>(() => new Set()); const [composerFocusRequest, setComposerFocusRequest] = useState(0); const [settingsOpen, setSettingsOpen] = useState(false); const [providerPolling, setProviderPolling] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [editingAgent, setEditingAgent] = useState(); const [deletingAgent, setDeletingAgent] = useState(); + const [search, setSearch] = useState(""); const [detailsOpen, setDetailsOpen] = useState(false); const [detailsMounted, setDetailsMounted] = useState(false); const [detailPanelWidth, setDetailPanelWidth] = useState(340); const [creatingAgentName, setCreatingAgentName] = useState(); const [creatingAgentPhase, setCreatingAgentPhase] = useState<"creating" | "typing">("creating"); const [creatingAgentBaselineIds, setCreatingAgentBaselineIds] = useState>(() => new Set()); const [composerFocusRequest, setComposerFocusRequest] = useState(0); const [settingsOpen, setSettingsOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [editingAgent, setEditingAgent] = useState(); const [deletingAgent, setDeletingAgent] = useState(); const agents = crew.agents.filter((agent) => `${agent.name} ${agent.role}`.toLowerCase().includes(search.toLowerCase())); function readableAuthError(reason: unknown) { const raw = reason instanceof Error ? reason.message : ""; @@ -128,24 +128,23 @@ export function App() { return () => window.clearTimeout(timer); }, [detailsOpen]); useEffect(() => { - if (!settingsOpen || !providerPolling) return; + if (!signedIn || !settingsOpen) return; let stopped = false; let timer: number | undefined; const poll = async () => { try { - await refreshModelProviders(); + if (document.visibilityState !== "hidden" && navigator.onLine) await refreshModelProviders(); if (stopped) return; } catch { /* keep polling while Settings remains open */ } if (!stopped) timer = window.setTimeout(() => void poll(), 2_000); }; void poll(); return () => { stopped = true; if (timer !== undefined) window.clearTimeout(timer); }; - }, [providerPolling, refreshModelProviders, settingsOpen]); + }, [signedIn, refreshModelProviders, settingsOpen]); function handleAgentAction(agent: Agent, action: AgentAction) { if (action === "edit") setEditingAgent(agent); if (action === "delete") setDeletingAgent(agent); } const dismissError = () => { crew.dismissError(); setAuthError(undefined); }; - const startModelProviderPolling = () => { dismissError(); setProviderPolling(true); }; async function createDefaultAgent() { if (creatingAgentName) return; const name = nextAgentName(crew.agents.map((agent) => agent.name)); @@ -155,7 +154,6 @@ export function App() { const settings = await window.runtaCrew?.settings.get(); const provider = crew.modelProviders.find((item) => item.id === settings?.modelProviderId); if (!provider) { - setAuthError("Select a model provider before creating an agent."); setSettingsOpen(true); return; } @@ -185,7 +183,7 @@ export function App() { {editingAgent && setEditingAgent(undefined)} onSave={(input) => crew.updateAgent(editingAgent.id, input)} />} {deletingAgent && setDeletingAgent(undefined)} onDelete={() => crew.deleteAgent(deletingAgent.id)} />} - {settingsOpen && { setProviderPolling(false); setSettingsOpen(false); void logout(); }} onClose={() => { setProviderPolling(false); setSettingsOpen(false); }} />} + {settingsOpen && { setSettingsOpen(false); void logout(); }} onClose={() => { setSettingsOpen(false); }} />} setPaletteOpen(false)} onSelectAgent={crew.setSelectedAgentId} onCreateAgent={() => void createDefaultAgent()} onSettings={() => setSettingsOpen(true)} onComputer={() => setDetailsOpen(true)} /> ; } diff --git a/src/ui/components/Dialogs.tsx b/src/ui/components/Dialogs.tsx index 589a60d..9979d42 100644 --- a/src/ui/components/Dialogs.tsx +++ b/src/ui/components/Dialogs.tsx @@ -59,7 +59,7 @@ export function SettingsDialog({ mode = "preferences", providers = [], organizat

Account

{initials}{accountName}{accountEmail && {accountEmail}}{onLogout && }

Agents

Model provider{providers.length ?