Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d6168a5
fix(frontend): stop WebKit storage and engine failures from hanging t…
EthanHealy01 Aug 8, 2026
69f642f
Merge branch 'main' into fix/webkit-engine-capabilities
EthanHealy01 Aug 8, 2026
bdccc90
Merge origin/main into fix/webkit-engine-capabilities
EthanHealy01 Aug 10, 2026
d0793ea
Merge remote-tracking branch 'origin/fix/webkit-engine-capabilities' …
EthanHealy01 Aug 10, 2026
f3d4ead
Drop the e2e:capabilities task, superseded by e2e:cross-browser
EthanHealy01 Aug 10, 2026
b8ee6e5
Resolve @app/* in worker bundles instead of exempting one import
EthanHealy01 Aug 10, 2026
592dc1a
Split the engine-agnostic storage work out of this branch
EthanHealy01 Aug 10, 2026
b4b9a14
Resolve @app/* in Storybook's worker bundle too
EthanHealy01 Aug 10, 2026
b211878
Stop WebKit's blob-value failures from blocking file access
EthanHealy01 Aug 12, 2026
9fb7e53
Reject instead of hanging when pdfium's WASM won't instantiate
EthanHealy01 Aug 12, 2026
822bbaf
Show a clicked file as soon as its bytes load, not after it parses
EthanHealy01 Aug 12, 2026
93c646f
Keep state identity when REMOVE_FILES removes nothing
EthanHealy01 Aug 12, 2026
5ce9259
Delete a file's superseded versions along with it
EthanHealy01 Aug 12, 2026
3582e82
Subscribe to workbench files in the form panel
EthanHealy01 Aug 12, 2026
e5f8183
Let a stuck policy overlay be dismissed on a file card
EthanHealy01 Aug 12, 2026
929e8f6
Settle a policy run that completed with no output
EthanHealy01 Aug 12, 2026
f51ea06
Drop a file from the workbench once its bytes are proven unreadable
EthanHealy01 Aug 12, 2026
163041a
Add onDismiss to the policy overlay's core stub
EthanHealy01 Aug 12, 2026
4164bc1
Stop a blocked IndexedDB open from hanging the file library
EthanHealy01 Aug 12, 2026
61b67ec
Show "Data lost" on library rows whose bytes are gone, and rescue the…
EthanHealy01 Aug 12, 2026
62967c9
Keep maintenance writes away from blob records that can wedge the store
EthanHealy01 Aug 12, 2026
62fe11a
Trim redundancies from this branch's storage work
EthanHealy01 Aug 12, 2026
1a636fb
merge main
EthanHealy01 Aug 13, 2026
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
32 changes: 20 additions & 12 deletions frontend/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ import tsconfigPaths from "vite-tsconfig-paths";
* the portal layer at editor/src/portal/). MDX docs pages live in
* editor/src/portal/docs/.
*/
/**
* Editor stories import via `@app/*` (proprietary→core fallback), `@core/*` and
* `@proprietary/*`. Resolve them exactly the way the editor's own build does -
* through vite-tsconfig-paths against the proprietary vite tsconfig - so the
* shared Storybook can host editor components without duplicating the alias map
* here. Built per pass: the main bundle and the worker bundle each need their own.
*/
const editorPathAliases = () =>
tsconfigPaths({
projects: [resolve(__dirname, "../editor/tsconfig.proprietary.vite.json")],
});

