diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 2290cfecf..12e179838 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -37,6 +37,7 @@ import { LeftSidebar } from "@/components/left-sidebar";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
+import { RootErrorBoundary } from "@/components/root-error-boundary";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
@@ -998,31 +999,27 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
);
}
-// PortalProvider must stay inside normal app-wide context providers here.
+// PortalProvider must stay inside normal app-wide context providers.
// `@gorhom/portal` renders portaled children at the host's location in the
// tree, so any context a portaled sheet might consume (QueryClient, theme,
-// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
+// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootProviders({ children }: { children: ReactNode }) {
return (
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
+
+
+
+
+ {children}
+
+
+
+
);
}
-export default function RootLayout() {
+function RootAppTree() {
return (
@@ -1036,6 +1033,18 @@ export default function RootLayout() {
);
}
+export default function RootLayout() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
const layoutStyles = StyleSheet.create((theme) => ({
surfaceFill: {
flex: 1,
diff --git a/packages/app/src/components/root-error-boundary.tsx b/packages/app/src/components/root-error-boundary.tsx
new file mode 100644
index 000000000..97c34386b
--- /dev/null
+++ b/packages/app/src/components/root-error-boundary.tsx
@@ -0,0 +1,157 @@
+import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
+import { Pressable, ScrollView, Text, View } from "react-native";
+import type { PressableStateCallbackType, StyleProp, ViewStyle } from "react-native";
+import { useTranslation } from "react-i18next";
+import { StyleSheet } from "react-native-unistyles";
+import { formatCaughtValue } from "./root-error-details";
+
+interface RootErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface RootErrorBoundaryState {
+ error: string | null;
+ resetKey: number;
+}
+
+export class RootErrorBoundary extends Component {
+ state: RootErrorBoundaryState = {
+ error: null,
+ resetKey: 0,
+ };
+
+ static getDerivedStateFromError(error: unknown): Partial {
+ return { error: formatCaughtValue(error) };
+ }
+
+ componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
+ console.error("[RootErrorBoundary] Unhandled render error", {
+ error: formatCaughtValue(error),
+ componentStack: errorInfo.componentStack,
+ });
+ }
+
+ retry = () => {
+ this.setState(({ resetKey }) => ({
+ error: null,
+ resetKey: resetKey + 1,
+ }));
+ };
+
+ render() {
+ const { error, resetKey } = this.state;
+ if (error !== null) {
+ return ;
+ }
+
+ return {this.props.children};
+ }
+}
+
+interface RootErrorFallbackProps {
+ error: string;
+ onRetry: () => void;
+}
+
+function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t("rootError.kicker")}
+ {t("rootError.title")}
+ {t("rootError.body")}
+
+ {t("rootError.details")}
+ {error}
+
+
+ {t("common.actions.retry")}
+
+
+
+ );
+}
+
+function retryButtonStyle({ pressed }: PressableStateCallbackType): StyleProp {
+ return [styles.retryButton, pressed ? styles.retryButtonPressed : null];
+}
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ flex: 1,
+ backgroundColor: theme.colors.surface0,
+ },
+ contentContainer: {
+ flexGrow: 1,
+ justifyContent: "center",
+ paddingHorizontal: theme.spacing[6],
+ paddingVertical: theme.spacing[8],
+ },
+ content: {
+ alignSelf: "center",
+ width: "100%",
+ maxWidth: 520,
+ gap: theme.spacing[4],
+ },
+ kicker: {
+ color: theme.colors.destructive,
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ title: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.xl,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ body: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.base,
+ lineHeight: 22,
+ },
+ messageBox: {
+ gap: theme.spacing[2],
+ borderWidth: 1,
+ borderColor: theme.colors.borderAccent,
+ borderRadius: theme.borderRadius.lg,
+ backgroundColor: theme.colors.surface1,
+ padding: theme.spacing[4],
+ },
+ messageLabel: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.xs,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ message: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ lineHeight: 20,
+ },
+ retryButton: {
+ alignSelf: "flex-start",
+ minHeight: 40,
+ justifyContent: "center",
+ borderRadius: theme.borderRadius.md,
+ backgroundColor: theme.colors.accent,
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[2],
+ },
+ retryButtonPressed: {
+ opacity: 0.85,
+ },
+ retryButtonText: {
+ color: theme.colors.accentForeground,
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.semibold,
+ },
+}));
diff --git a/packages/app/src/components/root-error-details.test.ts b/packages/app/src/components/root-error-details.test.ts
new file mode 100644
index 000000000..0daf52136
--- /dev/null
+++ b/packages/app/src/components/root-error-details.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from "vitest";
+import { formatCaughtValue } from "./root-error-details";
+
+describe("formatCaughtValue", () => {
+ it("preserves details for Error values", () => {
+ class RouteRenderError extends Error {
+ code = "E_ROUTE_RENDER";
+ cause = "workspace route";
+
+ constructor() {
+ super("route render exploded");
+ this.name = "RouteRenderError";
+ this.stack = "RouteRenderError: route render exploded\n at WorkspaceRoute";
+ }
+ }
+
+ const details = formatCaughtValue(new RouteRenderError());
+
+ expect(details).toContain("Name: RouteRenderError");
+ expect(details).toContain("Message: route render exploded");
+ expect(details).toContain("Stack:");
+ expect(details).toContain("RouteRenderError: route render exploded");
+ expect(details).toContain("Cause:");
+ expect(details).toContain("workspace route");
+ expect(details).toContain("E_ROUTE_RENDER");
+ });
+
+ it("does not duplicate aggregate errors as custom fields", () => {
+ const error = new AggregateError([new Error("first failure")], "multiple failures");
+ const details = formatCaughtValue(error);
+
+ expect(details).toContain("Errors:");
+ expect(details).toContain("first failure");
+ expect(details).not.toContain("Fields:");
+ });
+
+ it("preserves null aggregate error values", () => {
+ class ErrorWithNullableErrors extends Error {
+ errors = null;
+ }
+
+ const details = formatCaughtValue(new ErrorWithNullableErrors("nullable errors"));
+
+ expect(details).toContain("Errors:\nnull");
+ expect(details).not.toContain("Fields:");
+ });
+
+ it("does not throw for malformed Error text fields", () => {
+ const error = new Error("fallback");
+ Object.defineProperties(error, {
+ name: { configurable: true, value: null },
+ message: { configurable: true, value: 42 },
+ stack: { configurable: true, value: { frame: "bad stack" } },
+ });
+
+ const details = formatCaughtValue(error);
+
+ expect(details).toContain("Name: null");
+ expect(details).toContain("Message: 42");
+ expect(details).toContain('"frame": "bad stack"');
+ });
+
+ it("marks recursive Error causes", () => {
+ const error = new Error("self cause");
+ Object.defineProperty(error, "cause", { configurable: true, value: error });
+
+ const details = formatCaughtValue(error);
+
+ expect(details).toContain("Cause:\n[Circular Error]");
+ });
+
+ it("returns fallback details when Error properties throw", () => {
+ const error = new Error("fallback");
+ Object.defineProperty(error, "message", {
+ configurable: true,
+ get() {
+ throw new Error("bad message getter");
+ },
+ });
+
+ const details = formatCaughtValue(error);
+
+ expect(details).toContain("[Unserializable value]");
+ expect(details).toContain("Details unavailable:");
+ expect(details).toContain("Error: bad message getter");
+ });
+
+ it("renders string thrown values as the string", () => {
+ expect(formatCaughtValue("plain failure")).toBe("plain failure");
+ });
+
+ it("preserves empty string thrown values", () => {
+ expect(formatCaughtValue("")).toBe("");
+ });
+
+ it("renders numeric thrown values without extra category text", () => {
+ const details = formatCaughtValue(42);
+
+ expect(details).toBe("42");
+ expect(details).not.toContain("non-Error");
+ });
+
+ it("renders circular objects as JSON with circular markers", () => {
+ const value: { label: string; self?: unknown } = { label: "loop" };
+ value.self = value;
+
+ expect(formatCaughtValue(value)).toBe('{\n "label": "loop",\n "self": "[Circular]"\n}');
+ });
+});
diff --git a/packages/app/src/components/root-error-details.ts b/packages/app/src/components/root-error-details.ts
new file mode 100644
index 000000000..451981747
--- /dev/null
+++ b/packages/app/src/components/root-error-details.ts
@@ -0,0 +1,155 @@
+export function formatCaughtValue(value: unknown): string {
+ try {
+ return formatCaughtValueWithSeenErrors(value, new WeakSet());
+ } catch (formattingError) {
+ return formatFormattingFailure(value, formattingError);
+ }
+}
+
+function formatCaughtValueWithSeenErrors(value: unknown, seenErrors: WeakSet): string {
+ if (value instanceof Error) {
+ return formatError(value, seenErrors);
+ }
+
+ if (typeof value === "string") {
+ return value;
+ }
+
+ if (value === null || value === undefined) {
+ return safeString(value);
+ }
+
+ if (typeof value !== "object" && typeof value !== "function") {
+ return safeString(value);
+ }
+
+ return stringifyJson(value, seenErrors) ?? safeString(value);
+}
+
+function formatError(error: Error, seenErrors: WeakSet): string {
+ if (seenErrors.has(error)) {
+ return "[Circular Error]";
+ }
+
+ seenErrors.add(error);
+ const sections: string[] = [];
+ const name = formatErrorTextProperty(Reflect.get(error, "name"), seenErrors);
+ const message = formatErrorTextProperty(Reflect.get(error, "message"), seenErrors);
+ const stack = formatErrorTextProperty(Reflect.get(error, "stack"), seenErrors);
+
+ if (name) {
+ sections.push(`Name: ${name}`);
+ }
+ if (message) {
+ sections.push(`Message: ${message}`);
+ }
+ if (stack) {
+ sections.push(`Stack:\n${stack}`);
+ }
+
+ const errorCause = getErrorCause(error);
+ if (errorCause.hasCause) {
+ sections.push(`Cause:\n${formatCaughtValueWithSeenErrors(errorCause.value, seenErrors)}`);
+ }
+
+ const aggregateErrors = getAggregateErrors(error);
+ if (aggregateErrors.hasErrors) {
+ sections.push(`Errors:\n${formatCaughtValueWithSeenErrors(aggregateErrors.value, seenErrors)}`);
+ }
+
+ const fields = getErrorFields(error);
+ if (fields !== null) {
+ sections.push(`Fields:\n${stringifyJson(fields, seenErrors) ?? safeString(fields)}`);
+ }
+
+ seenErrors.delete(error);
+ return sections.join("\n\n") || safeString(error);
+}
+
+function formatErrorTextProperty(value: unknown, seenErrors: WeakSet): string | null {
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ return trimmed || null;
+ }
+ if (value === undefined) {
+ return null;
+ }
+ return stringifyJson(value, seenErrors) ?? safeString(value);
+}
+
+function getErrorCause(error: Error): { hasCause: boolean; value: unknown } {
+ if (!Reflect.has(error, "cause")) {
+ return { hasCause: false, value: null };
+ }
+ return { hasCause: true, value: Reflect.get(error, "cause") };
+}
+
+function getAggregateErrors(error: Error): { hasErrors: boolean; value: unknown } {
+ if (!Reflect.has(error, "errors")) {
+ return { hasErrors: false, value: null };
+ }
+ return { hasErrors: true, value: Reflect.get(error, "errors") };
+}
+
+function getErrorFields(error: Error): Record | null {
+ const fields: Record = {};
+ for (const key of Object.keys(error)) {
+ if (
+ key === "name" ||
+ key === "message" ||
+ key === "stack" ||
+ key === "cause" ||
+ key === "errors"
+ ) {
+ continue;
+ }
+ fields[key] = Reflect.get(error, key);
+ }
+
+ return Object.keys(fields).length > 0 ? fields : null;
+}
+
+function stringifyJson(value: unknown, seenErrors: WeakSet): string | null {
+ const seen = new WeakSet