- {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() {