feat: add iOS simulator worktree preview

Adds the worktree service, setup cache seeding, and Metro preview mount for /.sim. Also includes the related startup and mobile sidebar fixes needed by the updated worktree app flow.
This commit is contained in:
Mohamed Boudra
2026-06-11 12:23:53 +07:00
parent 6db285b1d9
commit a682476d11
21 changed files with 1143 additions and 189 deletions

View File

@@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals:
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
@@ -48,6 +49,16 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### iOS simulator preview service
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface.
### Desktop renderer profiling
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on

30
package-lock.json generated
View File

@@ -24193,6 +24193,19 @@
"css-in-js-utils": "^3.1.0"
}
},
"node_modules/inspect-webkit": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/inspect-webkit/-/inspect-webkit-0.0.5.tgz",
"integrity": "sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw==",
"dev": true,
"license": "MIT",
"bin": {
"inspect-webkit": "dist/cli.js"
},
"peerDependencies": {
"typescript": "^5"
}
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -32923,6 +32936,22 @@
"seroval": "^1.0"
}
},
"node_modules/serve-sim": {
"version": "0.1.40",
"resolved": "https://registry.npmjs.org/serve-sim/-/serve-sim-0.1.40.tgz",
"integrity": "sha512-32JSUriN/EnNR3zx6fmiOKjuFrbA8VHWR2J3dX8T51Z/xkSmrSLOwaz/3W9lPiCIaF2O3LT4qpAhLkp0f1kVaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"inspect-webkit": "^0.0.5"
},
"bin": {
"serve-sim": "dist/serve-sim.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@@ -37049,6 +37078,7 @@
"jsdom": "^20.0.3",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"serve-sim": "^0.1.40",
"typescript": "~5.9.2",
"vitest": "^4.1.6",
"wrangler": "^4.75.0",

View File

@@ -78,4 +78,31 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
return resolveWithCustomWebOverlay(context, moduleName, platform);
};
if (process.env.PASEO_SERVE_SIM_PREVIEW === "1") {
const { simMiddleware } = require("serve-sim/middleware");
const originalEnhanceMiddleware = config.server?.enhanceMiddleware;
config.server = config.server ?? {};
config.server.enhanceMiddleware = (metroMiddleware, server) => {
const middleware = originalEnhanceMiddleware
? originalEnhanceMiddleware(metroMiddleware, server)
: metroMiddleware;
const serveSimulator = simMiddleware({
basePath: "/.sim",
device: process.env.PASEO_SERVE_SIM_DEVICE_UDID,
});
return (req, res, next) => {
serveSimulator(req, res, (error) => {
if (error) {
if (next) {
next(error);
return;
}
throw error;
}
middleware(req, res, next);
});
};
};
}
module.exports = config;

View File

@@ -125,6 +125,7 @@
"jsdom": "^20.0.3",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"serve-sim": "^0.1.40",
"typescript": "~5.9.2",
"vitest": "^4.1.6",
"wrangler": "^4.75.0",

View File

