Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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(<MinerPanel />);

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(<MinerPanel />);

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(<MinerPanel />);

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();
});
});
24 changes: 19 additions & 5 deletions apps/loopover-ui/src/components/site/app-panels/miner-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -115,7 +115,7 @@ function MinerDashboardSkeleton() {
}

export function MinerPanel() {
const { session } = useSession();
const { session, hydrated } = useSession();
const login = session?.login ?? "";
const dashboard = useApiResource<MinerDashboard>(
`/v1/app/miner-dashboard?login=${encodeURIComponent(login)}`,
Expand All @@ -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 &&
Expand Down Expand Up @@ -189,7 +198,7 @@ export function MinerPanel() {
/>
<MinerCommandActions commands={commandActions} />
<StateBoundary
isLoading={dashboard.status === "loading"}
isLoading={!hydrated || dashboard.status === "loading"}
isEmpty={isEmpty}
onRetry={dashboard.reload}
onRefresh={dashboard.reload}
Expand All @@ -198,7 +207,12 @@ export function MinerPanel() {
emptyTitle="No miner actions yet"
emptyDescription="Once a decision pack or branch analysis exists, ranked next actions and blockers will appear here."
>
{dashboard.status === "error" ? (
{isSignedOut ? (
<EmptyState
title="Sign in to see your miner dashboard"
description="Ranked next actions, blockers, and repo fit appear here once you sign in with the GitHub account you mine under."
/>
) : isDashboardError ? (
<div className="rounded-token border border-warning/30 bg-warning/[0.04] p-4 text-token-sm text-warning">
Miner dashboard is unavailable right now ({dashboard.error}).
</div>
Expand Down
11 changes: 8 additions & 3 deletions apps/loopover-ui/src/lib/api/use-api-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never> = Object.freeze({
status: "error",
data: null,
error: "disabled",
error: API_RESOURCE_DISABLED,
loadedAt: null,
});

Expand Down
64 changes: 64 additions & 0 deletions apps/loopover-ui/src/routes/app.runs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>),
useApiResourceMock: vi.fn(),
useSessionMock: vi.fn(),
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: Record<string, unknown>) => ({
...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,
Expand Down Expand Up @@ -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(<AgentRuns />);

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(<AgentRuns />);

expect(screen.getByText("No session")).toBeTruthy();
});
});
29 changes: 18 additions & 11 deletions apps/loopover-ui/src/routes/app.runs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -335,7 +340,9 @@ function AgentRuns() {

<StateBoundary
isLoading={canUseLiveRuns && liveRuns.status === "loading"}
isError={canUseLiveRuns && liveRuns.status === "error" && liveRuns.error !== "disabled"}
isError={
canUseLiveRuns && liveRuns.status === "error" && liveRuns.error !== API_RESOURCE_DISABLED
}
errorKind={liveRuns.status === "error" ? liveRuns.errorKind : undefined}
errorLabel="Agent runs"
errorDescription={liveRuns.status === "error" ? liveRuns.error : undefined}
Expand Down
Loading