* feat(server): replace Pi ACP integration with direct SDK provider Replace the ACP-based Pi provider (which spawned a `pi-acp` subprocess and talked JSON-RPC over stdio) with a direct in-process integration using `@mariozechner/pi-coding-agent` as a library. The new `PiDirectAgentClient` and `PiDirectAgentSession` use the Pi SDK directly — creating sessions via `createAgentSession()`, managing models via `ModelRegistry`, and mapping Pi's `AgentSessionEvent` stream to Paseo's `AgentStreamEvent` types including: - Thread/turn lifecycle events - Assistant text and thinking/reasoning deltas - Tool execution (bash, read, write, edit, find, grep, ls) with arg caching across start/update/end events - Compaction events - Session persistence and resume via `SessionManager` Thinking levels flow through the existing `thinkingOptionId` / `setThinkingOption()` infrastructure, not through features. Includes 9 real e2e tests against a live Pi agent covering tool calls (bash/read/write/edit), reasoning chunks, session persistence/resume, model listing, and thinking option management. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update lockfile signatures and Nix hash * refactor(server): improve Pi direct provider code quality Address coding standards violations in pi-direct-agent.ts: - Replace all `as Record<string, unknown>` / `as any` / `as unknown as` casts with properly typed boundaries using Pi SDK's exported tool input types (BashToolInput, ReadToolInput, EditToolInput, etc.) - Introduce named interfaces at all boundaries: PiPromptPayload, ToolCallOutputSummary, PiModelReference, PiPersistenceMetadata - Parse tool args once at boundary into typed discriminated union, then trust types internally — no more typeof probing in mappers - Break all dense ternary chains into named steps / helper functions - Import SDK types directly (RegisteredCommand, ResourceLoader, Skill) instead of casting through anonymous inline shapes - Isolate the system prompt SDK escape hatch into a minimal boundary helper with a named interface Behavior is preserved — all 9 real e2e tests still pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: regenerate package-lock.json with resolved/integrity fields The previous lockfile regeneration ran under an offline/cached npm context and stripped resolved URLs and integrity hashes from existing entries, which would break npm ci and Nix builds. * fix: update Nix npmDepsHash for regenerated lockfile * chore(server): bump @mariozechner/pi-coding-agent to ^0.67.68 * fix: update lockfile signatures and Nix hash * fix(app): surface agent setter RPC errors as toasts setAgentMode/Model/ThinkingOption/Feature errors from the daemon were only logged to console, so failures like missing API keys silently vanished. Add toast.error fallbacks at all call sites and hoist ToastProvider above SessionProvider so session context can use it. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
891 lines
28 KiB
TypeScript
891 lines
28 KiB
TypeScript
import "@/styles/unistyles";
|
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
|
import {
|
|
Stack,
|
|
useGlobalSearchParams,
|
|
useNavigationContainerRef,
|
|
usePathname,
|
|
useRouter,
|
|
} from "expo-router";
|
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
|
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|
import { PortalProvider } from "@gorhom/portal";
|
|
import { VoiceProvider } from "@/contexts/voice-context";
|
|
import { useAppSettings } from "@/hooks/use-settings";
|
|
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
|
import { View, Text } from "react-native";
|
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
import {
|
|
getHostRuntimeStore,
|
|
useHosts,
|
|
useHostMutations,
|
|
useHostRuntimeClient,
|
|
} from "@/runtime/host-runtime";
|
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
|
import { useOpenProject } from "@/hooks/use-open-project";
|
|
import { SessionProvider } from "@/contexts/session-context";
|
|
import type { HostProfile } from "@/types/host-connection";
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useState,
|
|
useEffect,
|
|
type ReactNode,
|
|
useMemo,
|
|
useRef,
|
|
} from "react";
|
|
import { Platform } from "react-native";
|
|
import * as Linking from "expo-linking";
|
|
import * as Notifications from "expo-notifications";
|
|
import { LeftSidebar } from "@/components/left-sidebar";
|
|
import { DownloadToast } from "@/components/download-toast";
|
|
import { UpdateBanner } from "@/desktop/updates/update-banner";
|
|
import { ToastProvider } from "@/contexts/toast-context";
|
|
import { usePanelStore } from "@/stores/panel-store";
|
|
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
|
import {
|
|
SidebarAnimationProvider,
|
|
useSidebarAnimation,
|
|
} from "@/contexts/sidebar-animation-context";
|
|
import {
|
|
HorizontalScrollProvider,
|
|
useHorizontalScrollOptional,
|
|
} from "@/contexts/horizontal-scroll-context";
|
|
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
|
import { CommandCenter } from "@/components/command-center";
|
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
|
import { queryClient } from "@/query/query-client";
|
|
import {
|
|
WEB_NOTIFICATION_CLICK_EVENT,
|
|
type WebNotificationClickDetail,
|
|
ensureOsNotificationPermission,
|
|
} from "@/utils/os-notifications";
|
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
|
import { getDesktopHost } from "@/desktop/host";
|
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
|
import {
|
|
buildHostRootRoute,
|
|
mapPathnameToServer,
|
|
parseServerIdFromPathname,
|
|
parseHostAgentRouteFromPathname,
|
|
parseWorkspaceOpenIntent,
|
|
decodeWorkspaceIdFromPathSegment,
|
|
} from "@/utils/host-routes";
|
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
|
import { isWeb, isNative } from "@/constants/platform";
|
|
|
|
polyfillCrypto();
|
|
|
|
export type HostRuntimeBootstrapState = {
|
|
phase: "starting-daemon" | "connecting" | "online" | "error";
|
|
error: string | null;
|
|
retry: () => void;
|
|
};
|
|
|
|
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const firstValue = value[0];
|
|
if (typeof firstValue !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = firstValue.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
|
phase: "starting-daemon",
|
|
error: null,
|
|
retry: () => {},
|
|
});
|
|
|
|
function PushNotificationRouter() {
|
|
const router = useRouter();
|
|
const lastHandledIdRef = useRef<string | null>(null);
|
|
|
|
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;
|
|
router.push(buildNotificationRoute(data) as any);
|
|
},
|
|
);
|
|
|
|
void Promise.resolve(unlistenResult).then((unlisten) => {
|
|
if (typeof unlisten !== "function") {
|
|
return;
|
|
}
|
|
if (cancelled) {
|
|
unlisten();
|
|
return;
|
|
}
|
|
removeDesktopNotificationListener = unlisten;
|
|
});
|
|
}
|
|
|
|
const target = globalThis as unknown as EventTarget;
|
|
const openFromWebClick = (event: Event) => {
|
|
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
|
event.preventDefault();
|
|
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
|
};
|
|
|
|
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
removeDesktopNotificationListener?.();
|
|
target.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;
|
|
router.push(buildNotificationRoute(data) as any);
|
|
};
|
|
|
|
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
|
|
|
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
|
if (response) {
|
|
openFromResponse(response);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
subscription.remove();
|
|
};
|
|
}, [router]);
|
|
|
|
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}>
|
|
{null}
|
|
</SessionProvider>
|
|
);
|
|
}
|
|
|
|
function HostSessionManager() {
|
|
const hosts = useHosts();
|
|
|
|
if (hosts.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{hosts.map((daemon) => (
|
|
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [retryToken, setRetryToken] = useState(0);
|
|
const retry = useCallback(() => {
|
|
setPhase("starting-daemon");
|
|
setError(null);
|
|
setRetryToken((current) => current + 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let cancelAnyOnline: (() => void) | null = null;
|
|
const shouldManageDesktop = shouldUseDesktopDaemon();
|
|
const store = getHostRuntimeStore();
|
|
|
|
const init = async () => {
|
|
const settings = await loadSettingsFromStorage();
|
|
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
|
|
await store.loadFromStorage();
|
|
if (isDesktopManaged) {
|
|
setPhase("starting-daemon");
|
|
setError(null);
|
|
|
|
let raceSettled = false;
|
|
|
|
const anyOnline = store.waitForAnyConnectionOnline();
|
|
cancelAnyOnline = anyOnline.cancel;
|
|
|
|
const bootstrapPromise = (async (): Promise<
|
|
{ type: "online" } | { type: "error"; error: string }
|
|
> => {
|
|
try {
|
|
const bootstrapResult = await store.bootstrapDesktop();
|
|
if (!bootstrapResult.ok) {
|
|
return { type: "error", error: bootstrapResult.error };
|
|
}
|
|
if (!cancelled && !raceSettled) {
|
|
setPhase("connecting");
|
|
}
|
|
await store.addConnectionFromListenAndWaitForOnline({
|
|
listenAddress: bootstrapResult.listenAddress,
|
|
serverId: bootstrapResult.serverId,
|
|
hostname: bootstrapResult.hostname,
|
|
});
|
|
return { type: "online" };
|
|
} catch (err) {
|
|
return {
|
|
type: "error",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
})();
|
|
|
|
const result = await Promise.race([
|
|
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
|
|
bootstrapPromise,
|
|
]);
|
|
|
|
raceSettled = true;
|
|
anyOnline.cancel();
|
|
|
|
if (!cancelled) {
|
|
if (result.type === "online") {
|
|
setPhase("online");
|
|
setError(null);
|
|
} else {
|
|
setPhase("error");
|
|
setError(result.error);
|
|
}
|
|
}
|
|
} else {
|
|
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
|
if (!cancelled) {
|
|
setPhase("online");
|
|
setError(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
void init().catch((bootstrapError) => {
|
|
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
if (shouldManageDesktop) {
|
|
setPhase("error");
|
|
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
|
|
return;
|
|
}
|
|
setPhase("online");
|
|
setError(null);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
cancelAnyOnline?.();
|
|
};
|
|
}, [retryToken]);
|
|
|
|
const state = useMemo<HostRuntimeBootstrapState>(
|
|
() => ({
|
|
phase,
|
|
error,
|
|
retry,
|
|
}),
|
|
[error, phase, retry],
|
|
);
|
|
|
|
return (
|
|
<HostRuntimeBootstrapContext.Provider value={state}>
|
|
{children}
|
|
</HostRuntimeBootstrapContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useStoreReady(): boolean {
|
|
return useContext(HostRuntimeBootstrapContext).phase === "online";
|
|
}
|
|
|
|
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
|
|
return useContext(HostRuntimeBootstrapContext);
|
|
}
|
|
|
|
function QueryProvider({ children }: { children: ReactNode }) {
|
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
}
|
|
|
|
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
|
const flexStyle = { flex: 1 } as const;
|
|
|
|
interface AppContainerProps {
|
|
children: ReactNode;
|
|
selectedAgentId?: string;
|
|
chromeEnabled?: boolean;
|
|
}
|
|
|
|
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
|
|
|
function AppContainer({
|
|
children,
|
|
selectedAgentId,
|
|
chromeEnabled: chromeEnabledOverride,
|
|
}: AppContainerProps) {
|
|
const { theme } = useUnistyles();
|
|
const daemons = useHosts();
|
|
const { settings, updateSettings } = useAppSettings();
|
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
|
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
|
|
|
const cycleTheme = useCallback(() => {
|
|
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
|
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
|
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
|
|
}, [settings.theme, updateSettings]);
|
|
|
|
const isCompactLayout = useIsCompactFormFactor();
|
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
|
const pathname = usePathname();
|
|
// 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");
|
|
|
|
useEffect(() => {
|
|
const bp = UnistylesRuntime.breakpoint;
|
|
const screenW = UnistylesRuntime.screen.width;
|
|
const screenH = UnistylesRuntime.screen.height;
|
|
const isElectron = getIsElectronRuntime();
|
|
const windowW = isWeb ? window.innerWidth : undefined;
|
|
const windowH = isWeb ? window.innerHeight : undefined;
|
|
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
|
const ua = isWeb ? navigator.userAgent : undefined;
|
|
|
|
console.log(
|
|
"[layout-debug]",
|
|
JSON.stringify({
|
|
breakpoint: bp,
|
|
isCompactLayout,
|
|
isElectron,
|
|
chromeEnabled,
|
|
isFocusModeEnabled,
|
|
agentListOpen,
|
|
sidebarWidth,
|
|
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
|
unistylesScreen: { w: screenW, h: screenH },
|
|
window: { w: windowW, h: windowH },
|
|
devicePixelRatio: dpr,
|
|
userAgent: ua,
|
|
}),
|
|
);
|
|
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
|
|
|
useKeyboardShortcuts({
|
|
enabled: keyboardShortcutsEnabled,
|
|
isMobile: isCompactLayout,
|
|
toggleAgentList,
|
|
toggleFileExplorer,
|
|
toggleBothSidebars,
|
|
toggleFocusMode,
|
|
cycleTheme,
|
|
});
|
|
|
|
useActiveWorktreeNewAction();
|
|
|
|
const containerStyle = useMemo(
|
|
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
|
[theme.colors.surface0],
|
|
);
|
|
|
|
const content = (
|
|
<View style={containerStyle}>
|
|
<View style={rowStyle}>
|
|
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
|
<LeftSidebar selectedAgentId={selectedAgentId} />
|
|
)}
|
|
<View style={flexStyle}>{children}</View>
|
|
</View>
|
|
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
|
<DownloadToast />
|
|
<UpdateBanner />
|
|
<CommandCenter />
|
|
<ProjectPickerModal />
|
|
<WorkspaceSetupDialog />
|
|
<KeyboardShortcutsDialog />
|
|
</View>
|
|
);
|
|
|
|
if (!isCompactLayout) {
|
|
return content;
|
|
}
|
|
|
|
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
|
}
|
|
|
|
function MobileGestureWrapper({
|
|
children,
|
|
chromeEnabled,
|
|
}: {
|
|
children: ReactNode;
|
|
chromeEnabled: boolean;
|
|
}) {
|
|
const mobileView = usePanelStore((state) => state.mobileView);
|
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
|
const horizontalScroll = useHorizontalScrollOptional();
|
|
const {
|
|
translateX,
|
|
backdropOpacity,
|
|
windowWidth,
|
|
animateToOpen,
|
|
animateToClose,
|
|
isGesturing,
|
|
gestureAnimatingRef,
|
|
openGestureRef,
|
|
} = useSidebarAnimation();
|
|
const touchStartX = useSharedValue(0);
|
|
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
|
|
|
const handleGestureOpen = useCallback(() => {
|
|
gestureAnimatingRef.current = true;
|
|
openAgentList();
|
|
}, [openAgentList, gestureAnimatingRef]);
|
|
|
|
const openGesture = useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.withRef(openGestureRef)
|
|
.enabled(openGestureEnabled)
|
|
.manualActivation(true)
|
|
.failOffsetY([-10, 10])
|
|
.onTouchesDown((event) => {
|
|
const touch = event.changedTouches[0];
|
|
if (touch) {
|
|
touchStartX.value = touch.absoluteX;
|
|
}
|
|
})
|
|
.onTouchesMove((event, stateManager) => {
|
|
const touch = event.changedTouches[0];
|
|
if (!touch || event.numberOfTouches !== 1) return;
|
|
|
|
const deltaX = touch.absoluteX - touchStartX.value;
|
|
|
|
if (horizontalScroll?.isAnyScrolledRight.value) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
|
|
if (deltaX > 15) {
|
|
stateManager.activate();
|
|
}
|
|
})
|
|
.onStart(() => {
|
|
isGesturing.value = true;
|
|
})
|
|
.onUpdate((event) => {
|
|
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
|
translateX.value = newTranslateX;
|
|
backdropOpacity.value = interpolate(
|
|
newTranslateX,
|
|
[-windowWidth, 0],
|
|
[0, 1],
|
|
Extrapolation.CLAMP,
|
|
);
|
|
})
|
|
.onEnd((event) => {
|
|
isGesturing.value = false;
|
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
|
if (shouldOpen) {
|
|
animateToOpen();
|
|
runOnJS(handleGestureOpen)();
|
|
} else {
|
|
animateToClose();
|
|
}
|
|
})
|
|
.onFinalize(() => {
|
|
isGesturing.value = false;
|
|
}),
|
|
[
|
|
openGestureEnabled,
|
|
windowWidth,
|
|
translateX,
|
|
backdropOpacity,
|
|
animateToOpen,
|
|
animateToClose,
|
|
handleGestureOpen,
|
|
isGesturing,
|
|
openGestureRef,
|
|
horizontalScroll?.isAnyScrolledRight,
|
|
touchStartX,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
|
{children}
|
|
</GestureDetector>
|
|
);
|
|
}
|
|
|
|
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|
const { settings, isLoading: settingsLoading } = useAppSettings();
|
|
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
|
const systemColorScheme = useColorScheme();
|
|
const { theme } = useUnistyles();
|
|
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
|
|
|
// Apply theme setting on mount and when it changes
|
|
useEffect(() => {
|
|
if (settingsLoading) return;
|
|
if (settings.theme === "auto") {
|
|
UnistylesRuntime.setAdaptiveThemes(true);
|
|
} else {
|
|
UnistylesRuntime.setAdaptiveThemes(false);
|
|
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
|
|
}
|
|
}, [settingsLoading, settings.theme]);
|
|
|
|
useEffect(() => {
|
|
if (settingsLoading || isNative) {
|
|
return;
|
|
}
|
|
|
|
void updateDesktopWindowControls({
|
|
backgroundColor: theme.colors.surface0,
|
|
foregroundColor: theme.colors.foreground,
|
|
}).catch((error) => {
|
|
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
|
});
|
|
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
|
|
|
return (
|
|
<VoiceProvider>
|
|
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
|
<HostSessionManager />
|
|
<FaviconStatusSync />
|
|
{children}
|
|
</VoiceProvider>
|
|
);
|
|
}
|
|
|
|
function OfferLinkListener({
|
|
upsertDaemonFromOfferUrl,
|
|
}: {
|
|
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
|
|
}) {
|
|
const router = useRouter();
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const handleUrl = (url: string | null) => {
|
|
if (!url) return;
|
|
if (!url.includes("#offer=")) return;
|
|
void upsertDaemonFromOfferUrl(url)
|
|
.then((profile) => {
|
|
if (cancelled) return;
|
|
const serverId = (profile as any)?.serverId;
|
|
if (typeof serverId !== "string" || !serverId) return;
|
|
router.replace(buildHostRootRoute(serverId));
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) return;
|
|
console.warn("[Linking] Failed to import pairing offer", error);
|
|
});
|
|
};
|
|
|
|
void Linking.getInitialURL()
|
|
.then(handleUrl)
|
|
.catch(() => undefined);
|
|
|
|
const subscription = Linking.addEventListener("url", (event) => {
|
|
handleUrl(event.url);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
subscription.remove();
|
|
};
|
|
}, [router, upsertDaemonFromOfferUrl]);
|
|
|
|
return null;
|
|
}
|
|
|
|
interface OpenProjectEventPayload {
|
|
path?: unknown;
|
|
}
|
|
|
|
function OpenProjectListener() {
|
|
const hosts = useHosts();
|
|
const serverId = hosts[0]?.serverId ?? null;
|
|
const client = useHostRuntimeClient(serverId ?? "");
|
|
const openProject = useOpenProject(serverId);
|
|
const pendingPathRef = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
let unlisten: (() => void) | null = null;
|
|
const maybeOpenProject = (inputPath: string) => {
|
|
const nextPath = inputPath.trim();
|
|
if (!nextPath) {
|
|
return;
|
|
}
|
|
|
|
pendingPathRef.current = nextPath;
|
|
|
|
if (!serverId || !client) {
|
|
return;
|
|
}
|
|
|
|
const pathToOpen = pendingPathRef.current;
|
|
pendingPathRef.current = null;
|
|
if (!pathToOpen) {
|
|
return;
|
|
}
|
|
|
|
void openProject(pathToOpen).catch(() => undefined);
|
|
};
|
|
|
|
// Pull any path that was passed on cold start (before the listener existed).
|
|
// Store in the ref even if this effect instance is disposed — the next
|
|
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
|
|
void getDesktopHost()
|
|
?.getPendingOpenProject?.()
|
|
?.then((pending) => {
|
|
if (pending) {
|
|
pendingPathRef.current = pending;
|
|
}
|
|
if (!disposed && pending) {
|
|
maybeOpenProject(pending);
|
|
}
|
|
})
|
|
.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() : "";
|
|
maybeOpenProject(nextPath);
|
|
})
|
|
.then((dispose) => {
|
|
if (disposed) {
|
|
dispose();
|
|
return;
|
|
}
|
|
unlisten = dispose;
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
maybeOpenProject(pendingPathRef.current ?? "");
|
|
|
|
return () => {
|
|
disposed = true;
|
|
unlisten?.();
|
|
};
|
|
}, [client, openProject, serverId]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
|
const hosts = useHosts();
|
|
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
|
const shouldShowAppChrome = activeServerId !== null;
|
|
|
|
useEffect(() => {
|
|
if (!activeServerId || hosts.length === 0) {
|
|
return;
|
|
}
|
|
if (hosts.some((host) => host.serverId === activeServerId)) {
|
|
return;
|
|
}
|
|
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
|
}, [activeServerId, hosts, pathname, router]);
|
|
|
|
// Parse selectedAgentKey directly from pathname
|
|
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
|
const selectedAgentKey = useMemo(() => {
|
|
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
|
|
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
|
|
const openValue = Array.isArray(params.open) ? params.open[0] : params.open;
|
|
const openIntent = parseWorkspaceOpenIntent(openValue);
|
|
if (workspaceServerId && openIntent?.kind === "agent") {
|
|
const agentId = openIntent.agentId.trim();
|
|
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
|
|
}
|
|
|
|
const match = parseHostAgentRouteFromPathname(pathname);
|
|
return match ? `${match.serverId}:${match.agentId}` : undefined;
|
|
}, [params.open, pathname]);
|
|
|
|
return (
|
|
<AppContainer
|
|
selectedAgentId={shouldShowAppChrome ? selectedAgentKey : undefined}
|
|
chromeEnabled={shouldShowAppChrome}
|
|
>
|
|
{children}
|
|
</AppContainer>
|
|
);
|
|
}
|
|
|
|
function FaviconStatusSync() {
|
|
useFaviconStatus();
|
|
return null;
|
|
}
|
|
|
|
function RootStack() {
|
|
const storeReady = useStoreReady();
|
|
const { theme } = useUnistyles();
|
|
return (
|
|
<Stack
|
|
screenOptions={{
|
|
headerShown: false,
|
|
animation: "none",
|
|
contentStyle: {
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
}}
|
|
>
|
|
<Stack.Protected guard={storeReady}>
|
|
<Stack.Screen name="welcome" />
|
|
<Stack.Screen name="settings/index" />
|
|
<Stack.Screen name="settings/[section]" />
|
|
<Stack.Screen name="pair-scan" />
|
|
</Stack.Protected>
|
|
<Stack.Screen
|
|
name="h/[serverId]/workspace/[workspaceId]"
|
|
getId={({ params }) => {
|
|
const serverId = getRouteParamValue(params?.serverId);
|
|
const workspaceId = getRouteParamValue(params?.workspaceId);
|
|
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
|
}}
|
|
/>
|
|
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
|
<Stack.Screen name="h/[serverId]/index" />
|
|
<Stack.Screen name="h/[serverId]/sessions" />
|
|
<Stack.Screen name="h/[serverId]/open-project" />
|
|
<Stack.Screen name="h/[serverId]/settings" />
|
|
<Stack.Screen name="index" />
|
|
<Stack.Screen name="settings/hosts/[serverId]" />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function NavigationActiveWorkspaceObserver() {
|
|
const navigationRef = useNavigationContainerRef();
|
|
|
|
useEffect(() => {
|
|
syncNavigationActiveWorkspace(navigationRef);
|
|
const unsubscribeState = navigationRef.addListener("state", () => {
|
|
syncNavigationActiveWorkspace(navigationRef);
|
|
});
|
|
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
|
|
syncNavigationActiveWorkspace(navigationRef);
|
|
});
|
|
return () => {
|
|
unsubscribeState();
|
|
unsubscribeReady();
|
|
};
|
|
}, [navigationRef]);
|
|
|
|
return null;
|
|
}
|
|
|
|
export default function RootLayout() {
|
|
const { theme } = useUnistyles();
|
|
|
|
return (
|
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
|
<NavigationActiveWorkspaceObserver />
|
|
<PortalProvider>
|
|
<SafeAreaProvider>
|
|
<KeyboardProvider>
|
|
<QueryProvider>
|
|
<BottomSheetModalProvider>
|
|
<HostRuntimeBootstrapProvider>
|
|
<PushNotificationRouter />
|
|
<ToastProvider>
|
|
<ProvidersWrapper>
|
|
<SidebarAnimationProvider>
|
|
<HorizontalScrollProvider>
|
|
<OpenProjectListener />
|
|
<AppWithSidebar>
|
|
<RootStack />
|
|
</AppWithSidebar>
|
|
</HorizontalScrollProvider>
|
|
</SidebarAnimationProvider>
|
|
</ProvidersWrapper>
|
|
</ToastProvider>
|
|
</HostRuntimeBootstrapProvider>
|
|
</BottomSheetModalProvider>
|
|
</QueryProvider>
|
|
</KeyboardProvider>
|
|
</SafeAreaProvider>
|
|
</PortalProvider>
|
|
</GestureHandlerRootView>
|
|
);
|
|
}
|