* fix(app): label sidebar footer actions * fix(app): translate hosts tooltip
1012 lines
30 KiB
TypeScript
1012 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 { HostPicker } from "@/components/hosts/host-picker";
|
|
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
|
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-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 { RetainedPanelActivity } from "@/components/retained-panel";
|
|
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
|
import { type SidebarGroupMode } 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 {
|
|
MAX_SIDEBAR_WIDTH,
|
|
MIN_SIDEBAR_WIDTH,
|
|
selectIsAgentListOpen,
|
|
usePanelStore,
|
|
} from "@/stores/panel-store";
|
|
import { useWindowControlsPadding } 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";
|
|
|
|
const MIN_CHAT_WIDTH = 400;
|
|
|
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
|
|
|
interface SidebarSharedProps {
|
|
theme: SidebarTheme;
|
|
statusGroups: StatusGroup[];
|
|
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,
|
|
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,
|
|
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}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function MobileSidebar({
|
|
theme,
|
|
statusGroups,
|
|
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 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">
|
|
<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>
|
|
<WorkspacesSectionHeader />
|
|
<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>
|
|
|
|
{isInitialLoad ? (
|
|
<SidebarAgentListSkeleton />
|
|
) : (
|
|
<SidebarWorkspaceList
|
|
collapsedProjectKeys={collapsedProjectKeys}
|
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
groupMode={groupMode}
|
|
statusGroups={statusGroups}
|
|
projects={projects}
|
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
|
projectNamesByKey={projectNamesByKey}
|
|
isRefreshing={isManualRefresh && isRevalidating}
|
|
onRefresh={handleRefresh}
|
|
onWorkspacePress={handleWorkspacePress}
|
|
onAddProject={handleOpenProject}
|
|
parentGestureRef={closeGestureRef}
|
|
/>
|
|
)}
|
|
|
|
<SidebarFooter
|
|
theme={theme}
|
|
handleOpenProject={handleOpenProject}
|
|
handleHome={handleHome}
|
|
handleSettings={handleSettings}
|
|
labels={labels}
|
|
handleAddHost={handleAddHost}
|
|
handleOpenHostSettings={handleOpenHostSettings}
|
|
/>
|
|
</View>
|
|
</MobilePanelOverlay>
|
|
);
|
|
}
|
|
|
|
function DesktopSidebar({
|
|
theme,
|
|
statusGroups,
|
|
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 isSessionsActive = pathname.includes("/sessions");
|
|
const isSchedulesActive = pathname.includes("/schedules");
|
|
const padding = useWindowControlsPadding("sidebar");
|
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
|
const { width: viewportWidth } = useWindowDimensions();
|
|
|
|
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}
|
|
<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>
|
|
<WorkspacesSectionHeader />
|
|
|
|
{isInitialLoad ? (
|
|
<SidebarAgentListSkeleton />
|
|
) : (
|
|
<SidebarWorkspaceList
|
|
collapsedProjectKeys={collapsedProjectKeys}
|
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
groupMode={groupMode}
|
|
statusGroups={statusGroups}
|
|
projects={projects}
|
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
|
projectNamesByKey={projectNamesByKey}
|
|
isRefreshing={isManualRefresh && isRevalidating}
|
|
onRefresh={handleRefresh}
|
|
onAddProject={handleOpenProject}
|
|
/>
|
|
)}
|
|
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// 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],
|
|
// Align the title with the compact rows' icons and the project icons below
|
|
// (listContent + projectRow inner padding both spacing[2]).
|
|
paddingLeft: theme.spacing[2] + theme.spacing[2],
|
|
// Align the trailing action pill's right edge with the New workspace and
|
|
// project row pills (both 8px from the sidebar edge).
|
|
paddingRight: theme.spacing[2],
|
|
// Less than sidebarHeaderGroup's paddingBottom: the 28px-tall action buttons
|
|
// center the title and add their own offset above it, so equal padding reads
|
|
// as a larger gap than History's. Trim paddingTop to balance it visually.
|
|
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,
|
|
},
|
|
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",
|
|
},
|
|
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,
|
|
},
|
|
}));
|