Files
paseo/packages/app/src/app/_layout.tsx
T
Mohamed Boudra 01f9bc8291 Modernize compact headers and sidebar affordances (#6431)
* feat(app): modernize compact headers and sidebar affordances

Use a shared island header with quick tab creation and keep sheet footers inside their visible layout. Opt sidebar rows into touch sizing without changing global appearance settings.

* test(app): match darker light-theme workspace selection

* test(app): align plugin journeys with compact header
2026-10-10 20:53:09 +01:00

1017 lines
33 KiB
TypeScript

import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { LucideProvider } from "lucide-react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { AppState, useWindowDimensions, View } from "react-native";
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AppearanceProvider } from "@/appearance/provider";
import { CommandCenter } from "@/command-center/command-center";
import { CommandCenterRootActions } from "@/command-center/root-registration";
import { CommandCenterProvider } from "@/command-center/provider";
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
import { PluginCommandCenterActions } from "@/plugins/command-center/registration";
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { ChangelogHost } from "@/changelog";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
} from "@/components/desktop-sidebar-layout";
import { isNative, isWeb } from "@/constants/platform";
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import {
resolveStartupBlocker,
resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap,
bindHostRuntimeAppState,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowChrome } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import {
KeyboardActionDispatcherProvider,
useKeyboardActionDispatcher,
} from "@/keyboard/keyboard-action-dispatcher-context";
import { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostRegistryLoaded,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
import {
useHasWindowChromeObstruction,
WindowChromeProvider,
WindowChromeRegion,
WindowChromeSafeArea,
} from "@/utils/desktop-window";
import {
buildOpenProjectRoute,
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
polyfillNavigator();
polyfillCrypto();
export interface HostRuntimeBootstrapState {
splashError: string | null;
retry: () => void;
hasGivenUpWaitingForHost: boolean;
storeReady: boolean;
startupBlocker: StartupBlocker;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
splashError: null,
retry: () => {},
hasGivenUpWaitingForHost: false,
storeReady: false,
startupBlocker: { kind: "none" },
});
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
return;
}
router.navigate(buildNotificationRoute(data));
});
useEffect(() => {
if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data
: undefined;
openNotification(data);
},
);
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
return;
});
}
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
openNotification(customEvent.detail?.data);
};
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// When the app is open, don't show OS banners.
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const openFromResponse = (response: Notifications.NotificationResponse) => {
const identifier = response.notification.request.identifier;
if (lastHandledIdRef.current === identifier) {
return;
}
lastHandledIdRef.current = identifier;
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
openNotification(data);
};
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
openFromResponse(response);
}
return;
});
return () => {
subscription.remove();
};
}, [openNotification]);
return null;
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const client = useHostRuntimeClient(daemon.serverId);
if (!client) {
return null;
}
return (
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
<LegacyFavoriteProfileMigrationBootstrap serverId={daemon.serverId} client={client} />
<PluginCatalogSync serverId={daemon.serverId} client={client} />
</SessionProvider>
);
}
function LegacyFavoriteProfileMigrationBootstrap({
serverId,
client,
}: {
serverId: string;
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
}) {
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
const isConnected = useHostRuntimeIsConnected(serverId);
useEffect(() => {
if (!serverInfo || !isConnected) {
return;
}
void legacyFavoriteProfileMigration.migrateHost(serverId, client).catch((error) => {
console.warn("[AgentProfiles] Failed to migrate legacy favourites", error);
});
}, [client, isConnected, serverId, serverInfo]);
return null;
}
function HostSessionManager() {
const hosts = useHosts();
if (hosts.length === 0) {
return null;
}
return (
<>
{hosts.map((daemon) => (
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
))}
</>
);
}
export function useEarliestOnlineHostServerId(): string | null {
const store = getHostRuntimeStore();
const subscribe = useCallback(
(listener: () => void) => {
const unsubscribeAll = store.subscribeAll(listener);
const unsubscribeHostList = store.subscribeHostList(listener);
return () => {
unsubscribeAll();
unsubscribeHostList();
};
},
[store],
);
return useSyncExternalStore(
subscribe,
() => store.getEarliestOnlineHostServerId(),
() => store.getEarliestOnlineHostServerId(),
);
}
function useDaemonStartLastError(): string | null {
const service = getDaemonStartService({ store: getHostRuntimeStore() });
return useSyncExternalStore(
(listener) => service.subscribe(listener),
() => service.getLastError(),
() => service.getLastError(),
);
}
function useDaemonStartIsRunning(): boolean {
const service = getDaemonStartService({ store: getHostRuntimeStore() });
return useSyncExternalStore(
(listener) => service.subscribe(listener),
() => service.isRunning(),
() => service.isRunning(),
);
}
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
async function shouldStartBuiltInDaemon(): Promise<boolean> {
if (!shouldUseDesktopDaemon()) {
return false;
}
if (hasConfiguredLocalDaemonOverride()) {
return false;
}
const settings = await loadDesktopSettings();
return settings.daemon.manageBuiltInDaemon;
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const store = getHostRuntimeStore();
return bindHostRuntimeAppState(store, AppState);
}, []);
useEffect(() => {
const store = getHostRuntimeStore();
const daemonStartService = getDaemonStartService({ store });
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
});
}, []);
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const daemonStartError = useDaemonStartLastError();
const daemonStartIsRunning = useDaemonStartIsRunning();
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
const isDesktopRuntime = shouldUseDesktopDaemon();
const startupBlocker = useMemo(
() =>
resolveStartupBlocker({
isDesktopRuntime,
anyOnlineHostServerId,
daemonStartIsRunning,
daemonStartError,
}),
[anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, isDesktopRuntime],
);
const shouldRunGiveUpTimer = shouldRunStartupGiveUpTimer({
startupBlocker,
anyOnlineHostServerId,
hasGivenUpWaitingForHost,
});
useEffect(() => {
if (!shouldRunGiveUpTimer) {
return;
}
const handle = setTimeout(() => {
setHasGivenUpWaitingForHost(true);
}, STARTUP_GIVE_UP_TIMEOUT_MS);
return () => {
clearTimeout(handle);
};
}, [shouldRunGiveUpTimer]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
}, []);
const splashError =
startupBlocker.kind === "managed-daemon-error" ? startupBlocker.message : null;
const storeReady = resolveStartupNavigationReady({ startupBlocker });
const state = useMemo<HostRuntimeBootstrapState>(
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
);
return (
<HostRuntimeBootstrapContext.Provider value={state}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).storeReady;
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
return useContext(HostRuntimeBootstrapContext);
}
const rowStyle = { flex: 1, flexDirection: "row" } as const;
const flexStyle = { flex: 1 } as const;
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
interface AppContainerProps {
children: ReactNode;
chromeEnabled?: boolean;
}
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
const exitFocusMode = usePanelStore((state) => state.exitFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const cycleTheme = useCallback(() => {
void updateSettings({ theme: getNextThemePreference(settings.theme) });
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
const explorerSidebarPresentation = resolveExplorerSidebarPresentation({
isCompact: isCompactLayout,
});
const usesCompactExplorerHost = explorerSidebarPresentation !== "pane";
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
const isWorkspaceFocusModeEnabled = isWorkspaceRoute && isFocusModeEnabled;
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const hasMountedDesktopSidebar = useLatchedBoolean(chromeEnabled);
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
// The focused workspace owns its layout key, its checkout, and therefore the
// only correct answer to "is the explorer open". Let it decide when there is
// one: the pathname alone cannot identify the active workspace, because
// desktop cold-starts at "/" and restores the workspace from route params.
if (keyboardActionDispatcher.dispatch({ id: "sidebar.toggle.both", scope: "sidebar" })) {
return;
}
// Off a workspace route there is no explorer — only the agent list.
toggleAgentList();
}, [keyboardActionDispatcher, toggleAgentList]);
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
// global concerns like keyboard shortcuts. Split those out so settings (and
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
useKeyboardShortcuts({
enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout,
isWorkspaceFocusModeEnabled,
toggleAgentList,
toggleBothSidebars: toggleDesktopSidebars,
exitFocusMode,
cycleTheme,
});
useActiveWorktreeNewAction();
useGlobalNewWorkspaceAction();
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
isSettingsRoute: pathname.includes("/settings"),
});
const desktopSidebarMounted = hasMountedDesktopSidebar && !isWorkspaceFocusModeEnabled;
const desktopSidebarVisible = resolveDesktopSidebarVisibility({
chromeEnabled,
isCompactLayout,
isMounted: desktopSidebarMounted,
isOpen: isDesktopAgentListOpen,
canShare: canDesktopAppSidebarShare({
contentMinimumWidth: appContentMinimumWidth,
requestedSidebarWidth: sidebarWidth,
viewportWidth,
}),
});
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const appChromeLayout = resolveDesktopAppChromeLayout({
desktopSidebarRendered: desktopSidebarVisible,
hasTopLeftWindowControls,
sidebarControlsEnabled: chromeEnabled && !isWorkspaceFocusModeEnabled,
});
const sidebarChrome = (
<SidebarChrome
mounted={isCompactLayout ? chromeEnabled : desktopSidebarMounted}
visible={isCompactLayout ? chromeEnabled : desktopSidebarVisible}
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
/>
);
let themedSidebarChrome = sidebarChrome;
if (isWeb) {
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
}
const workspaceChrome = (
<View style={rowStyle}>
{!isCompactLayout ? (
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{themedSidebarChrome}
</WindowChromeRegion>
) : null}
{usesCompactExplorerHost ? (
<CompactExplorerSidebarHost
enabled={chromeEnabled}
presentation={explorerSidebarPresentation === "dock" ? "dock" : "overlay"}
>
<WindowChromeRegion corners={chromeEnabled ? "both" : appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
</CompactExplorerSidebarHost>
) : (
<WindowChromeRegion corners={appChromeLayout.contentCorners}>
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
)}
</View>
);
// Native panel gesture hosts outlive appearance keys, like native navigators.
// Their tracked styles update in place; web numeric styles still need remounting.
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
<WindowChromeSafeArea
placement="inline"
horizontalPadding={WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING}
pointerEvents="box-none"
style={layoutStyles.windowSidebarToggle}
>
<WindowSidebarMenuToggle />
</WindowChromeSafeArea>
</WindowChromeRegion>
) : null}
<DesktopWindowControls />
<FloatingPanelPortalHost />
</AppearanceStyleBoundary>
{isCompactLayout ? themedSidebarChrome : null}
<AppearanceStyleBoundary>
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
<LegacyAgentSkillsMigration />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<CommandCenterWorkspaceActions />
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
<HostConfirmationSheet />
<ProviderSettingsHost />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<AppDiagnosticHost />
<ChangelogHost />
<QuittingOverlay />
</AppearanceStyleBoundary>
</View>
);
const content = isCompactLayout ? (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>{surface}</MobileGestureWrapper>
) : (
surface
);
return <CommandCenterProvider>{content}</CommandCenterProvider>;
}
function SidebarChrome({
mounted,
visible,
keyboardShortcutsEnabled,
}: {
mounted: boolean;
visible: boolean;
keyboardShortcutsEnabled: boolean;
}) {
const isCompactLayout = useIsCompactFormFactor();
const isMobileActive = useIsMobilePanelActive("agent-list");
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
return (
<SidebarModelProvider active={active}>
{mounted ? <LeftSidebar active={active} /> : null}
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
</SidebarModelProvider>
);
}
function MobileGestureWrapper({
children,
chromeEnabled,
}: {
children: ReactNode;
chromeEnabled: boolean;
}) {
const openGesture = useOpenAgentListGesture(chromeEnabled);
return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
<View collapsable={false} style={layoutStyles.surfaceFill}>
{children}
</View>
</GestureDetector>
);
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
return (
<AppearanceProvider>
<VoiceProvider>
<DesktopWindowControlsSync />
<OfferLinkListener />
<HostSessionManager />
<FaviconStatusSync />
{children}
</VoiceProvider>
</AppearanceProvider>
);
}
function DesktopWindowControlsSync() {
const { isLoading } = useAppSettings();
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
useEffect(() => {
if (isLoading || isNative) return;
void updateDesktopWindowChrome({
backgroundColor: surface0,
trafficLightOffsetY: -4,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [isLoading, surface0]);
return null;
}
function OfferLinkListener() {
const router = useRouter();
useEffect(() => {
let cancelled = false;
const handleUrl = async (url: string | null) => {
if (!url) return;
if (!url.includes("#offer=") && !url.includes("#connect=") && !url.startsWith("relay://"))
return;
try {
const result = await getHostRuntimeStore().importConnectionLink(url, "openProject");
if (!cancelled && result.status === "connected") router.replace(buildOpenProjectRoute());
} catch (error) {
console.warn("[OfferLinkListener] Pairing link failed", error);
}
};
void Linking.getInitialURL()
.then((url) => handleUrl(url))
.catch(() => undefined);
const subscription = Linking.addEventListener("url", (event) => {
void handleUrl(event.url);
});
return () => {
cancelled = true;
subscription.remove();
};
}, [router]);
return null;
}
interface OpenProjectEventPayload {
path?: unknown;
}
interface PendingOpenProjectRequest {
id: number;
serverId: string;
path: string;
}
let nextOpenProjectRequestId = 1;
function OpenProjectListener() {
const chooseHost = useHostChooser();
const hostRegistryLoaded = useHostRegistryLoaded();
const [request, setRequest] = useState<PendingOpenProjectRequest | null>(null);
const [pendingPath, setPendingPath] = useState<string | null>(null);
const openProject = useOpenProject(request?.serverId ?? null);
const openPathOnChosenHost = useCallback(
(path: string) => {
const nextPath = path.trim();
if (!nextPath) {
return;
}
if (!hostRegistryLoaded) {
setPendingPath(nextPath);
return;
}
chooseHost({
title: "Choose host",
onChooseHost: (serverId) => {
setRequest({
id: nextOpenProjectRequestId++,
serverId,
path: nextPath,
});
},
});
},
[chooseHost, hostRegistryLoaded],
);
useEffect(() => {
if (!hostRegistryLoaded || !pendingPath) {
return;
}
const nextPath = pendingPath;
setPendingPath(null);
openPathOnChosenHost(nextPath);
}, [hostRegistryLoaded, openPathOnChosenHost, pendingPath]);
useEffect(() => {
if (!request) {
return;
}
let cancelled = false;
void openProject(request.path).then((result) => {
if (cancelled) {
return null;
}
if (!result.ok) {
setRequest((current) => (current?.id === request.id ? null : current));
return null;
}
setRequest((current) => (current?.id === request.id ? null : current));
return null;
});
return () => {
cancelled = true;
};
}, [openProject, request]);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (!disposed && pending) {
openPathOnChosenHost(pending);
}
return;
})
.catch(() => undefined);
// Listen for hot-start paths relayed via the second-instance event.
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
if (disposed) {
return;
}
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
openPathOnChosenHost(nextPath);
})
.then((dispose) => {
if (disposed) {
dispose();
return;
}
unlisten = dispose;
return;
})
.catch(() => undefined);
return () => {
disposed = true;
unlisten?.();
};
}, [openPathOnChosenHost]);
return null;
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const hosts = useHosts();
const storeReady = useStoreReady();
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const routeHasKnownHost =
routeServerId !== null && hosts.some((host) => host.serverId === routeServerId);
const shouldShowAppChrome =
storeReady &&
(pathname === "/open-project" ||
pathname === "/new" ||
pathname === "/sessions" ||
pathname === "/schedules" ||
routeHasKnownHost);
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
}
function FaviconStatusSync() {
useFaviconStatus();
return null;
}
const ROOT_STACK_SCREEN_OPTIONS = {
headerShown: false,
animation: "none" as const,
};
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
function RootStack() {
const storeReady = useStoreReady();
return (
<ThemedStack
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="new" />
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" />
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
</ThemedStack>
);
}
function WorkspaceRouteNavigationBridge() {
const navigationRef = useNavigationContainerRef();
useEffect(() => {
return registerWorkspaceRouteNavigationRef(navigationRef);
}, [navigationRef]);
return null;
}
function AppShell() {
return (
<MobilePanelsProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AgentNavigationListener />
<AppWithSidebar>
<WorkspaceRouteNavigationBridge />
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</MobilePanelsProvider>
);
}
function RuntimeProviders({ children }: { children: ReactNode }) {
return (
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<SidebarCalloutProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
</SidebarCalloutProvider>
</HostRuntimeBootstrapProvider>
);
}
// 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.
// 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 (
<KeyboardActionDispatcherProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
</KeyboardActionDispatcherProvider>
);
}
function RootAppTree() {
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</LucideProvider>
</View>
</GestureHandlerRootView>
);
}
export default function RootLayout() {
useEffect(() => installWebScrollbarStyles(), []);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
if (nextState !== "active") {
void flushDraftPersistStorage();
}
});
return () => subscription.remove();
}, []);
return <RootAppTree />;
}
const layoutStyles = StyleSheet.create((theme) => ({
surfaceFill: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
windowSidebarToggle: {
position: "absolute",
top: 1,
left: 0,
zIndex: 20,
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: "transparent",
},
}));