Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
209 changes: 151 additions & 58 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { PermissionUpdateNotice } from "./features/connection/PermissionUpdateNotice";
import { installPermissionUpdateNotice } from "./features/connection/permissionUpdateNotice";
import * as Linking from "expo-linking";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { StatusBar } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import {
createNavigationContainerRef,
CommonActions,
createPathConfigForStaticNavigation,
createStaticNavigation,
getActionFromState,
getStateFromPath,
type NavigationState,
} from "@react-navigation/native";

import { RegistryContext } from "@effect/atom-react";
Expand All @@ -19,22 +20,59 @@ import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet
import * as Dialog from "./components/ui/Dialog";
import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider";
import { installShowcaseCapture } from "./features/showcase/ShowcaseCaptureCoordinator";
import {
presentPendingIncomingShare,
pruneIncomingShareImports,
startIncomingShareInbox,
} from "./features/sharing/IncomingShareProvider";
import {
installAgentNotificationNavigation,
setAgentNotificationPathname,
} from "./features/agent-awareness/notificationNavigation";
import {
installAppShortcuts,
recordAppShortcutNavigationState,
} from "./features/shortcuts/useAppShortcuts";
import {
AppearancePreferencesProvider,
installAppearanceRuntime,
useAppearancePreferences,
} from "./features/settings/appearance/AppearancePreferencesProvider";
import { RootStack } from "./Stack";
import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./Stack";
import { pruneNewTaskFlowStates } from "./features/threads/new-task-flow-state";
import { pruneNewTaskBranchCheckouts } from "./features/threads/NewTaskDraftRouteScreen";
import { appAtomRegistry } from "./state/atom-registry";
import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts";
import { environmentThreadShells } from "./state/threads";
import { environmentShell } from "./state/shell";
import { OverlayPortalHost } from "./components/ui/OverlayPortal";
import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking";
import { hasCloudPublicConfig } from "./features/cloud/publicConfig";
import { startDevAutoConnect } from "./features/connection/devAutoConnect";
import {
resolvePendingNewTaskPromptLink,
takeNewTaskPromptLink,
} from "./features/prompt-links/newTaskPromptLink";
import { takeThreadPromptLink } from "./features/prompt-links/threadPromptLink";
import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state";
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
import { useUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";
import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
import {
attachRootNavigation,
flushRootNavigationQueue,
rootNavigationRef,
whenRootNavigationReady,
} from "./lib/rootNavigation";
import {
disposeOnFoundationReplace,
type FoundationHotModule,
} from "./lib/foundation-fast-refresh";
import { installSubscriptionUsageWidget } from "./widgets/SubscriptionUsageCoordinator";
import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery";
import { installModelPreferencesMigration } from "./state/model-preferences-migration";
import { installAppUpdateChecks } from "./features/updates/app-updates";
import { installComposerAttachmentUploads } from "./state/composer-attachment-uploads";
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
import { restoreSettingsLinkState } from "./features/settings/settings-navigation";
import {
Expand All @@ -55,10 +93,42 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") {
void SplashScreen.preventAutoHideAsync().catch(() => {});
void hydrateWorkspaceSelections();

const linkingConfig = {
initialRouteName: "Home",
screens: createPathConfigForStaticNavigation(RootStack, { initialRouteName: "Home" }) ?? {},
} as const;

function isBlankRouteParam(value: unknown): boolean {
return typeof value !== "string" || value.trim().length === 0;
}

function getAppStateFromPath(path: string, options: Parameters<typeof getStateFromPath>[1]) {
const state = takeScheduledTaskNewLink(
takeNewTaskPromptLink(
takeThreadPromptLink(
startDevAutoConnect(
restoreSettingsLinkState(
restoreDraftLinkState(getStateFromPath(path, options), path),
settingsLinkPage(),
),
),
),
),
);
// A device preview needs a concrete thread; a blank segment would mount it
// with nothing to stream.
const blankDevicePreview = state?.routes.some((route) => {
if (route.name !== "ThreadDevicePreview") return false;
const params = route.params as { environmentId?: unknown; threadId?: unknown } | undefined;
return isBlankRouteParam(params?.environmentId) || isBlankRouteParam(params?.threadId);
});
return blankDevicePreview ? undefined : state;
}

const appLinking = {
prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"],
config: { initialRouteName: "Home" },
filter: shouldHandleAppLink,
filter: (url: string) => shouldHandleAppLink(url, hasCloudPublicConfig()),
getInitialURL: async () => (await Linking.getInitialURL()) ?? (await launchWorkspaceURL()),
getActionFromState: (
state: Parameters<typeof getActionFromState>[0],
Expand All @@ -67,39 +137,69 @@ const appLinking = {
// Our router handles the selection; React Navigation's type only lists its own actions.
(workspaceDetailLinkAction(state) as ReturnType<typeof getActionFromState>) ??
getActionFromState(state, options),
getStateFromPath: (path: string, options: Parameters<typeof getStateFromPath>[1]) =>
restoreSettingsLinkState(
restoreDraftLinkState(getStateFromPath(path, options), path),
settingsLinkPage(),
),
getStateFromPath: getAppStateFromPath,
};

/** `useLinkTo` for app services that live outside the navigator. */
function linkToAppPath(href: string): void {
whenRootNavigationReady(() => {
const state = getAppStateFromPath(href, linkingConfig);
if (!state) throw new Error(`Failed to parse the href to a navigation state (${href}).`);
const action =
workspaceDetailLinkAction(state) ??
getActionFromState(state, linkingConfig) ??
CommonActions.reset(state);
rootNavigationRef.dispatch({ target: rootNavigationRef.getRootState()?.key, ...action });
});
}

function handleNavigationStateChange(state: NavigationState | undefined): void {
if (!state) return;
recordWorkspaceSelections(state);
setAgentNotificationPathname(workspaceLocationFromState(state).pathname);
recordAppShortcutNavigationState(state);
pruneNewTaskFlowStates(state);
pruneNewTaskBranchCheckouts(state);
pruneIncomingShareImports(state);
presentPendingIncomingShare();
resolvePendingNewTaskPromptLink(state);
}

function handleNavigationReady(): void {
handleNavigationStateChange(rootNavigationRef.getRootState());
flushRootNavigationQueue();
}

const Navigation = createStaticNavigation(RootStack);
const navigationRef = createNavigationContainerRef();

function SplashScreenCoordinator() {
const { isReady } = useAppearancePreferences();
declare const module: { readonly hot?: FoundationHotModule } | undefined;

useEffect(() => {
if (isReady) void SplashScreen.hide();
}, [isReady]);
const appServiceDisposers = [
installUiRuntimeMemoryWarningGc(),
subscribeComposerDraftModelReconciliation({
registry: appAtomRegistry,
threadShellAtom: environmentThreadShells.threadShellAtom,
threadRefsAtom: environmentThreadShells.threadRefsAtom,
shellStateAtom: environmentShell.stateAtom,
}),
installAppearanceRuntime(),
installSubscriptionUsageWidget(),
installThreadOutboxDelivery(),
installModelPreferencesMigration(),
installComposerAttachmentUploads(),
installPermissionUpdateNotice(),
startIncomingShareInbox(),
installAgentNotificationNavigation(linkToAppPath),
installAppShortcuts(linkToAppPath),
installShowcaseCapture(rootPathnameFromState),
installAppUpdateChecks(),
];

return null;
}
disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => {
for (const dispose of appServiceDisposers) dispose();
});

export default function App() {
useUiRuntimeMemoryWarningGc();
useEffect(
() =>
subscribeComposerDraftModelReconciliation({
registry: appAtomRegistry,
threadShellAtom: environmentThreadShells.threadShellAtom,
threadRefsAtom: environmentThreadShells.threadRefsAtom,
shellStateAtom: environmentShell.stateAtom,
}),
[],
);

return (
<RegistryContext.Provider value={appAtomRegistry}>
<CloudAuthProvider>
Expand All @@ -117,33 +217,26 @@ function AppContent() {

return (
<>
<SplashScreenCoordinator />
<SubscriptionUsageCoordinator />
<PermissionUpdateNotice />
<GestureHandlerRootView className="flex-1">
<KeyboardProvider statusBarTranslucent>
<SafeAreaProvider>
<VoiceInputProvider>
<StatusBar barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} />
{/* native-stack needs the theme's dark flag to select dark native headers. */}
<GlobalVoiceInputControl>
<NativeLayoutMetricsProvider>
<IncomingShareProvider>
<Navigation
ref={navigationRef}
linking={appLinking}
onReady={() => recordWorkspaceSelections(navigationRef.getRootState())}
theme={navigationTheme}
onStateChange={recordWorkspaceSelections}
/>
</IncomingShareProvider>
<Dialog.Host />
<ThreadArrangementHost />
<ProjectChipIconRasterizer />
</NativeLayoutMetricsProvider>
</GlobalVoiceInputControl>
<OverlayPortalHost />
</VoiceInputProvider>
<StatusBar barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} />
{/* native-stack needs the theme's dark flag to select dark native headers. */}
<GlobalVoiceInputControl>
<NativeLayoutMetricsProvider>
<Navigation
ref={attachRootNavigation}
linking={appLinking}
theme={navigationTheme}
onReady={handleNavigationReady}
onStateChange={handleNavigationStateChange}
/>
<Dialog.Host />
<ThreadArrangementHost />
<ProjectChipIconRasterizer />
</NativeLayoutMetricsProvider>
</GlobalVoiceInputControl>
<OverlayPortalHost />
</SafeAreaProvider>
</KeyboardProvider>
</GestureHandlerRootView>
Expand Down
Loading
Loading