const config: StorybookConfig = {
stories: [
"../editor/src/portal/**/*.mdx",
Expand Down Expand Up @@ -47,19 +59,15 @@ const config: StorybookConfig = {
// than a relative path.
"@public": resolve(__dirname, "../editor/public"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
// through vite-tsconfig-paths against the proprietary vite tsconfig — so the
// shared Storybook can host editor components without duplicating the alias
// map here.
config.plugins = config.plugins ?? [];
config.plugins.push(
tsconfigPaths({
projects: [
resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
],
}),
);
config.plugins.push(editorPathAliases());
// Worker bundles are a separate Rollup pass and do NOT inherit `plugins`, so
// without this a worker importing @app/* fails to resolve while the same
// import works everywhere else. Mirrors editor/vite.config.ts.
config.worker = {
...(config.worker ?? {}),
plugins: () => [editorPathAliases()],
};
// Point apiClient.saas at a mock origin so the SaaS-backed billing stories
// (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and
// their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host
Expand Down
15 changes: 10 additions & 5 deletions frontend/editor/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ import { defineConfig, devices } from "@playwright/test";
*
* @see https://playwright.dev/docs/test-configuration
*/
/** Shared by every stubbed project so a spec sees one layout on all engines. */
const STUBBED_VIEWPORT = { width: 1920, height: 1080 };

const chromiumViewport = {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 },
viewport: STUBBED_VIEWPORT,
};

export default defineConfig({
Expand Down Expand Up @@ -55,7 +58,8 @@ export default defineConfig({
},

projects: [
// Stubbed - no backend required, chromium-only for CI speed
// Stubbed - no backend required. The chromium arm of the cross-browser
// set below; CI fans all three out, one job per engine.
{
name: "stubbed",
testDir: "./src/core/tests/stubbed",
Expand Down Expand Up @@ -93,16 +97,17 @@ export default defineConfig({
},
},

// Cross-browser coverage for the stubbed suite (opt-in locally)
// Cross-browser coverage for the stubbed suite. Same viewport as `stubbed`,
// or a layout difference here reads as an engine outage.
{
name: "stubbed-firefox",
testDir: "./src/core/tests/stubbed",
use: { ...devices["Desktop Firefox"] },
use: { ...devices["Desktop Firefox"], viewport: STUBBED_VIEWPORT },
},
{
name: "stubbed-webkit",
testDir: "./src/core/tests/stubbed",
use: { ...devices["Desktop Safari"] },
use: { ...devices["Desktop Safari"], viewport: STUBBED_VIEWPORT },
},
],

Expand Down
4 changes: 4 additions & 0 deletions frontend/editor/public/locales/en-US/translation.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3890,6 +3890,8 @@ addFiles = "Add files"
addingFiles = "Adding files…"
collapse = "Collapse sidebar"
customizeGroups = "Customize groups"
dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it."
dataLostTitle = "File data is unavailable"
dropHint = "Open files to get started"
dropToAdd = "Drop files to add"
expand = "Expand sidebar"
Expand All @@ -3908,6 +3910,8 @@ viewAll = "View all {{count}} files"

[fileSidebar.fileItem]
closeViewer = "Close viewer"
dataLost = "Data lost"
dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it."
delete = "Delete"
moreActions = "More actions"
openInViewer = "Open in viewer"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ const FileEditorThumbnail = ({
const [showVersionHistory, setShowVersionHistory] = useState(false);

const policyEnforcing = policies.some((p) => p.enforcing);
// The overlay swallows clicks, so a run that never settles would leave the card
// unusable with no way out. Dismissible, like the viewer's; resets per run.
const [enforcingDismissed, setEnforcingDismissed] = useState(false);
if (!policyEnforcing && enforcingDismissed) setEnforcingDismissed(false);
// The policy currently enforcing, so the overlay's icon/spinner match that
// policy's badge instead of a fixed blue.
const enforcingPolicy = policies.find((p) => p.enforcing);
Expand Down Expand Up @@ -548,8 +552,9 @@ const FileEditorThumbnail = ({

{/* Policy enforcement overlay — shown while any policy is in-flight */}
<PolicyEnforcingOverlay
enforcing={policyEnforcing}
enforcing={policyEnforcing && !enforcingDismissed}
zIndex={2}
onDismiss={() => setEnforcingDismissed(true)}
accentVar={enforcingPolicy?.accentColor}
categoryId={enforcingPolicy?.id}
/>
Expand Down
18 changes: 16 additions & 2 deletions frontend/editor/src/core/components/layout/Workbench.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, Suspense, lazy } from "react";
import { useTranslation } from "react-i18next";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { Box, Loader, Center } from "@mantine/core";
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
Expand Down Expand Up @@ -44,7 +44,7 @@ export default function Workbench() {
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });

// Use context-based hooks to eliminate all prop drilling
const { files: activeFiles } = useAllFiles();
const { files: activeFiles, fileIds } = useAllFiles();
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
Expand Down Expand Up @@ -134,6 +134,20 @@ export default function Workbench() {
}

if (activeFiles.length === 0) {
// Files are open but their bytes are still loading (a cold PDF engine can
// take seconds). Showing the drop zone here reads as "the click did nothing".
if (fileIds.length > 0) {
return (
<Center h="100%" w="100%">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed" size="sm">
{t("fileManager.loadingFiles", "Loading files...")}
</Text>
</Stack>
</Center>
);
}
return <LandingPage />;
}

Expand Down
107 changes: 79 additions & 28 deletions frontend/editor/src/core/components/shared/FileSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ import {
deleteServerFile,
type DeleteScope,
} from "@app/services/serverStorageDelete";
import { fileStorage } from "@app/services/fileStorage";
import { fileStorage, onRecordUnreadable } from "@app/services/fileStorage";
import { alert } from "@app/components/toast";
import { useBulkAddProgress } from "@app/services/bulkAddProgress";
import { useFolderMembership } from "@app/hooks/useFolderMembership";
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
Expand Down Expand Up @@ -280,6 +281,19 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(

// Leaf files = user-visible files (excludes intermediate tool outputs)
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
// Files whose stored bytes this session PROVED unreadable. Rows render a
// "data lost" state instead of pretending the file can open; storage keeps
// the record so a reload re-tests it.
const [lostFileIds, setLostFileIds] = useState<ReadonlySet<string>>(
() => new Set(),
);
useEffect(
() =>
onRecordUnreadable((fileId) =>
setLostFileIds((prev) => new Set(prev).add(fileId as string)),
),
[],
);
const [stubsLoaded, setStubsLoaded] = useState(false);
// Kebab "Save to cloud" target; drives BulkUploadToServerModal.
const [saveToServerTarget, setSaveToServerTarget] = useState<
Expand All @@ -298,32 +312,45 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const storageEnabled = config?.storageEnabled === true && !isAnonymous;

const refreshStubs = useCallback(async () => {
// Leaf files from IDB - same source as the file selection modal.
const stubs = await indexedDB.loadLeafMetadata();
const idbIds = new Set(stubs.map((s) => s.id as string));

// Also include workbench files not yet flushed to IDB.
const pendingStubs = state.files.ids
.map((id) => state.files.byId[id])
.filter(
(stub): stub is NonNullable<typeof stub> =>
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
);
// `stubsLoaded` gates the spinner, so the `finally` below must set it on
// every path - callers never await this, so a rejection goes nowhere.
let stubs: StirlingFileStub[] = [];
try {
// Leaf files from IDB - same source as the file selection modal.
stubs = await indexedDB.loadLeafMetadata();
} catch (error) {
// Carry on with the in-memory workbench files: an unreadable library
// should cost the user their history, not the file they're working on.
console.error("Failed to read the file library from storage:", error);
}

const allStubs = [...stubs, ...pendingStubs];
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
const superseded = new Set(
allStubs.map((s) => s.parentFileId as string | undefined),
);
const currentStubs = allStubs.filter(
(s) => !superseded.has(s.id as string),
);
setAllFileStubs(
currentStubs.sort(
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
),
);
setStubsLoaded(true);
try {
const idbIds = new Set(stubs.map((s) => s.id as string));

// Also include workbench files not yet flushed to IDB.
const pendingStubs = state.files.ids
.map((id) => state.files.byId[id])
.filter(
(stub): stub is NonNullable<typeof stub> =>
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
);

const allStubs = [...stubs, ...pendingStubs];
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
const superseded = new Set(
allStubs.map((s) => s.parentFileId as string | undefined),
);
const currentStubs = allStubs.filter(
(s) => !superseded.has(s.id as string),
);
setAllFileStubs(
currentStubs.sort(
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
),
);
} finally {
setStubsLoaded(true);
}
}, [indexedDB, state.files.ids, state.files.byId]);

// Refresh on mount, workbench changes, or external IndexedDB writes —
Expand Down Expand Up @@ -362,7 +389,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
setDeleteTarget(stub);
return;
}
await fileActions.removeFiles([fileId], true);
// Its superseded versions go too - see orphanedAncestorIds.
const orphans = await fileStorage.orphanedAncestorIds([fileId]);
await fileActions.removeFiles([fileId, ...orphans], true);
await refreshStubs();
},
[allFileStubs, fileActions, refreshStubs],
Expand All @@ -380,7 +409,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
await deleteServerFile(stub.remoteStorageId);
}
if (scope === "device" || scope === "everywhere") {
await fileActions.removeFiles([stub.id], true);
const orphans = await fileStorage.orphanedAncestorIds([stub.id]);
await fileActions.removeFiles([stub.id, ...orphans], true);
} else if (scope === "cloud") {
// Local copy kept - drop the dead remote pointer so the cloud badge
// clears (the sidebar doesn't reconcile with the server itself).
Expand Down Expand Up @@ -484,6 +514,22 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const stub = allFileStubs.find((s) => s.id === fileId);
if (!stub) return;

// Its bytes are gone; opening it can only fail. Say so instead of a
// click that goes nowhere.
if (stub.dataUnavailable || lostFileIds.has(fileId as string)) {
alert({
alertType: "warning",
title: t("fileSidebar.dataLostTitle", "File data is unavailable"),
body: t(
"fileSidebar.dataLostBody",
"This browser lost this file's contents. Upload it again to keep working with it.",
),
expandable: false,
durationMs: 6000,
});
return;
}

// In the Watched Folders view a click sends the file into the open folder
// (mirrors how a click toggles a file into the active workbench elsewhere).
// On the folder list (no folder open) it's a no-op so browsing isn't disrupted.
Expand Down Expand Up @@ -538,6 +584,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
},
[
allFileStubs,
lostFileIds,
t,
state.files.ids,
state.ui.selectedFileIds,
fileActions,
Expand Down Expand Up @@ -725,6 +773,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
? state.files.byId[workbenchFileId]?.thumbnailUrl
: undefined) || stub.thumbnailUrl;
const fileOrigin = getFileOrigin(stub);
const dataUnavailable =
stub.dataUnavailable === true || lostFileIds.has(stub.id as string);
// Key by lineage (originalFileId) so a version swap updates the row in place instead of
// remounting. But a 1-input→many-output op (split) yields sibling leaves that share one
// originalFileId; those would collide on the key, so fall back to the unique leaf id when a
Expand All @@ -747,6 +797,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
thumbnailUrl={thumbnailUrl}
onClick={handleFileClick}
onEyeClick={handleEyeClick}
dataUnavailable={dataUnavailable}
draggable={isWatchedFoldersActive}
onDragStart={handleWatchedFolderDragStart}
folders={memberFolders}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,13 @@
transform: translateY(-50%) scale(1);
}
}

/* The stored bytes are gone - the row says so instead of pretending to open. */
.file-sidebar-datalost-badge {
display: inline-flex;
align-items: center;
gap: 0.15rem;
color: var(--c-danger);
font-size: 0.7rem;
white-space: nowrap;
}
Loading
Loading