Compare commits

...
Author SHA1 Message Date
posthog-eu[bot] ca52e0b352 Surface recent-files load failures with a retry instead of a silent empty state
The file-add modal's "Recent" list treated a failed or stalled load
identically to an empty history: loadRecentFiles swallowed errors into an
empty array (console.error/warn only) and FileListArea rendered just
"Loading files..." or "No recent files", with no error message, no retry,
and no telemetry.

- Track a loadError flag in useFileManager and thread it through
  FileManagerContext to FileListArea, which now renders four distinct
  states: loading / loaded / error (with a "Try again" retry) / empty.
- Capture the previously-swallowed errors as PostHog $exception events via
  a new analytics.captureException helper (top-level, server-files, and
  share-links failures) so the failure stops being invisible.
- Add a 30s timeout to the storage metadata GETs so a stalled connection
  surfaces as an error/retry instead of an indefinite spinner.
- Add FileListArea state-machine tests.

Generated-By: PostHog Code
Task-Id: 63a8e289-0cd7-4a4c-9914-43504209029d
2026-07-15 12:02:05 +00:00
7 changed files with 218 additions and 10 deletions
@@ -3608,6 +3608,8 @@ lastSynced = "Last synced"
leaveShare = "Remove from my list"
leaveShareFailed = "Could not remove the shared file."
leaveShareSuccess = "Removed from your shared list."
loadFailed = "Couldn't load your recent files"
loadFailedRetry = "Try again"
loadingFiles = "Loading files..."
localOnly = "Local only"
makeCopy = "Make a copy"
@@ -42,7 +42,8 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
const { loadRecentFiles, handleRemoveFile, loading, loadError } =
useFileManager();
const { actions: fileActions } = useFileActions();
// Get active file IDs from FileContext to show which files are already loaded
@@ -240,6 +241,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
modalHeight={modalHeight}
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
loadError={loadError}
activeFileIds={activeFileIds}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
@@ -0,0 +1,131 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MantineProvider } from "@mantine/core";
// Mock i18n so assertions can rely on the English default strings.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
// Isolate FileListArea's branching logic from its heavy children — we only
// care which state it renders, not how each child looks.
vi.mock("@app/components/fileManager/EmptyFilesState", () => ({
default: () => <div>No recent files</div>,
}));
vi.mock("@app/components/fileManager/FileListItem", () => ({
default: ({ file }: { file: { name: string } }) => <div>{file.name}</div>,
}));
vi.mock("@app/components/fileManager/FileHistoryGroup", () => ({
default: () => null,
}));
vi.mock("@app/ui/Button", () => ({
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => <button onClick={onClick}>{children}</button>,
}));
const contextValue = vi.hoisted(() => ({
current: {} as Record<string, unknown>,
}));
vi.mock("@app/contexts/FileManagerContext", () => ({
useFileManagerContext: () => contextValue.current,
}));
import FileListArea from "@app/components/fileManager/FileListArea";
function baseContext(overrides: Record<string, unknown>) {
return {
activeSource: "recent",
recentFiles: [],
filteredFiles: [],
selectedFilesSet: new Set(),
expandedFileIds: new Set(),
loadedHistoryFiles: new Map(),
onFileSelect: vi.fn(),
onFileRemove: vi.fn(),
onHistoryFileRemove: vi.fn(),
onFileDoubleClick: vi.fn(),
onDownloadSingle: vi.fn(),
isLoading: false,
loadError: false,
activeFileIds: [],
refreshRecentFiles: vi.fn(),
...overrides,
};
}
describe("FileListArea recent-files states", () => {
beforeEach(() => {
contextValue.current = baseContext({});
});
it("shows the empty state when there are no files and no error", () => {
contextValue.current = baseContext({ isLoading: false, loadError: false });
render(
<MantineProvider>
<FileListArea scrollAreaHeight="200px" />
</MantineProvider>,
);
expect(screen.getByText("No recent files")).toBeInTheDocument();
expect(screen.queryByText("Try again")).not.toBeInTheDocument();
});
it("shows the loading state while loading with no files yet", () => {
contextValue.current = baseContext({ isLoading: true });
render(
<MantineProvider>
<FileListArea scrollAreaHeight="200px" />
</MantineProvider>,
);
expect(screen.getByText("Loading files...")).toBeInTheDocument();
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
});
it("shows an error state with a retry action when the load fails (not the empty state)", () => {
const refreshRecentFiles = vi.fn();
contextValue.current = baseContext({
isLoading: false,
loadError: true,
refreshRecentFiles,
});
render(
<MantineProvider>
<FileListArea scrollAreaHeight="200px" />
</MantineProvider>,
);
// The failed load must be visually distinct from a genuinely empty history.
expect(
screen.getByText("Couldn't load your recent files"),
).toBeInTheDocument();
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
const retry = screen.getByText("Try again");
fireEvent.click(retry);
expect(refreshRecentFiles).toHaveBeenCalledTimes(1);
});
it("renders the file list when files are present even if a partial load error occurred", () => {
contextValue.current = baseContext({
recentFiles: [{ id: "a" }],
filteredFiles: [{ id: "a", name: "doc.pdf" }],
loadError: true,
});
render(
<MantineProvider>
<FileListArea scrollAreaHeight="200px" />
</MantineProvider>,
);
expect(screen.getByText("doc.pdf")).toBeInTheDocument();
expect(
screen.queryByText("Couldn't load your recent files"),
).not.toBeInTheDocument();
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
});
});
@@ -1,10 +1,12 @@
import React from "react";
import { Center, ScrollArea, Text, Stack } from "@mantine/core";
import CloudIcon from "@mui/icons-material/Cloud";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineRounded";
import { useTranslation } from "react-i18next";
import FileListItem from "@app/components/fileManager/FileListItem";
import FileHistoryGroup from "@app/components/fileManager/FileHistoryGroup";
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
import { Button } from "@app/ui/Button";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
interface FileListAreaProps {
@@ -29,7 +31,9 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onFileDoubleClick,
onDownloadSingle,
isLoading,
loadError,
activeFileIds,
refreshRecentFiles,
} = useFileManagerContext();
const { t } = useTranslation();
@@ -44,15 +48,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
scrollbarSize={8}
>
<Stack gap={0}>
{recentFiles.length === 0 && !isLoading ? (
<EmptyFilesState />
) : recentFiles.length === 0 && isLoading ? (
<Center style={{ height: "12.5rem" }}>
<Text c="dimmed" ta="center">
{t("fileManager.loadingFiles", "Loading files...")}
</Text>
</Center>
) : (
{recentFiles.length > 0 ? (
filteredFiles.map((file, index) => {
// All files in filteredFiles are now leaf files only
const historyFiles = loadedHistoryFiles.get(file.id) || [];
@@ -84,6 +80,39 @@ const FileListArea: React.FC<FileListAreaProps> = ({
</React.Fragment>
);
})
) : isLoading ? (
<Center style={{ height: "12.5rem" }}>
<Text c="dimmed" ta="center">
{t("fileManager.loadingFiles", "Loading files...")}
</Text>
</Center>
) : loadError ? (
<Center style={{ height: "12.5rem" }}>
<Stack align="center" gap="sm">
<ErrorOutlineIcon
style={{
fontSize: "3rem",
color: "var(--mantine-color-gray-5)",
}}
/>
<Text c="dimmed" ta="center">
{t(
"fileManager.loadFailed",
"Couldn't load your recent files",
)}
</Text>
<Button
variant="secondary"
onClick={() => {
void refreshRecentFiles();
}}
>
{t("fileManager.loadFailedRetry", "Try again")}
</Button>
</Stack>
</Center>
) : (
<EmptyFilesState />
)}
</Stack>
</ScrollArea>
@@ -42,6 +42,7 @@ interface FileManagerContextValue {
fileGroups: Map<FileId, StirlingFileStub[]>;
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
isLoading: boolean;
loadError: boolean;
activeFileIds: FileId[];
// Handlers
@@ -96,6 +97,7 @@ interface FileManagerProviderProps {
modalHeight: string;
refreshRecentFiles: () => Promise<void>;
isLoading: boolean;
loadError: boolean;
activeFileIds: FileId[];
maxSelectable?: number | null;
}
@@ -115,6 +117,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
modalHeight,
refreshRecentFiles,
isLoading,
loadError,
activeFileIds,
maxSelectable = null,
}) => {
@@ -1089,6 +1092,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileGroups,
loadedHistoryFiles,
isLoading,
loadError,
activeFileIds,
// Handlers
@@ -1131,6 +1135,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileGroups,
loadedHistoryFiles,
isLoading,
loadError,
activeFileIds,
handleSourceChange,
handleStorageFilterChange,
@@ -5,6 +5,7 @@ import { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
import { FileId } from "@app/types/fileContext";
import apiClient from "@app/services/apiClient";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { captureException } from "@app/services/analytics";
interface StoredFileResponse {
id: number;
@@ -33,6 +34,7 @@ interface AccessedShareLinkResponse {
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const indexedDB = useIndexedDB();
const { config } = useAppConfig();
@@ -99,6 +101,11 @@ export const useFileManager = () => {
const loadRecentFiles = useCallback(async (): Promise<StirlingFileStub[]> => {
setLoading(true);
setLoadError(false);
// Track whether any part of the load failed so callers can distinguish a
// failed/partial load from a genuinely empty history instead of silently
// falling through to an empty list.
let encounteredError = false;
try {
if (!indexedDB) {
return [];
@@ -122,6 +129,10 @@ export const useFileManager = () => {
{
suppressErrorToast: true,
skipAuthRedirect: true,
// Metadata listing, not a file transfer — bound the wait so a
// stalled connection surfaces as an error/retry instead of an
// indefinite "Loading files..." spinner.
timeout: 30000,
} as any,
);
const serverFiles = Array.isArray(response.data) ? response.data : [];
@@ -242,6 +253,8 @@ export const useFileManager = () => {
combinedStubs = [...updatedLocalStubs, ...serverStubs];
} catch (error) {
encounteredError = true;
captureException(error, { context: "loadRecentFiles.serverFiles" });
console.warn("Failed to load server files:", error);
}
@@ -252,6 +265,8 @@ export const useFileManager = () => {
>("/api/v1/storage/share-links/accessed", {
suppressErrorToast: true,
skipAuthRedirect: true,
// Bound the wait — see server-files fetch above.
timeout: 30000,
} as any);
const sharedLinks = Array.isArray(sharedResponse.data)
? sharedResponse.data
@@ -346,6 +361,8 @@ export const useFileManager = () => {
combinedStubs = [...combinedStubs, ...sharedStubs];
} catch (error) {
encounteredError = true;
captureException(error, { context: "loadRecentFiles.shareLinks" });
console.warn("Failed to load shared links:", error);
}
}
@@ -358,9 +375,12 @@ export const useFileManager = () => {
return sortedFiles;
} catch (error) {
encounteredError = true;
captureException(error, { context: "loadRecentFiles" });
console.error("Failed to load recent files:", error);
return [];
} finally {
setLoadError(encounteredError);
setLoading(false);
}
}, [
@@ -505,6 +525,7 @@ export const useFileManager = () => {
return {
loading,
loadError,
convertToFile,
loadRecentFiles,
handleRemoveFile,
@@ -38,3 +38,21 @@ export function trackEditorOperation(toolId: string, fileCount: number): void {
if (DEV) console.warn("[analytics] trackEditorOperation failed", error);
}
}
/**
* Report a caught error to PostHog so otherwise-swallowed failures become
* visible as `$exception` events instead of vanishing into a console.warn.
*/
export function captureException(
error: unknown,
context?: Record<string, unknown>,
): void {
try {
if (!canCapture()) return;
const normalized =
error instanceof Error ? error : new Error(String(error));
posthog.captureException(normalized, context);
} catch (captureError) {
if (DEV) console.warn("[analytics] captureException failed", captureError);
}
}