From 3b1db5c926f8c918ec789d4835238389aa7a0c2a Mon Sep 17 00:00:00 2001 From: bitfathers94 <237535319+bitfathers94@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:49:02 +0000 Subject: [PATCH] fix(ui): stop rendering the disabled-resource sentinel as a real API failure useApiResource wrote the literal "disabled" into error as a synthetic sentinel for a switched-off resource, and MinerPanel rendered it through the same warning box as a genuine apiFetch failure -- so a signed-out visitor saw "Miner dashboard is unavailable right now (disabled)". Export API_RESOURCE_DISABLED and compare against it explicitly instead, gate MinerPanel on useSession's hydrated flag so the box never flashes before the session resolves, and split AgentRuns' source pill so a live API error no longer reads as "No session". --- .../site/app-panels/miner-panel.test.tsx | 61 ++++++++++++++++++ .../site/app-panels/miner-panel.tsx | 24 +++++-- .../src/lib/api/use-api-resource.ts | 11 +++- apps/loopover-ui/src/routes/app.runs.test.tsx | 64 +++++++++++++++++++ apps/loopover-ui/src/routes/app.runs.tsx | 29 +++++---- 5 files changed, 170 insertions(+), 19 deletions(-) diff --git a/apps/loopover-ui/src/components/site/app-panels/miner-panel.test.tsx b/apps/loopover-ui/src/components/site/app-panels/miner-panel.test.tsx index 7c1b48ad4f..6491d20ff9 100644 --- a/apps/loopover-ui/src/components/site/app-panels/miner-panel.test.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/miner-panel.test.tsx @@ -9,6 +9,7 @@ const { useApiResource, useSession } = vi.hoisted(() => ({ })); vi.mock("@/lib/api/use-api-resource", () => ({ useApiResource: (...args: unknown[]) => useApiResource(...args), + API_RESOURCE_DISABLED: "disabled", })); vi.mock("@/lib/api/session", () => ({ useSession: () => useSession() })); vi.mock("@/components/site/mcp-version-badge", () => ({ @@ -45,3 +46,63 @@ describe("MinerPanel loading skeleton (#793)", () => { expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1); }); }); + +// #9672: `useApiResource` writes the literal "disabled" into `error` as a synthetic sentinel for "this +// resource is switched off" (see `enabled: Boolean(login)` above) -- MinerPanel used to render that +// sentinel through the same warning box as a real `apiFetch` failure, so a signed-out visitor saw +// "Miner dashboard is unavailable right now (disabled)" instead of a sign-in prompt. +describe("MinerPanel signed-out sentinel (#9672)", () => { + it("renders a sign-in prompt, not the disabled sentinel, when signed out", () => { + useSession.mockReturnValue({ session: null, hydrated: true }); + useApiResource.mockReturnValue({ + status: "error", + data: null, + error: "disabled", + loadedAt: null, + reload: () => {}, + }); + + render(); + + expect(screen.getByText("Sign in to see your miner dashboard")).toBeTruthy(); + expect(document.body.textContent).not.toContain("disabled"); + }); + + it("renders the loading skeleton, not the signed-out state, before the session hydrates", () => { + useSession.mockReturnValue({ session: null, hydrated: false }); + useApiResource.mockReturnValue({ + status: "error", + data: null, + error: "disabled", + loadedAt: null, + reload: () => {}, + }); + + const { container } = render(); + + expect(screen.queryByText("Sign in to see your miner dashboard")).toBeNull(); + expect(document.body.textContent).not.toContain("disabled"); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1); + }); + + it("still renders the unavailable-now warning box for a real, signed-in dashboard failure", () => { + useSession.mockReturnValue({ + session: { login: "miner", roles: ["miner"] }, + hydrated: true, + }); + useApiResource.mockReturnValue({ + status: "error", + data: null, + error: "500 Internal Server Error", + loadedAt: null, + reload: () => {}, + }); + + render(); + + expect( + screen.getByText("Miner dashboard is unavailable right now (500 Internal Server Error)."), + ).toBeTruthy(); + expect(screen.queryByText("Sign in to see your miner dashboard")).toBeNull(); + }); +}); diff --git a/apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx b/apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx index f0f044451a..d2049b5130 100644 --- a/apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx +++ b/apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx @@ -7,7 +7,7 @@ import { TableScroll } from "@/components/site/data-table"; import { McpVersionBadge } from "@/components/site/mcp-version-badge"; import { StatCard } from "@/components/site/primitives"; import { RefreshMeta } from "@/components/site/refresh-meta"; -import { StateBoundary } from "@/components/site/state-views"; +import { EmptyState, StateBoundary } from "@/components/site/state-views"; import { Dialog, DialogContent, @@ -18,7 +18,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { apiFetch } from "@/lib/api/request"; import { getApiOrigin } from "@/lib/api/origin"; -import { useApiResource } from "@/lib/api/use-api-resource"; +import { API_RESOURCE_DISABLED, useApiResource } from "@/lib/api/use-api-resource"; import { useSession } from "@/lib/api/session"; import { buildMinerCommandActions, @@ -115,7 +115,7 @@ function MinerDashboardSkeleton() { } export function MinerPanel() { - const { session } = useSession(); + const { session, hydrated } = useSession(); const login = session?.login ?? ""; const dashboard = useApiResource( `/v1/app/miner-dashboard?login=${encodeURIComponent(login)}`, @@ -124,6 +124,15 @@ export function MinerPanel() { { enabled: Boolean(login) }, ); const data = dashboard.status === "ready" ? dashboard.data : null; + // Gated on `hydrated` (#9672): before the session hook resolves, `login` reads as "" the same way it + // does for a genuinely signed-out visitor, which would otherwise flash the sign-in prompt at every + // authenticated visitor on first paint. + const isSignedOut = hydrated && !login; + // `useApiResource` reuses `status: "error"` for both the synthetic "disabled" sentinel (this resource + // was never enabled) and a real `apiFetch` failure -- only the latter is a dashboard failure worth + // reporting here. + const isDashboardError = + dashboard.status === "error" && dashboard.error !== API_RESOURCE_DISABLED; const blockerCount = data?.blockers.reduce((count, group) => count + group.items.length, 0) ?? 0; const isEmpty = data !== null && @@ -189,7 +198,7 @@ export function MinerPanel() { /> - {dashboard.status === "error" ? ( + {isSignedOut ? ( + + ) : isDashboardError ? (
Miner dashboard is unavailable right now ({dashboard.error}).
diff --git a/apps/loopover-ui/src/lib/api/use-api-resource.ts b/apps/loopover-ui/src/lib/api/use-api-resource.ts index 420f1a251c..4f05e0fd03 100644 --- a/apps/loopover-ui/src/lib/api/use-api-resource.ts +++ b/apps/loopover-ui/src/lib/api/use-api-resource.ts @@ -21,12 +21,17 @@ type UseApiResourceOptions = { enabled?: boolean; }; -/** The synthetic sentinel a disabled resource reports. Frozen at module scope so every disabled resource - * returns the same object rather than a fresh one per render. */ +/** The synthetic sentinel a disabled resource reports, distinguishing "switched off" from a real + * `apiFetch` failure that happens to share the `status: "error"` shape. Exported so call sites that + * need to tell the two apart compare against this instead of a bare string literal (#9672). */ +export const API_RESOURCE_DISABLED = "disabled"; + +/** Frozen at module scope so every disabled resource returns the same object rather than a fresh one + * per render. */ const DISABLED_STATE: ResourceState = Object.freeze({ status: "error", data: null, - error: "disabled", + error: API_RESOURCE_DISABLED, loadedAt: null, }); diff --git a/apps/loopover-ui/src/routes/app.runs.test.tsx b/apps/loopover-ui/src/routes/app.runs.test.tsx index 7e8c1dba55..97f8f4ecb6 100644 --- a/apps/loopover-ui/src/routes/app.runs.test.tsx +++ b/apps/loopover-ui/src/routes/app.runs.test.tsx @@ -11,7 +11,30 @@ const { toastBase, success, error } = vi.hoisted(() => { }); vi.mock("sonner", () => ({ toast: toastBase })); +// Stubs for the routed AgentRuns component: a fake Route.useSearch() + a no-op useNavigate (mirroring +// app.index.test.tsx's pattern) so it can render without a real router, and the data/session hooks so +// it renders without touching the network. +const { useSearchMock, useApiResourceMock, useSessionMock } = vi.hoisted(() => ({ + useSearchMock: vi.fn(() => ({}) as Record), + useApiResourceMock: vi.fn(), + useSessionMock: vi.fn(), +})); +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (config: Record) => ({ + ...config, + useSearch: () => useSearchMock(), + fullPath: "/app/runs", + }), + useNavigate: () => () => Promise.resolve(), +})); +vi.mock("@/lib/api/use-api-resource", () => ({ + useApiResource: (...args: unknown[]) => useApiResourceMock(...args), + API_RESOURCE_DISABLED: "disabled", +})); +vi.mock("@/lib/api/session", () => ({ useSession: () => useSessionMock() })); + import { + AgentRuns, DrawerSurface, mapAgentRunBundle, mapAgentRunKind, @@ -424,3 +447,44 @@ describe("SavedViews save/apply/remove flow (#8701)", () => { expect(window.localStorage.getItem("loopover.runs.views")).toBeNull(); }); }); + +// #9672: sourceLabel's final `else` branch used to read "No session" whenever `canUseLiveRuns` was +// false OR the live API call errored — so a signed-in operator whose /v1/agent/runs request failed saw +// a pill claiming they weren't signed in at all. +describe("AgentRuns source pill: no session vs live API error (#9672)", () => { + beforeEach(() => { + useSearchMock.mockReturnValue({}); + }); + + it("labels a signed-in operator's live API failure distinctly from having no session", () => { + useSessionMock.mockReturnValue({ session: { login: "octocat", roles: ["miner"] } }); + useApiResourceMock.mockReturnValue({ + status: "error", + data: null, + error: "500 Internal Server Error", + errorKind: "http", + loadedAt: null, + reload: () => {}, + }); + + render(); + + expect(screen.queryByText("No session")).toBeNull(); + expect(screen.getByText("Live API error")).toBeTruthy(); + }); + + it('still reads "No session" when nobody is signed in', () => { + useSessionMock.mockReturnValue({ session: null }); + useApiResourceMock.mockReturnValue({ + status: "error", + data: null, + error: "disabled", + loadedAt: null, + reload: () => {}, + }); + + render(); + + expect(screen.getByText("No session")).toBeTruthy(); + }); +}); diff --git a/apps/loopover-ui/src/routes/app.runs.tsx b/apps/loopover-ui/src/routes/app.runs.tsx index dbc48e0ba4..53012155c5 100644 --- a/apps/loopover-ui/src/routes/app.runs.tsx +++ b/apps/loopover-ui/src/routes/app.runs.tsx @@ -23,7 +23,7 @@ import { type Boundary, type Status, } from "@/components/site/control-primitives"; -import { useApiResource } from "@/lib/api/use-api-resource"; +import { API_RESOURCE_DISABLED, useApiResource } from "@/lib/api/use-api-resource"; import { useSession } from "@/lib/api/session"; import { EmptyState, StateActionButton, StateBoundary } from "@/components/site/state-views"; import { RefreshMeta } from "@/components/site/refresh-meta"; @@ -183,7 +183,7 @@ export function RunsFilterBar({ ); } -function AgentRuns() { +export function AgentRuns() { const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); const { session } = useSession(); @@ -278,18 +278,23 @@ function AgentRuns() { }, [runs, status, kind, q]); const grouped = useMemo(() => groupByDate(filtered), [filtered]); - const sourceStatus: Status = - canUseLiveRuns && liveRuns.status === "ready" + // !canUseLiveRuns ("no session") and canUseLiveRuns && status === "error" ("live API errored") are + // distinct failure modes with distinct causes (#9672) -- collapsing them into one "No session" label + // told a signed-in operator whose /v1/agent/runs request 500s that they weren't signed in at all. + const sourceStatus: Status = !canUseLiveRuns + ? "warn" + : liveRuns.status === "ready" ? "ready" - : canUseLiveRuns && liveRuns.status === "loading" + : liveRuns.status === "loading" ? "info" - : "warn"; - const sourceLabel = - canUseLiveRuns && liveRuns.status === "ready" + : "blocked"; + const sourceLabel = !canUseLiveRuns + ? "No session" + : liveRuns.status === "ready" ? "Live API" - : canUseLiveRuns && liveRuns.status === "loading" + : liveRuns.status === "loading" ? "Loading live API" - : "No session"; + : "Live API error"; // Keyboard navigation: ←/→ to cycle through filtered runs while drawer is open. useEffect(() => { @@ -335,7 +340,9 @@ function AgentRuns() {