import {
createPathConfigForStaticNavigation,
getPathFromState,
NavigationState,
StackActions,
useNavigation,
} from "@react-navigation/native";
import {
createNativeStackNavigator,
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
import { useEffect, useRef } from "react";
import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
import { useResolveClassNames } from "uniwind";
import { AppText as Text } from "./components/AppText";
import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
import { ReviewSheet } from "./features/review/ReviewSheet";
import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
import { HomeRouteScreen } from "./features/home/HomeRouteScreen";
import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute";
import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute";
import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScreen";
import {
NewTaskBranchPickerRouteScreen,
NewTaskEnvironmentPickerRouteScreen,
} from "./features/threads/NewTaskContextPickerScreens";
import {
ExistingThreadSettingsRouteProvider,
ExistingThreadSettingsRouteScreen,
NewTaskThreadSettingsRouteScreen,
} from "./features/threads/ThreadSettingsSheet";
import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled";
import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
import {
SettingsLegalDocumentCloseHeaderButton,
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads";
const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
type AppScreenOptions = NativeStackNavigationOptions & {
readonly unstable_navigationItemStyle?: "editor";
};
// Shared header presets. Screens only override genuinely dynamic values (titles,
// subtitles, toolbar items, search callbacks) via NativeStackScreenOptions.
//
// GLASS: transparent header over the screen's primary scroll view on supported
// iOS versions. Pre-glass iOS gets the same solid material as internal-scroll
// surfaces so content is laid out below the bar instead of underlapping it.
const GLASS_HEADER_OPTIONS: AppScreenOptions = {
headerBackButtonDisplayMode: "minimal",
headerBackTitle: "",
headerLargeTitle: false,
headerShadowVisible: false,
headerShown: true,
headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined,
headerTitleStyle: { fontSize: 18, fontWeight: "800" },
headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED,
scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? HEADER_SCROLL_EDGE_EFFECTS : undefined,
unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined,
};
// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally
// (file viewer, terminal, review) — there is nothing for glass to sample there.
const SOLID_HEADER_OPTIONS: AppScreenOptions = {
headerBackButtonDisplayMode: "minimal",
headerBackTitle: "",
headerLargeTitle: false,
headerShadowVisible: false,
headerShown: true,
headerTitleStyle: { fontSize: 18, fontWeight: "800" },
headerTransparent: false,
unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined,
};
// Solid header variant for screens inside sheets (centered title, no editor style).
const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = {
...SOLID_HEADER_OPTIONS,
unstable_navigationItemStyle: undefined,
};
// A native glass header for a sheet screen whose primary child is a scroll
// view. The centered sheet title stays stable while UIKit supplies scroll-edge
// fading from that child.
const SHEET_GLASS_HEADER_OPTIONS: AppScreenOptions = {
...GLASS_HEADER_OPTIONS,
unstable_navigationItemStyle: undefined,
};
const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
...SHEET_SOLID_HEADER_OPTIONS,
headerBackVisible: false,
headerLeft: SettingsLegalDocumentCloseHeaderButton,
headerRight: () => <SettingsLegalDocumentExternalHeaderButton />,
presentation: "fullScreenModal",
};
const SettingsContentStack = createNativeStackNavigator({
initialRouteName: "Settings",
screenOptions: {
...GLASS_HEADER_OPTIONS,
// Sheets read better with the iOS-default centered title (no editor style).
unstable_navigationItemStyle: undefined,
},
screens: {
Settings: createNativeStackScreen({
screen: SettingsRouteScreen,
linking: "",
options: {
title: "Settings",
},
}),
SettingsEnvironments: createNativeStackScreen({
screen: SettingsEnvironmentsRouteScreen,
linking: "environments",
options: {
title: "Environments",
},
}),
SettingsEnvironmentNew: createNativeStackScreen({
screen: ConnectionsNewRouteScreen,
linking: "environment-new",
options: {
title: "Add Environment",
},
}),
SettingsArchive: createNativeStackScreen({
screen: ArchivedThreadsRouteScreen,
linking: "archive",
options: {
title: "Archived Threads",
},
}),
SettingsAppearance: createNativeStackScreen({
screen: SettingsAppearanceRouteScreen,
linking: "appearance",
options: {
title: "Appearance",
},
}),
SettingsProjectGrouping: createNativeStackScreen({
screen: SettingsProjectGroupingRouteScreen,
linking: "project-grouping",
options: {
title: "Project Grouping",
},
}),
SettingsClientStorage: createNativeStackScreen({
screen: SettingsClientStorageRouteScreen,
linking: "client-storage",
options: {
title: "Client Storage",
},
}),
SettingsUsageAccount: createNativeStackScreen({
screen: UsageLimitAccountScreen,
options: { title: "Account" },
}),
SettingsUsage: createNativeStackScreen({
screen: UsageRouteScreen,
linking: "usage",
options: {
title: "Usage",
},
}),
},
});
// The outer stack never owns visible chrome. Settings routes render inside a
// nested stack whose native header remains mounted, while Clerk owns auth chrome.
// Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump.
const SettingsSheetStack = createNativeStackNavigator({
initialRouteName: "SettingsContent",
screenOptions: {
headerShown: false,
},
screens: {
SettingsContent: createNativeStackScreen({
screen: SettingsContentStack,
linking: "",
}),
SettingsAuth: createNativeStackScreen({
screen: SettingsAuthRouteScreen,
linking: "auth",
}),
SettingsWaitlist: createNativeStackScreen({
// Keep the old deep link working after the Connect GA launch.
screen: SettingsAuthRouteScreen,
linking: "waitlist",
}),
},
});
// Thread routes live FLAT in the root stack (not in a nested navigator). A nested
// stack means a second UINavigationController with its own UINavigationBar, which
// breaks iOS 26's shared-header morphing between Home and Thread (each pair inside
// one bar morphs; across two bars the whole screen slides). Flat linking paths keep
// the same deep-link URLs the nested config produced.
const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";
// New-task / add-project flow: nested navigator inside the formSheet (Settings-sheet
// pattern — a plain formSheet screen cannot render a stack header; the header and
// in-sheet pushes come from this nested stack).
const NewTaskSheetStack = createNativeStackNavigator({
initialRouteName: "NewTask",
screenOptions: {
...SHEET_GLASS_HEADER_OPTIONS,
// The form-sheet host owns the one opaque adaptive surface. Child screens
// and the navigation bar stay transparent over it, avoiding visible color
// slabs as view controllers move horizontally.
contentStyle: Platform.OS === "ios" ? { backgroundColor: "transparent" } : undefined,
// UIKit's default push adds a dimming shadow and independently transitions
// the navigation bar. Both read as mismatched sheet backgrounds here.
// simple_push retains native push/pop gestures without either artifact.
animation: Platform.OS === "ios" ? "simple_push" : undefined,
animationDuration: Platform.OS === "ios" ? 350 : undefined,
},
screens: {
NewTask: createNativeStackScreen({
screen: NewTaskRouteScreen,
linking: "",
options: {
title: "Choose project",
},
}),
NewTaskDraft: createNativeStackScreen({
screen: NewTaskDraftRouteScreen,
linking: "draft",
options: {
headerBackVisible: false,
title: "",
},
}),
NewTaskEnvironment: createNativeStackScreen({
screen: NewTaskEnvironmentPickerRouteScreen,
linking: "draft/environment",
options: {
title: "Environment",
},
}),
NewTaskBranch: createNativeStackScreen({
screen: NewTaskBranchPickerRouteScreen,
linking: "draft/branch",
options: {
title: "Branch",
},
}),
ThreadSettings: createNativeStackScreen({
screen: NewTaskThreadSettingsRouteScreen,
linking: "draft/settings",
options: {
gestureEnabled: true,
headerShown: false,
...(Platform.OS === "android"
? { presentation: "card" as const }
: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [1],
sheetGrabberVisible: true,
}),
},
}),
AddProject: createNativeStackScreen({
screen: AddProjectSourceRoute,
linking: "add-project",
options: {
title: "Add Project",
},
}),
AddProjectRepository: createNativeStackScreen({
screen: AddProjectRepositoryRoute,
linking: "add-project/repository",
}),
AddProjectDestination: createNativeStackScreen({
screen: AddProjectDestinationRoute,
linking: "add-project/destination",
}),
AddProjectLocal: createNativeStackScreen({
screen: AddProjectLocalRoute,
linking: "add-project/local",
}),
},
});
// Routes presented as sheets/overlays ON TOP of the workspace. They must not
// influence the adaptive workspace layout: opening Settings over Home should
// not flip the sidebar in or change the active thread.
const WORKSPACE_OVERLAY_ROUTES = new Set([
"ConnectOnboarding",
"Connections",
"ConnectionsNew",
"GitBranches",
"GitCommit",
"GitConfirm",
"GitOverview",
"NewTaskSheet",
"SettingsLegal",
"SettingsSheet",
"ThreadReviewComment",
"ThreadSettingsSheet",
]);
/**
* Pathname of the topmost NON-overlay route — the screen the workspace is
* actually "on", regardless of any sheets floating above it.
*/
function workspacePathFromState(state: NavigationState): string {
const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name));
const effectiveState =
routes.length > 0 && routes.length !== state.routes.length
? ({ ...state, routes, index: routes.length - 1 } as NavigationState)
: state;
const path = getPathFromState(effectiveState, navigationPathConfig);
return path.startsWith("/") ? path : `/${path}`;
}
// The drain hook subscribes to the outbox, all thread shells, projects, and
// connection statuses. Hosting it in a null-rendering leaf keeps those
// updates from re-rendering RootStackLayout (and with it every screen) on
// each enqueue, shell change, or reconnect.
function ThreadOutboxDrainWorker() {
useThreadOutboxDrain();
useComposerAttachmentUploadWorker();
return null;
}
function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
useAgentNotificationNavigation();
// Presents the T3 Connect onboarding sheet after an in-session sign-in.
useConnectOnboardingNavigation();
// Launcher app shortcuts: routes shortcut taps and tracks opened threads.
useAppShortcuts(props.state);
useEffect(() => {
const topRouteName = props.state.routes[props.state.index]?.name;
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [navigation, pendingShare, props.state]);
// Full pathname (sheets included) for keyboard-command scoping; the
// workspace layout only reacts to the underlying non-overlay route.
const path = getPathFromState(props.state, navigationPathConfig);
const pathname = path.startsWith("/") ? path : `/${path}`;
const workspacePathname = workspacePathFromState(props.state);
return (
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ExistingThreadSettingsRouteProvider>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
{props.children}
</AdaptiveWorkspaceLayout>
</ExistingThreadSettingsRouteProvider>
</HardwareKeyboardCommandProvider>
);
}
function NotFoundScreen() {
const navigation = useNavigation();
const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
const returnHomeButtonStyle = StyleSheet.flatten([
{
borderRadius: 999,
paddingHorizontal: 20,
paddingVertical: 14,
},
primaryBgStyle,
]);
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
paddingHorizontal: 24,
paddingVertical: 32,
}}
style={[{ flex: 1 }, screenBgStyle]}
>
<Text className="text-3xl font-t3-bold text-foreground" selectable>
Route not found
</Text>
<Pressable
style={returnHomeButtonStyle}
onPress={() => navigation.dispatch(StackActions.replace("Home"))}
>
<Text className="text-base font-t3-bold text-primary-foreground">Return home</Text>
</Pressable>
</ScrollView>
);
}
export const RootStack = createNativeStackNavigator({
initialRouteName: "Home",
layout: RootStackLayout,
screenOptions: {
headerShown: false,
},
screens: {
Home: createNativeStackScreen({
screen: HomeRouteScreen,
linking: "",
options: {
...GLASS_HEADER_OPTIONS,
contentStyle: { backgroundColor: "transparent" },
headerBackVisible: false,
...getCompactBrandHeaderOptions(),
},
}),
Thread: createNativeStackScreen({
screen: ThreadRouteScreen,
linking: THREAD_LINKING_PREFIX,
options: GLASS_HEADER_OPTIONS,
}),
ThreadTerminal: createNativeStackScreen({
screen: ThreadTerminalRouteScreen,
linking: `${THREAD_LINKING_PREFIX}/terminal`,
options: SOLID_HEADER_OPTIONS,
}),
ThreadReview: createNativeStackScreen({
screen: ReviewSheet,
linking: `${THREAD_LINKING_PREFIX}/review`,
options: SOLID_HEADER_OPTIONS,
}),
ThreadReviewComment: createNativeStackScreen({
screen: ReviewCommentComposerSheet,
linking: `${THREAD_LINKING_PREFIX}/review-comment`,
options: {
// Android cannot host the keyboard-driven comment composer inside a
// formSheet; use a full-screen modal there instead.
...(Platform.OS === "android"
? { presentation: "fullScreenModal" as const }
: FORM_SHEET_PRESENTATION_OPTIONS),
sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92],
sheetGrabberVisible: Platform.OS !== "android",
},
}),
ThreadFiles: createNativeStackScreen({
screen: ThreadFilesTreeScreen,
linking: `${THREAD_LINKING_PREFIX}/files`,
options: {
...GLASS_HEADER_OPTIONS,
title: "Files",
},
}),
ThreadFile: createNativeStackScreen({
screen: ThreadFileScreen,
linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
options: SOLID_HEADER_OPTIONS,
}),
ThreadSettingsSheet: createNativeStackScreen({
screen: ExistingThreadSettingsRouteScreen,
options: {
gestureEnabled: true,
headerShown: false,
...(Platform.OS === "android"
? { presentation: "card" as const }
: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [1],
sheetGrabberVisible: true,
}),
},
}),
GitOverview: createNativeStackScreen({
screen: GitOverviewSheet,
linking: `${THREAD_LINKING_PREFIX}/git`,
options: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.92],
sheetGrabberVisible: true,
},
}),
GitCommit: createNativeStackScreen({
screen: GitCommitSheet,
linking: `${THREAD_LINKING_PREFIX}/git/commit`,
options: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.92],
sheetGrabberVisible: true,
},
}),
GitBranches: createNativeStackScreen({
screen: GitBranchesSheet,
linking: `${THREAD_LINKING_PREFIX}/git/branches`,
options: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.92],
sheetGrabberVisible: true,
},
}),
GitConfirm: createNativeStackScreen({
screen: GitConfirmSheet,
linking: `${THREAD_LINKING_PREFIX}/git-confirm`,
options: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.45, 0.7],
sheetGrabberVisible: true,
},
}),
SettingsSheet: createNativeStackScreen({
screen: SettingsSheetStack,
linking: "settings",
options: {
gestureEnabled: true,
headerShown: false,
// Android pushes settings as a regular full page with an in-screen
// back header; iOS keeps the detented form sheet.
...(Platform.OS === "android"
? { presentation: "card" as const }
: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.7, 0.92],
sheetGrabberVisible: true,
}),
},
}),
SettingsLegal: createNativeStackScreen({
screen: SettingsLegalRouteScreen,
linking: "settings/legal",
options: {
...LEGAL_DOCUMENT_HEADER_OPTIONS,
title: "Legal",
},
}),
ConnectOnboarding: createNativeStackScreen({
screen: ConnectOnboardingRouteScreen,
linking: "connect-onboarding",
options: {
// A root-level Android formSheet does not host the native stack bar;
// the route renders an embedded AndroidSheetHeader instead.
...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS),
title: "Set up T3 Connect",
gestureEnabled: true,
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.6, 0.95],
sheetGrabberVisible: true,
},
}),
Connections: createNativeStackScreen({
screen: ConnectionsRouteScreen,
linking: "connections",
options: {
title: "Environments",
// Android: full page; the screen renders its own AndroidScreenHeader,
// so the native bar stays hidden. iOS keeps the sheet.
...(Platform.OS === "android"
? { presentation: "card" as const, headerShown: false }
: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.7],
sheetGrabberVisible: true,
}),
},
}),
ConnectionsNew: createNativeStackScreen({
screen: ConnectionsNewRouteScreen,
linking: "connections/new",
options: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.55, 0.7],
sheetGrabberVisible: true,
},
}),
NewTaskSheet: createNativeStackScreen({
screen: NewTaskSheetStack,
linking: "new",
// The whole new-task flow (choose project → draft → add project) shares
// draft state via NewTaskFlowProvider. The expo-router era mounted it in
// app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
layout: ({ children }) => (
<NewTaskFlowProvider>
<View className="flex-1 bg-sheet-solid">{children}</View>
</NewTaskFlowProvider>
),
options: {
gestureEnabled: true,
headerShown: false,
// Android pushes the flow as a regular full page — the draft should
// read like a thread that just doesn't exist yet; iOS keeps the sheet.
...(Platform.OS === "android"
? { presentation: "card" as const }
: {
...FORM_SHEET_PRESENTATION_OPTIONS,
sheetAllowedDetents: [0.92],
sheetGrabberVisible: true,
}),
},
}),
NotFound: createNativeStackScreen({
screen: NotFoundScreen,
linking: "*",
}),
},
});
type RootStackType = typeof RootStack;
const navigationPathConfig = {
screens: createPathConfigForStaticNavigation(RootStack) ?? {},
};
declare module "@react-navigation/native" {
interface RootNavigator extends RootStackType {}
}