fix(web): simplify sidebar error handling
This commit is contained in:
parent
406b26e837
commit
cbc96475f9
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Reference in New Issue