Files
paseo/packages/app/src/components/left-sidebar.tsx
T
Mohamed BoudraandGitHub 50ed0d0ab1 Fix desktop layout and window controls at half-screen width (#1983)
* fix(app): preserve desktop layout in half-screen windows

Treat native window controls as physical corner obstructions instead of deriving clearance from compact layout state. Keep 751px windows in desktop mode while preserving usable content when persisted sidebars are wide.

* fix(app): retain sidebar width across window resizes

* fix(app): yield app sidebar to narrow settings

* test(app): wait for mobile sidebar motion to settle

* fix(app): yield navigation to narrow workspace explorer

* fix(app): mark sidebar sizing helpers as worklets

* fix(app): hide app navigation when space is constrained
2026-07-14 23:01:14 +02:00

1027 lines
30 KiB
TypeScript

import { router, usePathname } from "expo-router";
import {
CalendarClock,
FolderPlus,
History,
Home,
Plus,
Search,
Server,
Settings,
X,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
Pressable,
StyleSheet as RNStyleSheet,
Text,
useWindowDimensions,
View,
type PressableStateCallbackType,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { 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 { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
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 { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHosts } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
buildSchedulesRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsHostSectionRoute,
buildSettingsRoute,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
interface SidebarSharedProps {
theme: SidebarTheme;
statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
groupMode: SidebarGroupMode;
collapsedProjectKeys: ReadonlySet<string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
toggleProjectCollapsed: (projectKey: string) => void;
handleRefresh: () => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
labels: SidebarLabels;
newWorkspaceKeys: ShortcutKey[][] | null;
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}
interface SidebarLabels {
addProject: string;
newWorkspace: string;
hosts: string;
home: string;
settings: string;
searchHosts: string;
sessions: string;
schedules: string;
closeSidebar: string;
}
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
handleViewSchedulesNavigate: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
isOpen: boolean;
handleViewMore: () => void;
handleViewSchedules: () => void;
}
export const LeftSidebar = memo(function LeftSidebar() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const {
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
refreshAll,
statusGroups,
pinnedGroups,
collapsedProjectKeys,
toggleProjectCollapsed,
groupMode,
shortcutModel,
} = useSidebarModel();
const { shortcutIndexByWorkspaceKey } = shortcutModel;
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
refreshAll();
}, [refreshAll]);
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker();
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 handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
showMobileAgent();
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
},
[showMobileAgent],
);
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
}, []);
const handleHomeMobile = useCallback(() => {
showMobileAgent();
router.push(buildOpenProjectRoute());
}, [showMobileAgent]);
const handleHomeDesktop = useCallback(() => {
router.push(buildOpenProjectRoute());
}, []);
const handleViewMoreNavigate = useCallback(() => {
router.push(buildSessionsRoute());
}, []);
const handleViewSchedulesNavigate = useCallback(() => {
router.push(buildSchedulesRoute());
}, []);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
newWorkspace: t("sidebar.actions.newWorkspace"),
hosts: t("sidebar.actions.hosts"),
home: t("sidebar.actions.home"),
settings: t("sidebar.actions.settings"),
searchHosts: t("sidebar.host.searchPlaceholder"),
sessions: t("sidebar.sections.sessions"),
schedules: t("sidebar.sections.schedules"),
closeSidebar: t("sidebar.actions.closeSidebar"),
}),
[t],
);
const sharedProps = {
theme,
statusGroups,
pinnedGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
labels,
newWorkspaceKeys,
};
if (isCompactLayout) {
return (
<RetainedPanelActivity active={isOpen}>
<MobileSidebar
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
);
}
return (
<RetainedPanelActivity active={isOpen}>
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
handleViewSchedules={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
);
});
function sidebarHostOptionTestID(serverId: string): string {
return `sidebar-host-row-${serverId}`;
}
function FooterIconButton({
buttonRef,
onPress,
testID,
label,
icon: Icon,
iconSize,
shortcutKeys,
theme,
}: {
onPress: () => void;
testID: string;
label: string;
icon: typeof FolderPlus;
iconSize?: number;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
collapsable={false}
accessible
accessibilityLabel={label}
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => (
<Icon
size={iconSize ?? theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
);
}
function SidebarHostPicker({
theme,
label,
onAddHost,
onOpenHostSettings,
}: {
theme: SidebarTheme;
label: string;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}) {
const hosts = useHosts();
const triggerRef = useRef<View | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleSelect = useCallback(
(id: string) => {
onOpenHostSettings(id);
},
[onOpenHostSettings],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
return (
<HostPicker
hosts={hosts}
value=""
onSelect={handleSelect}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showActiveConnection
onOpenHostSettings={onOpenHostSettings}
searchable
desktopPlacement="top-start"
desktopMinWidth={240}
addHostTestID="sidebar-host-add"
hostOptionTestID={sidebarHostOptionTestID}
>
<FooterIconButton
buttonRef={triggerRef}
onPress={handleOpen}
testID="sidebar-hosts-trigger"
label={label}
icon={Server}
iconSize={theme.iconSize.sm}
theme={theme}
/>
</HostPicker>
);
}
function IconTooltipContent({
label,
shortcutKeys,
}: {
label: string;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
);
}
const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({
label,
testID,
variant,
shortcutKeys,
onBeforeNavigate,
}: {
label: string;
testID: string;
variant: "header" | "compact";
shortcutKeys: ShortcutKey[][] | null;
onBeforeNavigate?: () => void;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
const supportsWorkspaceMultiplicity = useHostFeature(
activeWorkspaceServerId,
"workspaceMultiplicity",
);
const canUseActiveWorkspaceContext = Boolean(
activeWorkspace &&
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
);
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(
activeWorkspaceServerId
? buildNewWorkspaceRoute(
activeWorkspace && canUseActiveWorkspaceContext
? {
serverId: activeWorkspaceServerId,
sourceDirectory: activeWorkspace.projectRootPath,
projectId: activeWorkspace.projectId,
}
: { serverId: activeWorkspaceServerId },
)
: buildNewWorkspaceRoute(),
);
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={Plus}
label={label}
onPress={handlePress}
testID={testID}
variant={variant}
shortcutKeys={shortcutKeys}
/>
);
});
function SidebarFooter({
theme,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
}: {
theme: SidebarTheme;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
labels: {
addProject: string;
hosts: string;
home: string;
settings: string;
searchHosts: string;
};
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
return (
<View style={styles.sidebarFooter}>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<FooterIconButton
onPress={handleHome}
testID="sidebar-home"
label={labels.home}
icon={Home}
theme={theme}
/>
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
<SidebarHelpMenu />
</View>
</View>
);
}
function MobileSidebar({
theme,
statusGroups,
pinnedGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
newWorkspaceKeys,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
insetsBottom,
closeSidebar,
handleViewMoreNavigate,
handleViewSchedulesNavigate,
}: MobileSidebarProps) {
const pathname = usePathname();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
const handleViewMore = useCallback(() => {
closeSidebar();
handleViewMoreNavigate();
}, [closeSidebar, handleViewMoreNavigate]);
const handleViewSchedules = useCallback(() => {
closeSidebar();
handleViewSchedulesNavigate();
}, [closeSidebar, handleViewSchedulesNavigate]);
const handleWorkspacePress = useCallback(() => {
closeSidebar();
}, [closeSidebar]);
const mobileSidebarInsetStyle = useMemo(
() => ({
paddingTop: insetsTop,
paddingBottom: insetsBottom,
backgroundColor: theme.colors.surfaceSidebar,
}),
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
);
return (
<MobilePanelOverlay
panel="agent-list"
closeGesture={closeGesture}
panelStyle={mobileSidebarInsetStyle}
>
<View style={styles.sidebarContent} pointerEvents="auto">
<WindowChromeSafeArea placement="below" />
<View style={styles.sidebarHeaderGroup}>
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
onBeforeNavigate={closeSidebar}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
</View>
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={labels.closeSidebar}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={theme.iconSize.md}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</WindowChromeSafeArea>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusGroups={statusGroups}
pinnedGroups={pinnedGroups}
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
</View>
</MobilePanelOverlay>
);
}
function DesktopSidebar({
theme,
statusGroups,
pinnedGroups,
projects,
workspaceEntriesByKey,
projectNamesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
newWorkspaceKeys,
handleOpenProject,
handleHome,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
isOpen,
handleViewMore,
handleViewSchedules,
}: DesktopSidebarProps) {
const pathname = usePathname();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const visibleSidebarWidth = resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
});
const startWidthRef = useRef(visibleSidebarWidth);
const resizeWidth = useSharedValue(visibleSidebarWidth);
useEffect(() => {
resizeWidth.value = visibleSidebarWidth;
}, [resizeWidth, visibleSidebarWidth]);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onStart(() => {
startWidthRef.current = visibleSidebarWidth;
resizeWidth.value = visibleSidebarWidth;
})
.onUpdate((event) => {
// Dragging right (positive translationX) increases width
const newWidth = startWidthRef.current + event.translationX;
resizeWidth.value = resolveDesktopSidebarWidth({
requestedWidth: newWidth,
viewportWidth,
});
})
.onEnd(() => {
runOnJS(setSidebarWidth)(resizeWidth.value);
}),
[resizeWidth, setSidebarWidth, viewportWidth, visibleSidebarWidth],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
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 />
<WindowChromeSafeArea placement="below" />
<View style={styles.sidebarHeaderGroup}>
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
</View>
</View>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusGroups={statusGroups}
pinnedGroups={pinnedGroups}
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarCalloutSlot />
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
{/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}>
<View style={resizeHandleStyle} />
</GestureDetector>
</View>
</Animated.View>
);
}
function WorkspacesSectionHeader() {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
const searchButtonStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.workspacesHeaderIconButton,
(hovered || pressed) && styles.workspacesHeaderIconButtonHovered,
],
[],
);
return (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open command center"
testID="sidebar-command-center-search"
style={searchButtonStyle}
onPress={handleSearchPress}
>
{({ hovered, pressed }) => (
<Search
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>
</View>
);
}
// Stable element so the sidebar list's listHeaderComponent prop keeps identity across
// renders (WorkspacesSectionHeader takes no props).
const workspacesSectionHeaderElement = <WorkspacesSectionHeader />;
// 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({
desktopSidebar: {
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroup: {
paddingTop: theme.spacing[2],
gap: 2,
// Distance from History's bottom edge to the divider. WorkspacesSectionHeader
// uses a slightly smaller paddingTop to balance the action buttons' centering
// offset so the divider reads as visually centered between the two.
paddingBottom: theme.spacing[1.5],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
workspacesSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]), so the
// title lands at spacing[2] left to align with project icons, and the trailing
// pill sits flush with the list edge on the right.
paddingLeft: theme.spacing[2],
paddingRight: 0,
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[1],
},
workspacesSectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
workspacesSectionActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workspacesHeaderIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
workspacesHeaderIconButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
sidebarContent: {
flex: 1,
minHeight: 0,
},
mobileCloseButtonRow: {
position: "absolute",
top: theme.spacing[3],
left: 0,
right: 0,
zIndex: 2,
alignItems: "flex-end",
pointerEvents: "box-none",
},
mobileCloseButton: {
marginRight: theme.spacing[4],
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",
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
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],
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
}));