fix(web): simplify sidebar error handling

This commit is contained in:
i-trytoohard 2026-05-18 00:07:25 +05:30
parent 406b26e837
commit cbc96475f9
7 changed files with 112 additions and 19 deletions

View File

@ -204,7 +204,7 @@ function SessionPageShell({
sidebarSessions: DashboardSession[] | null;
sidebarOrchestrators?: ProjectSidebarOrchestrator[];
sidebarLoading: boolean;
sidebarError: boolean;
sidebarError: string | null;
onRetrySidebar: () => void;
activeProjectId?: string;
activeSessionId?: string;
@ -410,7 +410,7 @@ export default function SessionPage() {
const [loading, setLoading] = useState(cachedSession === null);
const [routeError, setRouteError] = useState<Error | null>(null);
const [sessionMissing, setSessionMissing] = useState(false);
const [sidebarError, setSidebarError] = useState(false);
const [sidebarError, setSidebarError] = useState<string | null>(null);
const [prefixByProject, setPrefixByProject] = useState<Map<string, string>>(new Map());
const sessionProjectId = session?.projectId ?? null;
const allPrefixes = [...prefixByProject.values()];
@ -689,7 +689,7 @@ export default function SessionPage() {
applyMuxSessionPatches(restSessions, pendingMuxSessionsRef.current ?? []) ?? restSessions;
cachedSidebarSessions = nextSessions;
setSidebarOrchestrators(body?.orchestrators);
setSidebarError(false);
setSidebarError(null);
setSidebarSessions((current) =>
areSidebarSessionsEqual(current, nextSessions) ? current : nextSessions,
);
@ -698,7 +698,7 @@ export default function SessionPage() {
return;
}
console.error("Failed to fetch sidebar sessions:", err);
setSidebarError(true);
setSidebarError(err instanceof Error ? err.message : String(err));
setSidebarSessions((current) => (current === null ? [] : current));
} finally {
fetchingSidebarRef.current = false;

View File

@ -203,8 +203,9 @@ function DashboardInner({
: "reconnecting";
const recoveredFromLoadError = Boolean(dashboardLoadError) && liveSessionsResolved;
const ssrLoadError = recoveredFromLoadError ? undefined : dashboardLoadError;
// Live WS error takes precedence; fall back to SSR load error when live data hasn't resolved it.
const visibleLoadError = loadError ?? ssrLoadError;
// Live transport errors only block the main dashboard when no session data is visible.
const visibleLoadError = sessions.length === 0 ? (loadError ?? ssrLoadError) : ssrLoadError;
const sidebarError = loadError ?? (ssrLoadError && sessions.length === 0 ? ssrLoadError : null);
const searchParams = useSearchParams();
const router = useRouter();
const routerRef = useRef(router);
@ -858,6 +859,7 @@ function DashboardInner({
activeProjectId={projectId}
activeSessionId={activeSessionId}
loading={!liveSessionsResolved}
error={sidebarError}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
onMobileClose={() => setMobileSidebarOpen(false)}

View File

@ -33,7 +33,7 @@ interface ProjectSidebarProps {
activeProjectId: string | undefined;
activeSessionId: string | undefined;
loading?: boolean;
error?: boolean;
error?: string | null;
onRetry?: () => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
@ -42,6 +42,18 @@ interface ProjectSidebarProps {
type SessionDotLevel = "respond" | "review" | "action" | "pending" | "working" | "merge" | "done";
function isTransientSessionListError(error: string | null | undefined): boolean {
if (!error) return false;
const normalized = error.toLowerCase();
return (
normalized.includes("timed out") ||
normalized.includes("timeout") ||
normalized.includes("failed to fetch") ||
normalized.includes("networkerror") ||
normalized.includes("network error")
);
}
const SessionDot = memo(function SessionDot({ level }: { level: SessionDotLevel }) {
return (
<div
@ -277,7 +289,7 @@ function ProjectSidebarInner({
activeProjectId,
activeSessionId,
loading = false,
error = false,
error = null,
onRetry,
collapsed = false,
onToggleCollapsed: _onToggleCollapsed,
@ -285,6 +297,7 @@ function ProjectSidebarInner({
}: ProjectSidebarProps) {
const router = useRouter();
const _isLoading = loading || sessions === null;
const transientError = isTransientSessionListError(error);
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
() =>
@ -701,9 +714,14 @@ function ProjectSidebarInner({
{error && sessions && sessions.length > 0 ? (
<div
role="status"
className="mx-3 mb-2 flex items-center justify-between gap-2 rounded-md border border-[var(--color-border-strong)] bg-[var(--color-bg-primary)] px-2 py-1.5 text-[11px] text-[var(--color-text-tertiary)]"
className="mx-3 mb-2 flex items-start justify-between gap-2 rounded-md border border-[var(--color-border-strong)] bg-[var(--color-bg-primary)] px-2 py-1.5 text-[11px] text-[var(--color-text-tertiary)]"
>
<span>Failed to refresh · showing cached sessions</span>
<span className="min-w-0">
<span className="block">Failed to refresh · showing cached sessions</span>
<span className="block truncate" title={error}>
{error}
</span>
</span>
{onRetry ? (
<button
type="button"
@ -1031,14 +1049,26 @@ function ProjectSidebarInner({
})
) : error ? (
<div className="px-3 py-2">
<div className="project-sidebar__empty">Failed to load sessions</div>
<button
type="button"
className="mt-2 text-xs font-medium text-[var(--color-link)] hover:underline"
onClick={onRetry}
<div className="project-sidebar__empty">
{transientError
? "Sessions temporarily unavailable"
: "Failed to load sessions"}
</div>
<div
className="mt-1 break-words text-xs text-[var(--color-text-tertiary)]"
title={error}
>
Retry
</button>
{error}
</div>
{onRetry ? (
<button
type="button"
className="mt-2 text-xs font-medium text-[var(--color-link)] hover:underline"
onClick={onRetry}
>
Retry
</button>
) : null}
</div>
) : (
<div className="project-sidebar__empty">

View File

@ -63,10 +63,11 @@ export function PullRequestsPage({
}
return levels;
}, [initialSessions, attentionZones]);
const { sessions, attentionLevels } = useSessionEvents({
const { sessions, attentionLevels, loadError } = useSessionEvents({
initialSessions,
project: projectId,
muxSessions: mux?.status === "connected" ? mux.sessions : undefined,
muxLastError: mux?.lastError,
initialAttentionLevels,
attentionZones,
});
@ -118,6 +119,7 @@ export function PullRequestsPage({
orchestrators={orchestratorLinks}
activeProjectId={projectId}
activeSessionId={undefined}
error={loadError}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileMenuOpen(false)}

View File

@ -123,6 +123,65 @@ describe("ProjectSidebar", () => {
expect(screen.getByRole("button", { name: /new project/i })).toBeInTheDocument();
});
it("shows a first-load failure only for the empty session state", () => {
render(
<ProjectSidebar
projects={projects}
sessions={[]}
activeProjectId="project-1"
activeSessionId={undefined}
error="HTTP 500"
onRetry={vi.fn()}
/>,
);
expect(screen.getByText("Failed to load sessions")).toBeInTheDocument();
expect(screen.getByText("HTTP 500")).toBeInTheDocument();
expect(
screen.queryByText("Failed to refresh · showing cached sessions"),
).not.toBeInTheDocument();
});
it("shows transient empty-sidebar failures as temporary instead of hard first-load failures", () => {
render(
<ProjectSidebar
projects={projects}
sessions={[]}
activeProjectId="project-1"
activeSessionId={undefined}
error="Sidebar sessions request timed out after 6000ms"
onRetry={vi.fn()}
/>,
);
expect(screen.getByText("Sessions temporarily unavailable")).toBeInTheDocument();
expect(screen.queryByText("Failed to load sessions")).not.toBeInTheDocument();
expect(screen.getByText("Sidebar sessions request timed out after 6000ms")).toBeInTheDocument();
});
it("shows a cached-data refresh banner without replacing visible sessions", () => {
render(
<ProjectSidebar
projects={projects}
sessions={[
makeSession({
id: "project-1-session",
projectId: "project-1",
branch: "feat/cached",
}),
]}
activeProjectId="project-1"
activeSessionId={undefined}
error="GitHub API rate limited"
/>,
);
expect(screen.getByText("Failed to refresh · showing cached sessions")).toBeInTheDocument();
expect(screen.getByText("GitHub API rate limited")).toBeInTheDocument();
expect(screen.queryByText("Failed to load sessions")).not.toBeInTheDocument();
expect(screen.getByText("feat/cached")).toBeInTheDocument();
});
it("marks the active project row as the current page", () => {
render(
<ProjectSidebar

View File

@ -19,6 +19,7 @@ describe("useSessionEvents - mux", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllTimers();
vi.useRealTimers();
});
it("triggers refresh when mux patch contains unknown id", async () => {
@ -107,6 +108,5 @@ describe("useSessionEvents - mux", () => {
"[useSessionEvents] refresh failed:",
expect.anything(),
);
vi.useRealTimers();
});
});