@@ -41,15 +41,20 @@ import {
} from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_AGENT_LIST,
SidebarAnimationProvider,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
import {
resolveStartupBlocker,
resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer,
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -60,7 +65,6 @@ import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
@@ -70,7 +74,6 @@ import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostMutations,
useHostRuntimeClient,
useHosts,
@@ -82,6 +85,7 @@ import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildHostRootRoute,
parseHostAgentRouteFromPathname,
@@ -103,6 +107,7 @@ export interface HostRuntimeBootstrapState {
retry: () => void;
hasGivenUpWaitingForHost: boolean;
storeReady: boolean;
startupBlocker: StartupBlocker;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
@@ -110,6 +115,7 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
retry: () => {},
hasGivenUpWaitingForHost: false,
storeReady: false,
startupBlocker: { kind: "none" },
});
function PushNotificationRouter() {
@@ -313,18 +319,26 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const daemonStartError = useDaemonStartLastError();
const daemonStartIsRunning = useDaemonStartIsRunning();
const waitForConfiguredLocalDaemon =
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
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 (
anyOnlineHostServerId ||
daemonStartError ||
daemonStartIsRunning ||
waitForConfiguredLocalDaemon ||
hasGivenUpWaitingForHost
) {
if (!shouldRunGiveUpTimer) {
return;
}
const handle = setTimeout(() => {
@@ -333,13 +347,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
return () => {
clearTimeout(handle);
};
}, [
anyOnlineHostServerId,
daemonStartError,
daemonStartIsRunning,
waitForConfiguredLocalDaemon,
hasGivenUpWaitingForHost,
]);
}, [shouldRunGiveUpTimer]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
@@ -350,14 +358,13 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
});
}, []);
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
const isCurrentlyStoreReady =
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
const splashError =
startupBlocker.kind === "managed-daemon-error" ? startupBlocker.message : null;
const storeReady = resolveStartupNavigationReady({ startupBlocker });
const state = useMemo<HostRuntimeBootstrapState>(
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady],
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
);
return (
@@ -504,7 +511,7 @@ function MobileGestureWrapper({
animateToOpen,
animateToClose,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
@@ -540,7 +547,7 @@ function MobileGestureWrapper({
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
stateManager.fail();
return;
}
@@ -586,11 +593,9 @@ function MobileGestureWrapper({
isGesturing.value = false;
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
}
})
@@ -602,7 +607,7 @@ function MobileGestureWrapper({
windowWidth,
translateX,
backdropOpacity,
mobileVisualPanel,
mobilePanelState,
animateToOpen,
animateToClose,
handleGestureOpen,

View File

@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveStartupBlocker,
resolveStartupNavigationReady,
resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection,
shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap,
WELCOME_ROUTE,
} from "./host-runtime-bootstrap";
@@ -117,6 +120,80 @@ describe("startHostRuntimeBootstrap", () => {
});
});
describe("startup blocking policy", () => {
const noBlockerInput = {
isDesktopRuntime: false,
anyOnlineHostServerId: null,
daemonStartIsRunning: false,
daemonStartError: null,
};
it("runs the give-up timer when no startup blocker is active", () => {
const blocker = resolveStartupBlocker(noBlockerInput);
expect(blocker).toEqual({ kind: "none" });
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
expect(
shouldRunStartupGiveUpTimer({
startupBlocker: blocker,
anyOnlineHostServerId: null,
hasGivenUpWaitingForHost: false,
}),
).toBe(true);
});
it("blocks navigation while desktop is starting the managed daemon", () => {
const blocker = resolveStartupBlocker({
...noBlockerInput,
isDesktopRuntime: true,
daemonStartIsRunning: true,
});
expect(blocker).toEqual({ kind: "managed-daemon-starting" });
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(false);
expect(
shouldRunStartupGiveUpTimer({
startupBlocker: blocker,
anyOnlineHostServerId: null,
hasGivenUpWaitingForHost: false,
}),
).toBe(false);
});
it("unblocks navigation when any host is online", () => {
const blocker = resolveStartupBlocker({
...noBlockerInput,
isDesktopRuntime: true,
anyOnlineHostServerId: "srv_desktop",
daemonStartIsRunning: true,
});
expect(blocker).toEqual({ kind: "none" });
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
});
it("keeps desktop daemon startup errors on the startup error surface", () => {
const blocker = resolveStartupBlocker({
...noBlockerInput,
isDesktopRuntime: true,
daemonStartError: "daemon failed to start",
});
expect(blocker).toEqual({
kind: "managed-daemon-error",
message: "daemon failed to start",
});
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
expect(
shouldRunStartupGiveUpTimer({
startupBlocker: blocker,
anyOnlineHostServerId: null,
hasGivenUpWaitingForHost: false,
}),
).toBe(false);
});
});
describe("resolveStartupRedirectRoute", () => {
const baseInput = {
pathname: "/",

View File

@@ -58,6 +58,56 @@ export function startDaemonIfGateAllows(input: {
export const WELCOME_ROUTE: Href = "/welcome";
export type StartupBlocker =
| { kind: "none" }
| { kind: "managed-daemon-starting" }
| { kind: "managed-daemon-error"; message: string };
export interface ResolveStartupBlockerInput {
isDesktopRuntime: boolean;
anyOnlineHostServerId: string | null;
daemonStartIsRunning: boolean;
daemonStartError: string | null;
}
export function resolveStartupBlocker(input: ResolveStartupBlockerInput): StartupBlocker {
if (!input.isDesktopRuntime) {
return { kind: "none" };
}
if (input.anyOnlineHostServerId) {
return { kind: "none" };
}
if (input.daemonStartError) {
return { kind: "managed-daemon-error", message: input.daemonStartError };
}
if (input.daemonStartIsRunning) {
return { kind: "managed-daemon-starting" };
}
return { kind: "none" };
}
export function resolveStartupNavigationReady(input: { startupBlocker: StartupBlocker }): boolean {
return input.startupBlocker.kind !== "managed-daemon-starting";
}
export function shouldRunStartupGiveUpTimer(input: {
startupBlocker: StartupBlocker;
anyOnlineHostServerId: string | null;
hasGivenUpWaitingForHost: boolean;
}): boolean {
if (input.anyOnlineHostServerId) {
return false;
}
if (input.hasGivenUpWaitingForHost) {
return false;
}
return input.startupBlocker.kind === "none";
}
export interface ResolveStartupRedirectInput {
pathname: string;
anyOnlineHostServerId: string | null;

View File

@@ -23,11 +23,8 @@ import {
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
@@ -68,7 +65,7 @@ export function ExplorerSidebar({
const { width: viewportWidth } = useWindowDimensions();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
@@ -166,7 +163,7 @@ export function ExplorerSidebar({
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) {
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
stateManager.fail();
return;
}
@@ -206,11 +203,9 @@ export function ExplorerSidebar({
windowWidth,
});
if (shouldClose) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
animateToOpen();
}
})
@@ -222,7 +217,7 @@ export function ExplorerSidebar({
windowWidth,
translateX,
backdropOpacity,
mobileVisualPanel,
mobilePanelState,
animateToOpen,
animateToClose,
handleCloseFromGesture,

View File

@@ -1,15 +1,12 @@
import type { ReactNode } from "react";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
const storeReady = useStoreReady();
const bootstrapState = useHostRuntimeBootstrapState();
const isDesktop = shouldUseDesktopDaemon();
if (!storeReady) {
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
if (bootstrapState.startupBlocker.kind !== "none") {
return <StartupSplashScreen bootstrapState={bootstrapState} />;
}
return children;

View File

@@ -40,11 +40,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_AGENT_LIST,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
@@ -64,6 +60,7 @@ import {
import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildHostOpenProjectRoute,
buildHostNewWorkspaceRoute,
@@ -581,7 +578,7 @@ function MobileSidebar({
animateToOpen,
animateToClose,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
closeGestureRef,
} = useSidebarAnimation();
@@ -645,7 +642,7 @@ function MobileSidebar({
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) {
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
stateManager.fail();
return;
}
@@ -679,11 +676,9 @@ function MobileSidebar({
isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
animateToOpen();
}
})
@@ -695,7 +690,7 @@ function MobileSidebar({
closeTouchStartX,
closeTouchStartY,
isGesturing,
mobileVisualPanel,
mobilePanelState,
windowWidth,
translateX,
backdropOpacity,

View File

@@ -11,12 +11,7 @@ import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_AGENT_LIST,
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
@@ -41,18 +36,8 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
null,
);
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_VISUAL_PANEL_AGENT_LIST;
}
if (mobileView === "file-explorer") {
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
}
return MOBILE_VISUAL_PANEL_AGENT;
}
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { mobileVisualPanel } = useSidebarAnimation();
const { startMobilePanelTransition, settleMobilePanel } = useSidebarAnimation();
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
@@ -84,6 +69,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
const previousMobileView = prevMobileView.current;
const ownsMobileViewChange =
previousMobileView === "file-explorer" || mobileView === "file-explorer";
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
@@ -102,17 +90,49 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
return;
}
if (isCompactLayout) {
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
}
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
if (isOpen) {
if (isCompactLayout) {
startMobilePanelTransition("file-explorer");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout) {
settleMobilePanel("file-explorer");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
if (isCompactLayout && mobileView === "agent") {
startMobilePanelTransition("agent");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
@@ -122,6 +142,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
}, [
isOpen,
mobileView,
@@ -130,32 +153,49 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
windowWidth,
isGesturing,
isCompactLayout,
mobileVisualPanel,
startMobilePanelTransition,
settleMobilePanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
startMobilePanelTransition("file-explorer");
translateX.value = withTiming(
0,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("file-explorer");
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity]);
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
const animateToClose = useCallback(() => {
"worklet";
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
startMobilePanelTransition("agent");
translateX.value = withTiming(
windowWidth,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth]);
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
const value = useMemo<ExplorerSidebarAnimationContextValue>(
() => ({

View File

@@ -15,6 +15,17 @@ import { isNative } from "@/constants/platform";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
MOBILE_PANEL_STATE_AGENT,
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
MOBILE_PANEL_TARGET_AGENT,
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
shouldSettleMobilePanelTransition,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
@@ -30,8 +41,11 @@ interface SidebarAnimationContextValue {
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
isGesturing: SharedValue<boolean>;
mobileVisualPanel: SharedValue<number>;
mobilePanelState: SharedValue<number>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
@@ -49,6 +63,16 @@ function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explore
return MOBILE_VISUAL_PANEL_AGENT;
}
function getSettledMobilePanelState(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
}
if (mobileView === "file-explorer") {
return MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
}
return MOBILE_PANEL_STATE_AGENT;
}
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
@@ -63,6 +87,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
const mobilePanelState = useSharedValue(getSettledMobilePanelState(mobileView));
const mobilePanelTarget = useSharedValue(getMobileVisualPanel(mobileView));
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
@@ -72,6 +98,71 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const prevMobileView = useRef(mobileView);
const prevWindowWidth = useRef(windowWidth);
const startMobilePanelTransition = useCallback(
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
"worklet";
if (nextMobileView === "agent-list") {
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT_LIST;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPENING;
return;
}
if (nextMobileView === "file-explorer") {
mobilePanelTarget.value = MOBILE_PANEL_TARGET_FILE_EXPLORER;
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING;
return;
}
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT;
if (mobilePanelState.value === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN) {
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING;
return;
}
if (mobilePanelState.value === MOBILE_PANEL_STATE_AGENT_LIST_OPEN) {
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_CLOSING;
return;
}
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
},
[mobilePanelState, mobilePanelTarget],
);
const settleMobilePanel = useCallback(
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
"worklet";
if (nextMobileView === "agent-list") {
if (
!shouldSettleMobilePanelTransition(
mobilePanelTarget.value,
MOBILE_PANEL_TARGET_AGENT_LIST,
)
) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
return;
}
if (nextMobileView === "file-explorer") {
if (
!shouldSettleMobilePanelTransition(
mobilePanelTarget.value,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
)
) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
return;
}
if (!shouldSettleMobilePanelTransition(mobilePanelTarget.value, MOBILE_PANEL_TARGET_AGENT)) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
},
[mobileVisualPanel, mobilePanelState, mobilePanelTarget],
);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
@@ -82,6 +173,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
const previousMobileView = prevMobileView.current;
const ownsMobileViewChange = previousMobileView === "agent-list" || mobileView === "agent-list";
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
@@ -108,17 +201,49 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
return;
}
if (isCompactLayout) {
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
}
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
if (isOpen) {
if (isCompactLayout) {
startMobilePanelTransition("agent-list");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout) {
settleMobilePanel("agent-list");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
if (isCompactLayout && mobileView === "agent") {
startMobilePanelTransition("agent");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
@@ -128,6 +253,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
}, [
isOpen,
mobileView,
@@ -137,31 +265,50 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
isGesturing,
isCompactLayout,
mobileVisualPanel,
mobilePanelState,
startMobilePanelTransition,
settleMobilePanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
startMobilePanelTransition("agent-list");
translateX.value = withTiming(
0,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent-list");
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity]);
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
const animateToClose = useCallback(() => {
"worklet";
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
startMobilePanelTransition("agent");
translateX.value = withTiming(
-windowWidth,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth]);
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
const value = useMemo<SidebarAnimationContextValue>(
() => ({
@@ -170,8 +317,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
windowWidth,
animateToOpen,
animateToClose,
startMobilePanelTransition,
settleMobilePanel,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
@@ -182,8 +332,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
windowWidth,
animateToOpen,
animateToClose,
startMobilePanelTransition,
settleMobilePanel,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,

View File

@@ -2,12 +2,9 @@ import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import {
MOBILE_VISUAL_PANEL_AGENT,
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { isWeb } from "@/constants/platform";
import { canOpenRightSidebarGesture } from "@/utils/sidebar-animation-state";
interface UseExplorerOpenGestureParams {
enabled: boolean;
@@ -28,7 +25,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
openGestureRef,
} = useExplorerSidebarAnimation();
const {
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef: mobilePanelGestureAnimatingRef,
openGestureRef: leftOpenGestureRef,
} = useSidebarAnimation();
@@ -68,7 +65,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
if (!canOpenRightSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
stateManager.fail();
return;
}
@@ -117,11 +114,9 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
const shouldOpenByVelocity = event.velocityX < -500;
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
if (shouldOpen) {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
animateToClose();
}
})
@@ -133,7 +128,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
windowWidth,
translateX,
backdropOpacity,
mobileVisualPanel,
mobilePanelState,
animateToOpen,
animateToClose,
isGesturing,

View File

@@ -655,21 +655,6 @@ function buildComposerConfig(input: {
};
}
function computeWorkspaceTitle(
workspace: ReturnType<typeof normalizeWorkspaceDescriptor> | null,
displayName: string,
sourceDirectory: string | null,
): string {
const fallbackDirectoryName = sourceDirectory?.split(/[\\/]/).findLast(Boolean) ?? null;
return (
workspace?.name ||
workspace?.projectDisplayName ||
displayName ||
fallbackDirectoryName ||
"Choose project"
);
}
function collectAttachedPrNumbers(attachments: ReadonlyArray<UserComposerAttachment>): Set<number> {
const numbers = new Set<number>();
for (const attachment of attachments) {
@@ -801,7 +786,6 @@ export function NewWorkspaceScreen({
projects,
selectedProject,
selectedSourceDirectory,
selectedDisplayName,
projectPickerOptions,
projectByOptionId,
selectedProjectOptionId,
@@ -1091,12 +1075,6 @@ export function NewWorkspaceScreen({
[composerState, draftKey, ensureWorkspace, serverId, toast],
);
const workspaceTitle = computeWorkspaceTitle(
workspace,
selectedDisplayName,
selectedSourceDirectory,
);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
@@ -1308,30 +1286,18 @@ export function NewWorkspaceScreen({
triggerLabel,
],
);
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<ScreenHeader
left={
<>
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={1}>
New workspace
</Text>
<Text style={styles.headerProjectTitle} numberOfLines={1}>
{workspaceTitle}
</Text>
</View>
</>
}
leftStyle={styles.headerLeft}
borderless
/>
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={contentStyle}>
<TitlebarDragRegion />
<View style={styles.centered}>
<View style={styles.composerTitleContainer}>
<Text style={styles.composerTitle}>New workspace</Text>
</View>
<Composer
agentId={draftKey}
serverId={serverId}
@@ -1385,29 +1351,15 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
headerLeft: {
gap: theme.spacing[2],
composerTitleContainer: {
marginBottom: theme.spacing[8],
paddingLeft: theme.spacing[6],
paddingRight: theme.spacing[4],
},
headerTitleContainer: {
flexShrink: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
headerTitle: {
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
composerTitle: {
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 0,
},
headerProjectTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
flexShrink: 1,
},
errorText: {
fontSize: theme.fontSize.sm,

View File

@@ -26,6 +26,7 @@ import {
RotateCw,
Rows2,
Globe,
Plus,
SquarePen,
SquareTerminal,
X,
@@ -48,6 +49,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import {
WorkspaceTabPresentationResolver,
@@ -66,6 +68,7 @@ import { RenderProfile } from "@/utils/render-profiler";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedX = withUnistyles(X);
@@ -80,6 +83,7 @@ const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedRows2 = withUnistyles(Rows2);
const ThemedPlus = withUnistyles(Plus);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -88,6 +92,51 @@ function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackTyp
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
}
function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
}
function updateMeasuredWidth(setWidth: Dispatch<SetStateAction<number>>, event: LayoutChangeEvent) {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
}
interface WorkspaceInlineAddTabButtonProps {
shortcutKeys: ShortcutKey[][] | null;
onPress: () => void;
onLayout: (event: LayoutChangeEvent) => void;
}
function WorkspaceInlineAddTabButton({
shortcutKeys,
onPress,
onLayout,
}: WorkspaceInlineAddTabButtonProps) {
return (
<View style={styles.inlineAddButton} onLayout={onLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab-inline"
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="New agent tab"
style={inlineAddActionButtonStyle}
>
<ThemedPlus size={16} uniProps={mutedColorMapping} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
{shortcutKeys ? (
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
</View>
);
}
function TabContextMenuItem({
entry,
}: {
@@ -490,21 +539,27 @@ export function WorkspaceDesktopTabsRow({
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsContainerWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
updateMeasuredWidth(setTabsContainerWidth, event);
}, []);
const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
setTabsActionsWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
updateMeasuredWidth(setTabsActionsWidth, event);
}, []);
const handleInlineAddButtonLayout = useCallback((event: LayoutChangeEvent) => {
updateMeasuredWidth(setInlineAddButtonWidth, event);
}, []);
const layoutMetrics = useMemo(
() => ({
rowHorizontalInset: 0,
actionsReservedWidth: Math.max(0, tabsActionsWidth),
actionsReservedWidth: Math.max(
0,
tabsActionsWidth + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH),
),
rowPaddingHorizontal: 0,
tabGap: 0,
maxTabWidth: 200,
@@ -513,7 +568,7 @@ export function WorkspaceDesktopTabsRow({
estimatedCharWidth: 7,
closeButtonWidth: 22,
}),
[tabsActionsWidth],
[inlineAddButtonWidth, tabsActionsWidth],
);
const tabLabelLengths = useMemo(
@@ -672,6 +727,11 @@ export function WorkspaceDesktopTabsRow({
getItemData={getTabDragData}
renderItem={renderTab}
/>
<WorkspaceInlineAddTabButton
shortcutKeys={newTabKeys}
onPress={handleCreateAgentTab}
onLayout={handleInlineAddButtonLayout}
/>
</ScrollView>
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -928,6 +988,11 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
paddingHorizontal: theme.spacing[2],
},
inlineAddButton: {
paddingHorizontal: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
},
tab: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -1028,6 +1093,13 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
inlineAddActionButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
newTabActionButtonDisabled: {
opacity: 0.5,
},

View File

@@ -1,7 +1,22 @@
import { describe, expect, it } from "vitest";
import {
canCloseLeftSidebarGesture,
canCloseRightSidebarGesture,
canOpenLeftSidebarGesture,
canOpenRightSidebarGesture,
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
MOBILE_PANEL_STATE_AGENT,
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
MOBILE_PANEL_TARGET_AGENT,
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
shouldSettleMobilePanelTransition,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
@@ -41,4 +56,56 @@ describe("sidebar-animation-state", () => {
backdropOpacity: 0,
});
});
it("allows the left open gesture only after the app is settled on the agent panel", () => {
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -430, 430)).toBe(true);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -240, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING, -430, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING, -430, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN, -430, 430)).toBe(false);
});
it("allows the left close gesture only while the left sidebar is settled open", () => {
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN)).toBe(true);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING)).toBe(false);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING)).toBe(false);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
});
it("allows the right open gesture only after the app is settled on the agent panel", () => {
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 430, 430)).toBe(true);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 240, 430)).toBe(false);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING, 430, 430)).toBe(
false,
);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING, 430, 430)).toBe(
false,
);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN, 430, 430)).toBe(false);
});
it("allows the right close gesture only while the right sidebar is settled open", () => {
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN)).toBe(true);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING)).toBe(false);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING)).toBe(false);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
});
it("rejects stale settle callbacks from a panel that is no longer the destination", () => {
expect(
shouldSettleMobilePanelTransition(
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_AGENT_LIST,
),
).toBe(true);
expect(
shouldSettleMobilePanelTransition(MOBILE_PANEL_TARGET_AGENT_LIST, MOBILE_PANEL_TARGET_AGENT),
).toBe(false);
expect(
shouldSettleMobilePanelTransition(
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
),
).toBe(false);
});
});

View File

@@ -15,6 +15,20 @@ interface SidebarAnimationTargets {
backdropOpacity: number;
}
export const MOBILE_PANEL_STATE_AGENT = 0;
export const MOBILE_PANEL_STATE_AGENT_LIST_OPENING = 1;
export const MOBILE_PANEL_STATE_AGENT_LIST_OPEN = 2;
export const MOBILE_PANEL_STATE_AGENT_LIST_CLOSING = 3;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING = 4;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN = 5;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING = 6;
export const MOBILE_PANEL_TARGET_AGENT = 0;
export const MOBILE_PANEL_TARGET_AGENT_LIST = 1;
export const MOBILE_PANEL_TARGET_FILE_EXPLORER = 2;
const CLOSED_POSITION_TOLERANCE = 1;
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth
@@ -38,3 +52,45 @@ export function getRightSidebarAnimationTargets(
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function canOpenLeftSidebarGesture(
mobilePanelState: number,
translateX: number,
windowWidth: number,
): boolean {
"worklet";
return (
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
translateX <= -windowWidth + CLOSED_POSITION_TOLERANCE
);
}
export function canCloseLeftSidebarGesture(mobilePanelState: number): boolean {
"worklet";
return mobilePanelState === MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
}
export function canOpenRightSidebarGesture(
mobilePanelState: number,
translateX: number,
windowWidth: number,
): boolean {
"worklet";
return (
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
translateX >= windowWidth - CLOSED_POSITION_TOLERANCE
);
}
export function canCloseRightSidebarGesture(mobilePanelState: number): boolean {
"worklet";
return mobilePanelState === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
}
export function shouldSettleMobilePanelTransition(
activeTarget: number,
settledTarget: number,
): boolean {
"worklet";
return activeTarget === settledTarget;
}

View File

@@ -2,6 +2,7 @@
"worktree": {
"setup": [
"npm ci",
"node ./scripts/seed-ios-native-cache.mjs",
"cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" ./scripts/dev-home.sh",
"npm run build:server",
"npm run build --workspace=@getpaseo/expo-two-way-audio",
@@ -20,6 +21,10 @@
"desktop": {
"type": "service",
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT npm run dev --workspace=@getpaseo/desktop"
},
"ios-simulator": {
"type": "service",
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} ./scripts/paseo-ios-simulator-service.sh"
}
}
}

View File

@@ -0,0 +1,356 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { closeSync, existsSync, mkdirSync, openSync, readdirSync } from "node:fs";
import { basename, dirname, join, resolve as resolvePath } from "node:path";
import { spawn, spawnSync } from "node:child_process";
const rootDir = resolvePath(import.meta.dirname, "..");
const appDir = join(rootDir, "packages/app");
const appProductName = "PaseoDebug";
const appScheme = "paseo";
const preferredSimulatorType = process.env.PASEO_IOS_DEVICE_TYPE || "iPhone 16 Pro";
const paseoPort = requiredEnv("PASEO_PORT");
const worktreePath = process.env.PASEO_WORKTREE_PATH || rootDir;
const worktreeName = process.env.PASEO_BRANCH_NAME || basename(worktreePath);
const worktreeHash = createHash("sha1").update(worktreePath).digest("hex").slice(0, 8);
const simulatorName =
process.env.PASEO_IOS_SIMULATOR_NAME || `Paseo ${worktreeName} ${worktreeHash}`;
const daemonEndpoint =
process.env.PASEO_DEV_DAEMON_ENDPOINT ||
`localhost:${process.env.PASEO_SERVICE_DAEMON_PORT || "6768"}`;
const env = {
...process.env,
PATH: `${join(rootDir, "node_modules/.bin")}:${process.env.PATH || ""}`,
APP_VARIANT: "development",
CI: process.env.CI || "1",
EXPO_PUBLIC_LOCAL_DAEMON: daemonEndpoint,
};
const nativeBuildLog = join(rootDir, ".dev", "ios-build", `${simulatorSlug()}.log`);
let simulatorUdid = "";
let metro;
let shuttingDown = false;
let simulatorVisibilityGuard;
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exitCode = 1;
void cleanup();
});
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
async function main() {
startSimulatorVisibilityGuard();
simulatorUdid = findSimulator(simulatorName) || createSimulator(simulatorName);
hideNativeSimulatorApp();
bootSimulator(simulatorUdid);
hideNativeSimulatorApp();
run("npx", ["serve-sim", "--detach", "-q", simulatorUdid], { cwd: rootDir });
hideNativeSimulatorApp();
metro = startMetro();
await waitForUrl(`http://127.0.0.1:${paseoPort}/.sim`);
console.log(`iOS preview: ${process.env.PASEO_URL || `http://127.0.0.1:${paseoPort}`}/.sim`);
console.log("Building app dependencies...");
try {
run("npm", ["--prefix", rootDir, "run", "build:client"], { cwd: rootDir });
console.log("Generating iOS project...");
run("npx", ["expo", "prebuild", "--platform", "ios", "--non-interactive"], {
cwd: appDir,
});
const nativeProject = getNativeProject();
console.log(`Building iOS app (${nativeProject.scheme}); log: ${nativeBuildLog}`);
buildApp(nativeProject);
console.log("Installing iOS app...");
installApp(nativeProject);
console.log("Launching iOS app...");
launchApp();
hideNativeSimulatorApp();
} catch (error) {
process.exitCode = 1;
console.error("iOS app build/install failed; leaving preview running for manual QA.");
console.error(error instanceof Error ? error.message : error);
}
await waitForExit(metro);
await cleanup();
}
function buildApp(nativeProject) {
run(
"xcodebuild",
[
...nativeProject.args,
"-scheme",
nativeProject.scheme,
"-configuration",
"Debug",
"-destination",
`id=${simulatorUdid}`,
"-derivedDataPath",
nativeProject.derivedDataPath,
"build",
],
{ cwd: appDir, logFile: nativeBuildLog },
);
}
function installApp(nativeProject) {
const appPath = findBuiltApp(nativeProject.derivedDataPath);
run("xcrun", ["simctl", "install", simulatorUdid, appPath], { cwd: appDir });
}
function launchApp() {
const metroUrl = encodeURIComponent(`http://127.0.0.1:${paseoPort}`);
run(
"xcrun",
["simctl", "openurl", simulatorUdid, `${appScheme}://expo-development-client/?url=${metroUrl}`],
{ cwd: appDir },
);
}
function startMetro() {
const child = spawn("npx", ["expo", "start", "--port", paseoPort, "--localhost"], {
cwd: appDir,
env: {
...env,
PASEO_SERVE_SIM_PREVIEW: "1",
PASEO_SERVE_SIM_DEVICE_UDID: simulatorUdid,
BROWSER: "none",
},
stdio: "inherit",
});
child.on("exit", (code, signal) => {
if (!shuttingDown && code !== 0) {
console.error(`Metro exited with ${signal || code}`);
process.exitCode = code || 1;
}
});
return child;
}
function getNativeProject() {
const workspace = firstPath(join(appDir, "ios"), (name) => name.endsWith(".xcworkspace"));
const project = firstPath(join(appDir, "ios"), (name) => name.endsWith(".xcodeproj"));
const projectFile = workspace || project;
if (!projectFile) {
throw new Error("Expo prebuild did not create an iOS workspace or project.");
}
return {
args: workspace ? ["-workspace", workspace] : ["-project", projectFile],
scheme: getScheme(workspace ? ["-workspace", workspace] : ["-project", projectFile]),
derivedDataPath: join(rootDir, ".dev", "ios-build", simulatorSlug()),
};
}
function getScheme(projectArgs) {
const output = run("xcodebuild", [...projectArgs, "-list", "-json"], {
cwd: appDir,
capture: true,
});
const list = parseJson(output);
const schemes = list.workspace?.schemes || list.project?.schemes || [];
const scheme = schemes.find((value) => value === appProductName) || schemes[0];
if (!scheme) throw new Error("No iOS scheme found after Expo prebuild.");
return scheme;
}
function findBuiltApp(derivedDataPath) {
const productsDir = join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator");
const appPath = join(productsDir, `${appProductName}.app`);
if (existsSync(appPath)) return appPath;
const fallback = firstPath(productsDir, (name) => name.endsWith(".app"));
if (fallback) return fallback;
throw new Error(`Built app was not found in ${productsDir}`);
}
function findSimulator(name) {
const output = parseJson(run("xcrun", ["simctl", "list", "devices", "-j"], { capture: true }));
for (const devices of Object.values(output.devices || {})) {
for (const device of devices) {
if (device.name === name && device.isAvailable !== false) {
return device.udid;
}
}
}
return null;
}
function createSimulator(name) {
return run("xcrun", ["simctl", "create", name, resolveSimulatorType()], {
capture: true,
}).trim();
}
function resolveSimulatorType() {
const output = parseJson(
run("xcrun", ["simctl", "list", "devicetypes", "-j"], {
capture: true,
}),
);
const types = output.devicetypes || [];
const preferred = types.find((type) => type.name === preferredSimulatorType);
if (preferred) return preferred.identifier;
const fallback =
types.find((type) => /^iPhone .* Pro$/.test(type.name)) ||
types.find((type) => /^iPhone\b/.test(type.name));
if (!fallback) throw new Error("No iPhone simulator device type is installed.");
return fallback.identifier;
}
function bootSimulator(udid) {
spawnSync("xcrun", ["simctl", "boot", udid], { stdio: "ignore" });
waitForBootedSimulator(udid);
}
function waitForBootedSimulator(udid) {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
hideNativeSimulatorApp();
const output = parseJson(run("xcrun", ["simctl", "list", "devices", "-j"], { capture: true }));
for (const devices of Object.values(output.devices || {})) {
for (const device of devices) {
if (device.udid === udid && device.state === "Booted") return;
}
}
spawnSync("sleep", ["1"]);
}
throw new Error(`Timed out waiting for simulator ${udid} to boot.`);
}
async function cleanup() {
if (shuttingDown) return;
shuttingDown = true;
stopSimulatorVisibilityGuard();
if (metro && !metro.killed) {
metro.kill("SIGTERM");
}
if (simulatorUdid) {
spawnSync("npx", ["serve-sim", "--kill", simulatorUdid], {
cwd: rootDir,
stdio: "ignore",
env,
});
spawnSync("xcrun", ["simctl", "shutdown", simulatorUdid], { stdio: "ignore" });
}
}
async function shutdown() {
await cleanup();
process.exit();
}
function run(command, args, options = {}) {
if (options.logFile) {
return runWithLogFile(command, args, options);
}
const stdio = options.capture || options.logFile ? ["ignore", "pipe", "pipe"] : "inherit";
const result = spawnSync(command, args, {
cwd: options.cwd || rootDir,
env,
encoding: "utf8",
stdio,
});
if (result.status !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
throw new Error(`${command} ${args.join(" ")} failed${output ? `\n${output}` : ""}`);
}
return result.stdout || "";
}
function runWithLogFile(command, args, options) {
mkdirSync(dirname(options.logFile), { recursive: true });
const logFile = openSync(options.logFile, "w");
try {
const result = spawnSync(command, args, {
cwd: options.cwd || rootDir,
env,
encoding: "utf8",
stdio: ["ignore", logFile, logFile],
});
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed; see ${options.logFile}`);
}
return "";
} finally {
closeSync(logFile);
}
}
function hideNativeSimulatorApp() {
spawnSync(
"osascript",
[
"-e",
'tell application "System Events"',
"-e",
'if exists application process "Simulator" then set visible of application process "Simulator" to false',
"-e",
"end tell",
],
{ stdio: "ignore", timeout: 2_000 },
);
}
function startSimulatorVisibilityGuard() {
hideNativeSimulatorApp();
simulatorVisibilityGuard = setInterval(hideNativeSimulatorApp, 250);
simulatorVisibilityGuard.unref?.();
}
function stopSimulatorVisibilityGuard() {
if (simulatorVisibilityGuard) {
clearInterval(simulatorVisibilityGuard);
simulatorVisibilityGuard = undefined;
}
}
function parseJson(value) {
try {
return JSON.parse(value);
} catch (error) {
throw new Error(`Could not parse JSON: ${error instanceof Error ? error.message : error}`, {
cause: error,
});
}
}
function firstPath(dir, predicate) {
try {
const name = readdirSync(dir).find(predicate);
return name ? join(dir, name) : null;
} catch {
return null;
}
}
function simulatorSlug() {
return `${simulatorName.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}-${worktreeHash}`;
}
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`${name} is required; run this as a Paseo service.`);
return value;
}
async function waitForUrl(url) {
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {}
await new Promise((done) => setTimeout(done, 1000));
}
throw new Error(`Timed out waiting for ${url}`);
}
async function waitForExit(child) {
return new Promise((done) => child.on("exit", done));
}

View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec node "$SCRIPT_DIR/paseo-ios-simulator-service.mjs"

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
import { basename, join } from "node:path";
const sourceRoot = process.env.PASEO_SOURCE_CHECKOUT_PATH;
const targetRoot = process.env.PASEO_WORKTREE_PATH || process.cwd();
if (process.env.PASEO_SKIP_IOS_NATIVE_CACHE === "1") {
process.exit(0);
}
if (!sourceRoot || sourceRoot === targetRoot) {
process.exit(0);
}
seedDirectory({
label: "iOS project",
source: join(sourceRoot, "packages/app/ios"),
target: join(targetRoot, "packages/app/ios"),
});
seedDirectory({
label: "iOS derived data",
source: newestDirectory(join(sourceRoot, ".dev/ios-build")),
target: join(targetRoot, ".dev/ios-build", simulatorSlug()),
});
function seedDirectory({ label, source, target }) {
if (!source || !existsSync(source) || existsSync(target)) {
return;
}
try {
mkdirSync(join(target, ".."), { recursive: true });
cpSync(source, target, {
recursive: true,
preserveTimestamps: true,
errorOnExist: false,
force: false,
});
console.log(`Seeded ${label} cache from ${source}`);
} catch (error) {
console.warn(`Skipping ${label} cache seed: ${error instanceof Error ? error.message : error}`);
}
}
function newestDirectory(parent) {
try {
return readdirSync(parent)
.map((name) => join(parent, name))
.filter((path) => statSync(path).isDirectory())
.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs)[0];
} catch {
return null;
}
}
function simulatorSlug() {
const worktreeName = process.env.PASEO_BRANCH_NAME || basename(targetRoot);
const worktreeHash = createHash("sha1").update(targetRoot).digest("hex").slice(0, 8);
const simulatorName = `Paseo ${worktreeName} ${worktreeHash}`;
return `${simulatorName.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}-${worktreeHash}`;
}