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:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "/",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
() => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user