1030 lines
29 KiB
TypeScript
1030 lines
29 KiB
TypeScript
import { router, usePathname } from "expo-router";
|
|
import { FolderPlus, MessagesSquare, Settings, X } from "lucide-react-native";
|
|
import {
|
|
type Dispatch,
|
|
memo,
|
|
type ReactElement,
|
|
type RefObject,
|
|
type SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
Pressable,
|
|
StyleSheet as RNStyleSheet,
|
|
Text,
|
|
useWindowDimensions,
|
|
View,
|
|
type PressableStateCallbackType,
|
|
type StyleProp,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
import Animated, {
|
|
Extrapolation,
|
|
interpolate,
|
|
runOnJS,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
} from "react-native-reanimated";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
|
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 { 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";
|
|
import {
|
|
type SidebarProjectEntry,
|
|
useSidebarWorkspacesList,
|
|
} from "@/hooks/use-sidebar-workspaces-list";
|
|
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
|
import {
|
|
MAX_SIDEBAR_WIDTH,
|
|
MIN_SIDEBAR_WIDTH,
|
|
selectIsAgentListOpen,
|
|
usePanelStore,
|
|
} from "@/stores/panel-store";
|
|
import { resolveActiveHost } from "@/utils/active-host";
|
|
import { formatConnectionStatus } from "@/utils/daemons";
|
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
|
import {
|
|
buildHostSessionsRoute,
|
|
buildSettingsRoute,
|
|
mapPathnameToServer,
|
|
} from "@/utils/host-routes";
|
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
|
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
|
|
|
const MIN_CHAT_WIDTH = 400;
|
|
|
|
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
|
|
|
interface LeftSidebarProps {
|
|
selectedAgentId?: string;
|
|
}
|
|
|
|
interface SidebarSharedProps {
|
|
theme: SidebarTheme;
|
|
activeServerId: string | null;
|
|
activeHostLabel: string;
|
|
activeHostStatusColor: string;
|
|
hostOptions: ComboboxOption[];
|
|
hostTriggerRef: RefObject<View | null>;
|
|
isHostPickerOpen: boolean;
|
|
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
|
projects: SidebarProjectEntry[];
|
|
isInitialLoad: boolean;
|
|
isRevalidating: boolean;
|
|
isManualRefresh: boolean;
|
|
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
|
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
|
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
|
handleRefresh: () => void;
|
|
handleHostSelect: (nextServerId: string) => void;
|
|
handleOpenProject: () => void;
|
|
handleSettings: () => void;
|
|
renderHostOption: (input: {
|
|
option: ComboboxOption;
|
|
selected: boolean;
|
|
active: boolean;
|
|
onPress: () => void;
|
|
}) => ReactElement;
|
|
}
|
|
|
|
interface MobileSidebarProps extends SidebarSharedProps {
|
|
insetsTop: number;
|
|
insetsBottom: number;
|
|
isOpen: boolean;
|
|
closeToAgent: () => void;
|
|
handleViewMoreNavigate: () => void;
|
|
}
|
|
|
|
interface DesktopSidebarProps extends SidebarSharedProps {
|
|
insetsTop: number;
|
|
isOpen: boolean;
|
|
handleViewMore: () => void;
|
|
}
|
|
|
|
export const LeftSidebar = memo(function LeftSidebar({
|
|
selectedAgentId: _selectedAgentId,
|
|
}: LeftSidebarProps) {
|
|
void _selectedAgentId;
|
|
|
|
const { theme } = useUnistyles();
|
|
const insets = useSafeAreaInsets();
|
|
const isCompactLayout = useIsCompactFormFactor();
|
|
const isOpen = usePanelStore((state) =>
|
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
|
);
|
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
|
const pathname = usePathname();
|
|
const daemons = useHosts();
|
|
const activeDaemon = useMemo(
|
|
() => resolveActiveHost({ hosts: daemons, pathname }),
|
|
[daemons, pathname],
|
|
);
|
|
const activeServerId = activeDaemon?.serverId ?? null;
|
|
const activeHostLabel = useMemo(() => {
|
|
if (!activeDaemon) return "No host";
|
|
const trimmed = activeDaemon.label?.trim();
|
|
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
|
}, [activeDaemon]);
|
|
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
|
|
const activeHostStatus = activeServerId
|
|
? (activeHostSnapshot?.connectionStatus ?? "connecting")
|
|
: "idle";
|
|
let activeHostStatusColor: string;
|
|
if (activeHostStatus === "online") activeHostStatusColor = theme.colors.palette.green[400];
|
|
else if (activeHostStatus === "connecting")
|
|
activeHostStatusColor = theme.colors.palette.amber[500];
|
|
else activeHostStatusColor = theme.colors.palette.red[500];
|
|
const hostOptions = useMemo(
|
|
() =>
|
|
daemons.map((daemon) => ({
|
|
id: daemon.serverId,
|
|
label: daemon.label?.trim() || daemon.serverId,
|
|
})),
|
|
[daemons],
|
|
);
|
|
const renderHostOption = useCallback(
|
|
({
|
|
option,
|
|
selected,
|
|
active,
|
|
onPress,
|
|
}: {
|
|
option: ComboboxOption;
|
|
selected: boolean;
|
|
active: boolean;
|
|
onPress: () => void;
|
|
}) => (
|
|
<HostSwitchOption
|
|
serverId={option.id}
|
|
label={option.label}
|
|
selected={selected}
|
|
active={active}
|
|
onPress={onPress}
|
|
/>
|
|
),
|
|
[],
|
|
);
|
|
const hostTriggerRef = useRef<View | null>(null);
|
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
|
|
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
|
serverId: activeServerId,
|
|
enabled: isCompactLayout || isOpen,
|
|
});
|
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
|
useSidebarShortcutModel({ projects, isInitialLoad });
|
|
|
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
setIsManualRefresh(true);
|
|
refreshAll();
|
|
}, [refreshAll]);
|
|
|
|
useEffect(() => {
|
|
if (!isRevalidating && isManualRefresh) {
|
|
setIsManualRefresh(false);
|
|
}
|
|
}, [isRevalidating, isManualRefresh]);
|
|
|
|
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
|
|
|
const handleOpenProjectMobile = useCallback(() => {
|
|
showMobileAgent();
|
|
void openProjectPicker();
|
|
}, [showMobileAgent, openProjectPicker]);
|
|
|
|
const handleOpenProjectDesktop = useCallback(() => {
|
|
void openProjectPicker();
|
|
}, [openProjectPicker]);
|
|
|
|
const handleSettingsMobile = useCallback(() => {
|
|
showMobileAgent();
|
|
router.push(buildSettingsRoute());
|
|
}, [showMobileAgent]);
|
|
|
|
const handleSettingsDesktop = useCallback(() => {
|
|
router.push(buildSettingsRoute());
|
|
}, []);
|
|
|
|
const handleViewMoreNavigate = useCallback(() => {
|
|
if (!activeServerId) {
|
|
return;
|
|
}
|
|
router.push(buildHostSessionsRoute(activeServerId));
|
|
}, [activeServerId]);
|
|
|
|
const handleHostSelect = useCallback(
|
|
(nextServerId: string) => {
|
|
if (!nextServerId) {
|
|
return;
|
|
}
|
|
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
|
setIsHostPickerOpen(false);
|
|
router.push(nextPath);
|
|
},
|
|
[pathname],
|
|
);
|
|
|
|
const sharedProps = {
|
|
theme,
|
|
activeServerId,
|
|
activeHostLabel,
|
|
activeHostStatusColor,
|
|
hostOptions,
|
|
hostTriggerRef,
|
|
isHostPickerOpen,
|
|
setIsHostPickerOpen,
|
|
projects,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
isManualRefresh,
|
|
collapsedProjectKeys,
|
|
shortcutIndexByWorkspaceKey,
|
|
toggleProjectCollapsed,
|
|
handleRefresh,
|
|
handleHostSelect,
|
|
renderHostOption,
|
|
};
|
|
|
|
if (isCompactLayout) {
|
|
return (
|
|
<MobileSidebar
|
|
{...sharedProps}
|
|
insetsTop={insets.top}
|
|
insetsBottom={insets.bottom}
|
|
isOpen={isOpen}
|
|
closeToAgent={showMobileAgent}
|
|
handleOpenProject={handleOpenProjectMobile}
|
|
handleSettings={handleSettingsMobile}
|
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DesktopSidebar
|
|
{...sharedProps}
|
|
insetsTop={insets.top}
|
|
isOpen={isOpen}
|
|
handleOpenProject={handleOpenProjectDesktop}
|
|
handleSettings={handleSettingsDesktop}
|
|
handleViewMore={handleViewMoreNavigate}
|
|
/>
|
|
);
|
|
});
|
|
|
|
interface HostPickerTriggerProps {
|
|
triggerRef: React.Ref<View>;
|
|
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
|
hostOptionsEmpty: boolean;
|
|
hostStatusDotStyle: StyleProp<ViewStyle>;
|
|
activeHostLabel: string;
|
|
}
|
|
|
|
function HostPickerTrigger({
|
|
triggerRef,
|
|
setIsHostPickerOpen,
|
|
hostOptionsEmpty,
|
|
hostStatusDotStyle,
|
|
activeHostLabel,
|
|
}: HostPickerTriggerProps) {
|
|
const pressableStyle = useCallback(
|
|
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.hostTrigger,
|
|
hovered && styles.hostTriggerHovered,
|
|
],
|
|
[],
|
|
);
|
|
const handlePress = useCallback(() => setIsHostPickerOpen(true), [setIsHostPickerOpen]);
|
|
return (
|
|
<Pressable
|
|
ref={triggerRef}
|
|
style={pressableStyle}
|
|
onPress={handlePress}
|
|
disabled={hostOptionsEmpty}
|
|
>
|
|
<View style={hostStatusDotStyle} />
|
|
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
|
{activeHostLabel}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function HostSwitchOption({
|
|
serverId,
|
|
label,
|
|
selected,
|
|
active,
|
|
onPress,
|
|
}: {
|
|
serverId: string;
|
|
label: string;
|
|
selected: boolean;
|
|
active: boolean;
|
|
onPress: () => void;
|
|
}) {
|
|
const snapshot = useHostRuntimeSnapshot(serverId);
|
|
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
|
|
|
return (
|
|
<ComboboxItem
|
|
label={label}
|
|
description={formatConnectionStatus(connectionStatus)}
|
|
selected={selected}
|
|
active={active}
|
|
onPress={onPress}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function FooterIconButton({
|
|
onPress,
|
|
testID,
|
|
accessibilityLabel,
|
|
icon: Icon,
|
|
theme,
|
|
}: {
|
|
onPress: () => void;
|
|
testID: string;
|
|
accessibilityLabel: string;
|
|
icon: typeof FolderPlus;
|
|
theme: SidebarTheme;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
style={styles.footerIconButton}
|
|
testID={testID}
|
|
nativeID={testID}
|
|
collapsable={false}
|
|
accessible
|
|
accessibilityLabel={accessibilityLabel}
|
|
accessibilityRole="button"
|
|
onPress={onPress}
|
|
>
|
|
{({ hovered }) => (
|
|
<Icon
|
|
size={theme.iconSize.md}
|
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function AddProjectTooltipContent({
|
|
newAgentKeys,
|
|
}: {
|
|
newAgentKeys: ReturnType<typeof useShortcutKeys>;
|
|
}) {
|
|
return (
|
|
<View style={styles.tooltipRow}>
|
|
<Text style={styles.tooltipText}>Add project</Text>
|
|
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SidebarFooter({
|
|
theme,
|
|
activeServerId,
|
|
activeHostLabel,
|
|
hostStatusDotStyle,
|
|
hostOptions,
|
|
hostTriggerRef,
|
|
isHostPickerOpen,
|
|
setIsHostPickerOpen,
|
|
handleHostSelect,
|
|
renderHostOption,
|
|
handleOpenProject,
|
|
handleSettings,
|
|
}: {
|
|
theme: SidebarTheme;
|
|
activeServerId: string | null;
|
|
activeHostLabel: string;
|
|
hostStatusDotStyle: StyleProp<ViewStyle>;
|
|
hostOptions: ComboboxOption[];
|
|
hostTriggerRef: RefObject<View | null>;
|
|
isHostPickerOpen: boolean;
|
|
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
|
handleHostSelect: (nextServerId: string) => void;
|
|
renderHostOption: SidebarSharedProps["renderHostOption"];
|
|
handleOpenProject: () => void;
|
|
handleSettings: () => void;
|
|
}) {
|
|
const newAgentKeys = useShortcutKeys("new-agent");
|
|
return (
|
|
<View style={styles.sidebarFooter}>
|
|
<View style={styles.footerHostSlot}>
|
|
<HostPickerTrigger
|
|
triggerRef={hostTriggerRef}
|
|
setIsHostPickerOpen={setIsHostPickerOpen}
|
|
hostOptionsEmpty={hostOptions.length === 0}
|
|
hostStatusDotStyle={hostStatusDotStyle}
|
|
activeHostLabel={activeHostLabel}
|
|
/>
|
|
</View>
|
|
<View style={styles.footerIconRow}>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<FooterIconButton
|
|
onPress={handleOpenProject}
|
|
testID="sidebar-add-project"
|
|
accessibilityLabel="Add project"
|
|
icon={FolderPlus}
|
|
theme={theme}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" align="center" offset={8}>
|
|
<AddProjectTooltipContent newAgentKeys={newAgentKeys} />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<FooterIconButton
|
|
onPress={handleSettings}
|
|
testID="sidebar-settings"
|
|
accessibilityLabel="Settings"
|
|
icon={Settings}
|
|
theme={theme}
|
|
/>
|
|
</View>
|
|
<Combobox
|
|
options={hostOptions}
|
|
value={activeServerId ?? ""}
|
|
onSelect={handleHostSelect}
|
|
renderOption={renderHostOption}
|
|
searchable={false}
|
|
title="Switch host"
|
|
searchPlaceholder="Search hosts..."
|
|
desktopMinWidth={280}
|
|
open={isHostPickerOpen}
|
|
onOpenChange={setIsHostPickerOpen}
|
|
anchorRef={hostTriggerRef}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function MobileSidebar({
|
|
theme,
|
|
activeServerId,
|
|
activeHostLabel,
|
|
activeHostStatusColor,
|
|
hostOptions,
|
|
hostTriggerRef,
|
|
isHostPickerOpen,
|
|
setIsHostPickerOpen,
|
|
projects,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
isManualRefresh,
|
|
collapsedProjectKeys,
|
|
shortcutIndexByWorkspaceKey,
|
|
toggleProjectCollapsed,
|
|
handleRefresh,
|
|
handleHostSelect,
|
|
renderHostOption,
|
|
handleOpenProject,
|
|
handleSettings,
|
|
insetsTop,
|
|
insetsBottom,
|
|
isOpen,
|
|
closeToAgent,
|
|
handleViewMoreNavigate,
|
|
}: MobileSidebarProps) {
|
|
const pathname = usePathname();
|
|
const isSessionsActive = pathname.includes("/sessions");
|
|
const {
|
|
translateX,
|
|
backdropOpacity,
|
|
windowWidth,
|
|
animateToOpen,
|
|
animateToClose,
|
|
isGesturing,
|
|
gestureAnimatingRef,
|
|
closeGestureRef,
|
|
} = useSidebarAnimation();
|
|
const closeTouchStartX = useSharedValue(0);
|
|
const closeTouchStartY = useSharedValue(0);
|
|
|
|
const handleCloseFromGesture = useCallback(() => {
|
|
gestureAnimatingRef.current = true;
|
|
closeToAgent();
|
|
}, [closeToAgent, gestureAnimatingRef]);
|
|
|
|
const handleViewMore = useCallback(() => {
|
|
if (!activeServerId) {
|
|
return;
|
|
}
|
|
translateX.value = -windowWidth;
|
|
backdropOpacity.value = 0;
|
|
closeToAgent();
|
|
handleViewMoreNavigate();
|
|
}, [
|
|
activeServerId,
|
|
backdropOpacity,
|
|
closeToAgent,
|
|
handleViewMoreNavigate,
|
|
translateX,
|
|
windowWidth,
|
|
]);
|
|
|
|
const handleWorkspacePress = useCallback(() => {
|
|
closeToAgent();
|
|
}, [closeToAgent]);
|
|
|
|
const closeGesture = useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.withRef(closeGestureRef)
|
|
.enabled(isOpen)
|
|
.manualActivation(true)
|
|
.onTouchesDown((event) => {
|
|
const touch = event.changedTouches[0];
|
|
if (!touch) {
|
|
return;
|
|
}
|
|
closeTouchStartX.value = touch.absoluteX;
|
|
closeTouchStartY.value = touch.absoluteY;
|
|
})
|
|
.onTouchesMove((event, stateManager) => {
|
|
const touch = event.changedTouches[0];
|
|
if (!touch || event.numberOfTouches !== 1) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
|
|
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
|
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
|
const absDeltaX = Math.abs(deltaX);
|
|
const absDeltaY = Math.abs(deltaY);
|
|
|
|
if (deltaX >= 10) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
|
stateManager.activate();
|
|
}
|
|
})
|
|
.onStart(() => {
|
|
isGesturing.value = true;
|
|
})
|
|
.onUpdate((event) => {
|
|
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
|
|
translateX.value = newTranslateX;
|
|
backdropOpacity.value = interpolate(
|
|
newTranslateX,
|
|
[-windowWidth, 0],
|
|
[0, 1],
|
|
Extrapolation.CLAMP,
|
|
);
|
|
})
|
|
.onEnd((event) => {
|
|
isGesturing.value = false;
|
|
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
|
if (shouldClose) {
|
|
animateToClose();
|
|
runOnJS(handleCloseFromGesture)();
|
|
} else {
|
|
animateToOpen();
|
|
}
|
|
})
|
|
.onFinalize(() => {
|
|
isGesturing.value = false;
|
|
}),
|
|
[
|
|
isOpen,
|
|
closeGestureRef,
|
|
closeTouchStartX,
|
|
closeTouchStartY,
|
|
isGesturing,
|
|
windowWidth,
|
|
translateX,
|
|
backdropOpacity,
|
|
animateToClose,
|
|
animateToOpen,
|
|
handleCloseFromGesture,
|
|
],
|
|
);
|
|
|
|
const mobileSidebarInsetStyle = useMemo(
|
|
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
|
[windowWidth, insetsTop, insetsBottom],
|
|
);
|
|
|
|
const hostStatusDotStyle = useMemo(
|
|
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
|
[activeHostStatusColor],
|
|
);
|
|
|
|
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{ translateX: translateX.value }],
|
|
}));
|
|
|
|
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
|
opacity: backdropOpacity.value,
|
|
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
|
|
}));
|
|
|
|
let overlayPointerEvents: "auto" | "none" | "box-none";
|
|
if (!isWeb) overlayPointerEvents = "box-none";
|
|
else if (isOpen) overlayPointerEvents = "auto";
|
|
else overlayPointerEvents = "none";
|
|
|
|
const backdropStyle = useMemo(
|
|
() => [staticStyles.backdrop, backdropAnimatedStyle],
|
|
[backdropAnimatedStyle],
|
|
);
|
|
const mobileSidebarStyle = useMemo(
|
|
() => [
|
|
staticStyles.mobileSidebar,
|
|
mobileSidebarInsetStyle,
|
|
sidebarAnimatedStyle,
|
|
{ backgroundColor: theme.colors.surfaceSidebar },
|
|
],
|
|
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
|
);
|
|
|
|
return (
|
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
|
<Animated.View style={backdropStyle} />
|
|
|
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
|
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
|
<SidebarHeaderRow
|
|
icon={MessagesSquare}
|
|
label="Sessions"
|
|
onPress={handleViewMore}
|
|
isActive={isSessionsActive}
|
|
testID="sidebar-sessions"
|
|
/>
|
|
<Pressable
|
|
style={styles.mobileCloseButton}
|
|
onPress={closeToAgent}
|
|
testID="sidebar-close"
|
|
nativeID="sidebar-close"
|
|
accessible
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Close sidebar"
|
|
hitSlop={8}
|
|
>
|
|
{({ hovered, pressed }) => (
|
|
<X
|
|
size={theme.iconSize.md}
|
|
color={
|
|
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
|
}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
|
|
{isInitialLoad ? (
|
|
<SidebarAgentListSkeleton />
|
|
) : (
|
|
<SidebarWorkspaceList
|
|
serverId={activeServerId}
|
|
collapsedProjectKeys={collapsedProjectKeys}
|
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
projects={projects}
|
|
isRefreshing={isManualRefresh && isRevalidating}
|
|
onRefresh={handleRefresh}
|
|
onWorkspacePress={handleWorkspacePress}
|
|
onAddProject={handleOpenProject}
|
|
parentGestureRef={closeGestureRef}
|
|
/>
|
|
)}
|
|
|
|
<SidebarFooter
|
|
theme={theme}
|
|
activeServerId={activeServerId}
|
|
activeHostLabel={activeHostLabel}
|
|
hostStatusDotStyle={hostStatusDotStyle}
|
|
hostOptions={hostOptions}
|
|
hostTriggerRef={hostTriggerRef}
|
|
isHostPickerOpen={isHostPickerOpen}
|
|
setIsHostPickerOpen={setIsHostPickerOpen}
|
|
handleHostSelect={handleHostSelect}
|
|
renderHostOption={renderHostOption}
|
|
handleOpenProject={handleOpenProject}
|
|
handleSettings={handleSettings}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function DesktopSidebar({
|
|
theme,
|
|
activeServerId,
|
|
activeHostLabel,
|
|
activeHostStatusColor,
|
|
hostOptions,
|
|
hostTriggerRef,
|
|
isHostPickerOpen,
|
|
setIsHostPickerOpen,
|
|
projects,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
isManualRefresh,
|
|
collapsedProjectKeys,
|
|
shortcutIndexByWorkspaceKey,
|
|
toggleProjectCollapsed,
|
|
handleRefresh,
|
|
handleHostSelect,
|
|
renderHostOption,
|
|
handleOpenProject,
|
|
handleSettings,
|
|
insetsTop,
|
|
isOpen,
|
|
handleViewMore,
|
|
}: DesktopSidebarProps) {
|
|
const pathname = usePathname();
|
|
const isSessionsActive = pathname.includes("/sessions");
|
|
const padding = useWindowControlsPadding("sidebar");
|
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
|
const { width: viewportWidth } = useWindowDimensions();
|
|
const hostStatusDotStyle = useMemo(
|
|
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
|
[activeHostStatusColor],
|
|
);
|
|
|
|
const startWidthRef = useRef(sidebarWidth);
|
|
const resizeWidth = useSharedValue(sidebarWidth);
|
|
|
|
useEffect(() => {
|
|
resizeWidth.value = sidebarWidth;
|
|
}, [sidebarWidth, resizeWidth]);
|
|
|
|
const resizeGesture = useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
|
.onStart(() => {
|
|
startWidthRef.current = sidebarWidth;
|
|
resizeWidth.value = sidebarWidth;
|
|
})
|
|
.onUpdate((event) => {
|
|
// Dragging right (positive translationX) increases width
|
|
const newWidth = startWidthRef.current + event.translationX;
|
|
const maxWidth = Math.max(
|
|
MIN_SIDEBAR_WIDTH,
|
|
Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
|
);
|
|
const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
|
resizeWidth.value = clampedWidth;
|
|
})
|
|
.onEnd(() => {
|
|
runOnJS(setSidebarWidth)(resizeWidth.value);
|
|
}),
|
|
[sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth],
|
|
);
|
|
|
|
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
|
width: resizeWidth.value,
|
|
}));
|
|
|
|
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
|
const desktopSidebarStyle = useMemo(
|
|
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
|
|
[resizeAnimatedStyle],
|
|
);
|
|
const desktopSidebarBorderStyle = useMemo(
|
|
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
|
[insetsTop],
|
|
);
|
|
const resizeHandleStyle = useMemo(
|
|
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
|
[],
|
|
);
|
|
|
|
if (!isOpen) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Animated.View style={desktopSidebarStyle}>
|
|
<View style={desktopSidebarBorderStyle}>
|
|
<View style={styles.sidebarDragArea}>
|
|
<TitlebarDragRegion />
|
|
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
|
<SidebarHeaderRow
|
|
icon={MessagesSquare}
|
|
label="Sessions"
|
|
onPress={handleViewMore}
|
|
isActive={isSessionsActive}
|
|
testID="sidebar-sessions"
|
|
/>
|
|
</View>
|
|
|
|
{isInitialLoad ? (
|
|
<SidebarAgentListSkeleton />
|
|
) : (
|
|
<SidebarWorkspaceList
|
|
serverId={activeServerId}
|
|
collapsedProjectKeys={collapsedProjectKeys}
|
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
projects={projects}
|
|
isRefreshing={isManualRefresh && isRevalidating}
|
|
onRefresh={handleRefresh}
|
|
onAddProject={handleOpenProject}
|
|
/>
|
|
)}
|
|
|
|
<SidebarCalloutSlot />
|
|
|
|
<SidebarFooter
|
|
theme={theme}
|
|
activeServerId={activeServerId}
|
|
activeHostLabel={activeHostLabel}
|
|
hostStatusDotStyle={hostStatusDotStyle}
|
|
hostOptions={hostOptions}
|
|
hostTriggerRef={hostTriggerRef}
|
|
isHostPickerOpen={isHostPickerOpen}
|
|
setIsHostPickerOpen={setIsHostPickerOpen}
|
|
handleHostSelect={handleHostSelect}
|
|
renderHostOption={renderHostOption}
|
|
handleOpenProject={handleOpenProject}
|
|
handleSettings={handleSettings}
|
|
/>
|
|
|
|
{/* Resize handle - absolutely positioned over right border */}
|
|
<GestureDetector gesture={resizeGesture}>
|
|
<View style={resizeHandleStyle} />
|
|
</GestureDetector>
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
|
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
|
// tries to patch the native node that Reanimated also manages.
|
|
const staticStyles = RNStyleSheet.create({
|
|
backdrop: {
|
|
...RNStyleSheet.absoluteFillObject,
|
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
},
|
|
mobileSidebar: {
|
|
position: "absolute" as const,
|
|
top: 0,
|
|
left: 0,
|
|
bottom: 0,
|
|
overflow: "hidden" as const,
|
|
},
|
|
desktopSidebar: {
|
|
position: "relative" as const,
|
|
},
|
|
});
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
sidebarContent: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
mobileCloseButton: {
|
|
position: "absolute",
|
|
top: theme.spacing[3],
|
|
right: theme.spacing[4],
|
|
zIndex: 2,
|
|
width: 32,
|
|
height: 32,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderRadius: theme.borderRadius.lg,
|
|
backgroundColor: theme.colors.surfaceSidebar,
|
|
},
|
|
desktopSidebarBorder: {
|
|
borderRightWidth: 1,
|
|
borderRightColor: theme.colors.border,
|
|
backgroundColor: theme.colors.surfaceSidebar,
|
|
},
|
|
resizeHandle: {
|
|
position: "absolute",
|
|
right: -5,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 10,
|
|
zIndex: 10,
|
|
},
|
|
sidebarDragArea: {
|
|
position: "relative",
|
|
},
|
|
hostTrigger: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "flex-start",
|
|
gap: theme.spacing[2],
|
|
minWidth: 0,
|
|
paddingVertical: theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.lg,
|
|
},
|
|
hostTriggerHovered: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
hostStatusDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: theme.borderRadius.full,
|
|
},
|
|
hostTriggerText: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
},
|
|
sidebarFooter: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[3],
|
|
borderTopWidth: 1,
|
|
borderTopColor: theme.colors.border,
|
|
},
|
|
footerHostSlot: {
|
|
flexGrow: 0,
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
marginRight: theme.spacing[2],
|
|
},
|
|
footerIconRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
flexShrink: 0,
|
|
},
|
|
footerIconButton: {
|
|
width: 28,
|
|
height: 28,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
paddingVertical: theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[1],
|
|
},
|
|
hostPickerList: {
|
|
gap: theme.spacing[2],
|
|
},
|
|
hostPickerOption: {
|
|
paddingVertical: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.lg,
|
|
backgroundColor: theme.colors.surface2,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
},
|
|
hostPickerOptionText: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
hostPickerCancel: {
|
|
paddingVertical: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.lg,
|
|
backgroundColor: theme.colors.surface0,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.border,
|
|
alignItems: "center",
|
|
},
|
|
hostPickerCancelText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
tooltipRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
tooltipText: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.popoverForeground,
|
|
},
|
|
}));
|