* refactor: clear no-unnecessary-type-conversion + unbound-method (T3.b partial)
Fixes 15 files within the hard cap:
unbound-method (interface method shorthand → property function syntax):
- pane-context.tsx: PaneContextValue + PaneFocusContextValue (fixes agent-panel, browser-panel, draft-panel)
- provider-runner.ts: ProviderTurnRunner interface
- process-tree.ts: process.kill.bind(process)
no-unnecessary-type-conversion (remove redundant Boolean()/String() wraps):
- agent-timeline-store.ts, worktree-session.ts, pairing-qr.ts
- sherpa-realtime-session.ts, sherpa-stt.ts (String() → direct call chain)
- workspace-execution.ts, confirm-dialog.ts, use-archive-agent.ts
- use-agent-history.ts, left-sidebar.tsx, autocomplete.tsx, dropdown-menu.tsx
Deferred (9 errors across 7 files, all single-line Boolean(hovered) removals):
project-picker-modal.tsx, agent-list.tsx, branch-switcher.tsx,
file-explorer-pane.tsx, workspace-open-in-editor-button.tsx,
browser-pane.electron.tsx, sortable-inline-list.web.tsx
* fix(app): preserve Boolean coercion fallback in archive state lookups
readPendingState(...)[key] and (pendingQuery.data ?? {})[key] return
boolean | undefined at runtime — noUncheckedIndexedAccess is off so
TypeScript types this as boolean but the actual value can be undefined.
Add ?? false to maintain the explicit boolean return contract.
995 lines
28 KiB
TypeScript
995 lines
28 KiB
TypeScript
import { router, usePathname } from "expo-router";
|
|
import { FolderPlus, MessagesSquare, Settings } 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..."
|
|
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"
|
|
/>
|
|
|
|
{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, { paddingTop: insetsTop }],
|
|
[resizeAnimatedStyle, insetsTop],
|
|
);
|
|
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
|
|
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,
|
|
},
|
|
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,
|
|
},
|
|
}));
|