* feat(schedules): per-run workspaces, isolation and archive controls Scheduled agents never appeared in the sidebar: each schedule reused one stamped workspace and archived the run's agent on completion. Now every run mints its own workspace — the entity the sidebar shows — with per-schedule controls for isolation (local or worktree) and whether to archive that workspace when the run finishes. The schedule form is rebuilt on a non-React form model (open/commands/close) to end the flicker, edit-into-create contamination, and multi-host hydration bugs that came from effect-choreographed shared state. That lands the reusable foundations it needed: a form kit over a single control-geometry owner, a data-access taxonomy (replica/snapshot/fetch) with real load-state semantics, and lint/boundary guardrails so new screens inherit the paved road. Also fixes desktop combobox popovers truncating options when the trigger is narrower than the content. New schedule protocol fields are optional on both create and update, so old and new clients/daemons stay compatible. * test(app): complete mock themes for control-geometry tokens switch and terminal-profile-edit-modal now style through createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50], and theme.colors.borderAccent. The hand-rolled mock themes in these two suites lacked those tokens, so the eager StyleSheet.create mock threw at import (collection error), not on any assertion. Add the missing tokens. * fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration - Server: archive the run's workspace even when agent creation fails, so a misconfigured provider no longer orphans a sidebar workspace/worktree. - Edit form: keep a stored worktree isolation while worktree eligibility is still resolving, instead of silently downgrading a saved schedule to local. - Schedules screen: show the load error/retry UI when every host fails, rather than spinning forever behind the loading branch. - Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the sheet closes instead of re-opening. - Form model: apply late-loading saved preferences to untouched create-mode fields (never to edited schedules or user-modified fields). * fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety - Push-router: re-send terminal and checkout-diff subscriptions after a reconnect; the old per-hook effect resubscribed on isConnected and the extracted router did not, so daemon restarts silently stopped terminals_changed pushes (root cause of the terminal-activity e2e failures). - Server: pass the run's source repo root when archiving worktree runs so the worktree is unregistered (git worktree remove/prune), not just deleted; also archive the workspace when agent creation fails even with archive-off (an agentless workspace has nothing to inspect). - Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the Android native path too; replace the JSDOM component test with a real Playwright dismiss spec and inline the trivial dismiss decision. - Form model: editing an interval schedule no longer rewrites its cadence to cron unless the cadence UI is touched (90-minute intervals have no cron equivalent); switching projects clears stale provider/model state and gates submit until the new host's snapshot resolves. * chore(lint): drop custom lint wrapper and boundary script, plain oxlint The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports). The receiver-sensitive checks the custom script enforced are not worth a parallel lint pipeline; if they come back it will be as oxlint rules. * fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations - Persist the run's workspace/agent ids on the running-run record so a daemon crash mid-run can be recovered: restart archives the interrupted run's workspace under the same policy as normal cleanup (agentless always archives, otherwise archiveOnFinish is respected). - Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on hosts without workspaceMultiplicity — an old daemon ignores the fields, so offering the switch there would lie. - Optimistic pause/resume/delete no longer throw when the schedules cache holds a still-connecting entry alongside loaded data. - Mode field is gated on provider mode options, not a selected model, so model-less providers can pick their advertised modes.
1191 lines
35 KiB
TypeScript
1191 lines
35 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, {
|
|
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 { 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 { 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 { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
|
import { useHostFeature } from "@/runtime/host-features";
|
|
import {
|
|
type SidebarProjectEntry,
|
|
type SidebarStatusWorkspacePlacement,
|
|
useSidebarWorkspacesList,
|
|
} from "@/hooks/use-sidebar-workspaces-list";
|
|
import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-workspaces";
|
|
import { useSidebarViewStore, 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 { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
|
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 SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
|
|
|
interface LeftSidebarProps {
|
|
selectedAgentId?: string;
|
|
}
|
|
|
|
interface SidebarSharedProps {
|
|
theme: SidebarTheme;
|
|
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
|
|
projects: SidebarProjectEntry[];
|
|
projectNamesByKey: Map<string, string>;
|
|
isInitialLoad: boolean;
|
|
isRevalidating: boolean;
|
|
isManualRefresh: boolean;
|
|
groupMode: SidebarGroupMode;
|
|
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
|
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
|
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
|
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;
|
|
home: string;
|
|
settings: string;
|
|
switchHost: string;
|
|
searchHosts: string;
|
|
sessions: string;
|
|
schedules: string;
|
|
closeSidebar: string;
|
|
}
|
|
|
|
interface MobileSidebarProps extends SidebarSharedProps {
|
|
insetsTop: number;
|
|
insetsBottom: number;
|
|
isOpen: boolean;
|
|
closeSidebar: () => void;
|
|
handleViewMoreNavigate: () => void;
|
|
handleViewSchedulesNavigate: () => void;
|
|
}
|
|
|
|
interface DesktopSidebarProps extends SidebarSharedProps {
|
|
insetsTop: number;
|
|
isOpen: boolean;
|
|
handleViewMore: () => void;
|
|
handleViewSchedules: () => void;
|
|
}
|
|
|
|
export const LeftSidebar = memo(function LeftSidebar({
|
|
selectedAgentId: _selectedAgentId,
|
|
}: LeftSidebarProps) {
|
|
void _selectedAgentId;
|
|
|
|
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 {
|
|
workspacePlacements,
|
|
projects,
|
|
projectNamesByKey,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
refreshAll,
|
|
} = useSidebarWorkspacesList({
|
|
enabled: isCompactLayout || isOpen,
|
|
});
|
|
const statusWorkspacePlacements = useStatusModeWorkspacePlacements({
|
|
placements: workspacePlacements,
|
|
});
|
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
|
useSidebarShortcutModel({ projects });
|
|
|
|
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
|
|
|
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"),
|
|
home: t("sidebar.actions.home"),
|
|
settings: t("sidebar.actions.settings"),
|
|
switchHost: t("sidebar.host.switchTitle"),
|
|
searchHosts: t("sidebar.host.searchPlaceholder"),
|
|
sessions: t("sidebar.sections.sessions"),
|
|
schedules: t("sidebar.sections.schedules"),
|
|
closeSidebar: t("sidebar.actions.closeSidebar"),
|
|
}),
|
|
[t],
|
|
);
|
|
|
|
const sharedProps = {
|
|
theme,
|
|
statusWorkspacePlacements,
|
|
projects,
|
|
projectNamesByKey,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
isManualRefresh,
|
|
groupMode,
|
|
collapsedProjectKeys,
|
|
shortcutIndexByWorkspaceKey,
|
|
toggleProjectCollapsed,
|
|
handleRefresh,
|
|
labels,
|
|
newWorkspaceKeys,
|
|
};
|
|
|
|
if (isCompactLayout) {
|
|
return (
|
|
<MobileSidebar
|
|
{...sharedProps}
|
|
insetsTop={insets.top}
|
|
insetsBottom={insets.bottom}
|
|
isOpen={isOpen}
|
|
closeSidebar={showMobileAgent}
|
|
handleOpenProject={handleOpenProjectMobile}
|
|
handleHome={handleHomeMobile}
|
|
handleSettings={handleSettingsMobile}
|
|
handleAddHost={handleAddHostMobile}
|
|
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
|
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DesktopSidebar
|
|
{...sharedProps}
|
|
insetsTop={insets.top}
|
|
isOpen={isOpen}
|
|
handleOpenProject={handleOpenProjectDesktop}
|
|
handleHome={handleHomeDesktop}
|
|
handleSettings={handleSettingsDesktop}
|
|
handleAddHost={handleAddHostDesktop}
|
|
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
|
handleViewMore={handleViewMoreNavigate}
|
|
handleViewSchedules={handleViewSchedulesNavigate}
|
|
/>
|
|
);
|
|
});
|
|
|
|
function sidebarHostOptionTestID(serverId: string): string {
|
|
return `sidebar-host-row-${serverId}`;
|
|
}
|
|
|
|
function FooterIconButton({
|
|
buttonRef,
|
|
onPress,
|
|
testID,
|
|
accessibilityLabel,
|
|
icon: Icon,
|
|
iconSize,
|
|
theme,
|
|
}: {
|
|
onPress: () => void;
|
|
testID: string;
|
|
accessibilityLabel: string;
|
|
icon: typeof FolderPlus;
|
|
iconSize?: number;
|
|
theme: SidebarTheme;
|
|
buttonRef?: RefObject<View | null>;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
ref={buttonRef}
|
|
style={styles.footerIconButton}
|
|
testID={testID}
|
|
nativeID={testID}
|
|
collapsable={false}
|
|
accessible
|
|
accessibilityLabel={accessibilityLabel}
|
|
accessibilityRole="button"
|
|
onPress={onPress}
|
|
>
|
|
{({ hovered }) => (
|
|
<Icon
|
|
size={iconSize ?? theme.iconSize.md}
|
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function SidebarHostPicker({
|
|
theme,
|
|
onAddHost,
|
|
onOpenHostSettings,
|
|
}: {
|
|
theme: SidebarTheme;
|
|
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"
|
|
accessibilityLabel="Hosts"
|
|
icon={Server}
|
|
iconSize={theme.iconSize.sm}
|
|
theme={theme}
|
|
/>
|
|
</HostPicker>
|
|
);
|
|
}
|
|
|
|
function AddProjectTooltipContent({
|
|
newAgentKeys,
|
|
label,
|
|
}: {
|
|
newAgentKeys: ReturnType<typeof useShortcutKeys>;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<View style={styles.tooltipRow}>
|
|
<Text style={styles.tooltipText}>{label}</Text>
|
|
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function HeaderIconTooltipContent({
|
|
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;
|
|
home: string;
|
|
settings: string;
|
|
switchHost: string;
|
|
searchHosts: string;
|
|
};
|
|
handleAddHost: () => void;
|
|
handleOpenHostSettings: (serverId: string) => void;
|
|
}) {
|
|
const newAgentKeys = useShortcutKeys("new-agent");
|
|
|
|
return (
|
|
<View style={styles.sidebarFooter}>
|
|
<View style={styles.footerIconRow}>
|
|
<SidebarHostPicker
|
|
theme={theme}
|
|
onAddHost={handleAddHost}
|
|
onOpenHostSettings={handleOpenHostSettings}
|
|
/>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<FooterIconButton
|
|
onPress={handleOpenProject}
|
|
testID="sidebar-add-project"
|
|
accessibilityLabel={labels.addProject}
|
|
icon={FolderPlus}
|
|
theme={theme}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" align="center" offset={8}>
|
|
<AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<FooterIconButton
|
|
onPress={handleHome}
|
|
testID="sidebar-home"
|
|
accessibilityLabel={labels.home}
|
|
icon={Home}
|
|
theme={theme}
|
|
/>
|
|
<FooterIconButton
|
|
onPress={handleSettings}
|
|
testID="sidebar-settings"
|
|
accessibilityLabel={labels.settings}
|
|
icon={Settings}
|
|
theme={theme}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function MobileSidebar({
|
|
theme,
|
|
statusWorkspacePlacements,
|
|
projects,
|
|
projectNamesByKey,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
isManualRefresh,
|
|
groupMode,
|
|
collapsedProjectKeys,
|
|
shortcutIndexByWorkspaceKey,
|
|
toggleProjectCollapsed,
|
|
handleRefresh,
|
|
newWorkspaceKeys,
|
|
handleOpenProject,
|
|
handleHome,
|
|
handleSettings,
|
|
labels,
|
|
handleAddHost,
|
|
handleOpenHostSettings,
|
|
insetsTop,
|
|
insetsBottom,
|
|
isOpen,
|
|
closeSidebar,
|
|
handleViewMoreNavigate,
|
|
handleViewSchedulesNavigate,
|
|
}: MobileSidebarProps) {
|
|
const pathname = usePathname();
|
|
const isSessionsActive = pathname.includes("/sessions");
|
|
const isSchedulesActive = pathname.includes("/schedules");
|
|
const {
|
|
translateX,
|
|
backdropOpacity,
|
|
windowWidth,
|
|
animateToOpen,
|
|
animateToClose,
|
|
overlayVisible,
|
|
isGesturing,
|
|
mobilePanelState,
|
|
gestureAnimatingRef,
|
|
closeGestureRef,
|
|
} = useSidebarAnimation();
|
|
const closeTouchStartX = useSharedValue(0);
|
|
const closeTouchStartY = useSharedValue(0);
|
|
|
|
const handleCloseFromGesture = useCallback(() => {
|
|
gestureAnimatingRef.current = true;
|
|
closeSidebar();
|
|
}, [closeSidebar, gestureAnimatingRef]);
|
|
|
|
const handleViewMore = useCallback(() => {
|
|
translateX.value = -windowWidth;
|
|
backdropOpacity.value = 0;
|
|
closeSidebar();
|
|
handleViewMoreNavigate();
|
|
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
|
|
|
|
const handleViewSchedules = useCallback(() => {
|
|
translateX.value = -windowWidth;
|
|
backdropOpacity.value = 0;
|
|
closeSidebar();
|
|
handleViewSchedulesNavigate();
|
|
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
|
|
|
|
const handleWorkspacePress = useCallback(() => {
|
|
closeSidebar();
|
|
}, [closeSidebar]);
|
|
|
|
const closeGesture = useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.withRef(closeGestureRef)
|
|
.enabled(true)
|
|
.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 (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}),
|
|
[
|
|
closeGestureRef,
|
|
closeTouchStartX,
|
|
closeTouchStartY,
|
|
isGesturing,
|
|
mobilePanelState,
|
|
windowWidth,
|
|
translateX,
|
|
backdropOpacity,
|
|
animateToClose,
|
|
animateToOpen,
|
|
handleCloseFromGesture,
|
|
],
|
|
);
|
|
|
|
const mobileSidebarInsetStyle = useMemo(
|
|
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
|
[windowWidth, insetsTop, insetsBottom],
|
|
);
|
|
|
|
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{ translateX: translateX.value }],
|
|
}));
|
|
|
|
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
|
opacity: backdropOpacity.value,
|
|
}));
|
|
|
|
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,
|
|
// pointerEvents is React-owned, not worklet-owned: Reanimated never
|
|
// touches it, so a stale animated-prop revert can't wedge an invisible
|
|
// tap-eating backdrop.
|
|
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
|
|
],
|
|
[backdropAnimatedStyle, isOpen],
|
|
);
|
|
const mobileSidebarStyle = useMemo(
|
|
() => [
|
|
staticStyles.mobileSidebar,
|
|
mobileSidebarInsetStyle,
|
|
sidebarAnimatedStyle,
|
|
{ backgroundColor: theme.colors.surfaceSidebar },
|
|
],
|
|
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
|
|
);
|
|
// display is React-owned on the plain wrapper View (no animated styles), so
|
|
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
|
|
// reverts the panel transform to after a heavy commit (reanimated#9635).
|
|
const overlayStyle = useMemo(
|
|
() => [
|
|
StyleSheet.absoluteFillObject,
|
|
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
|
|
],
|
|
[overlayVisible],
|
|
);
|
|
|
|
return (
|
|
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
|
<Animated.View style={backdropStyle} />
|
|
|
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
|
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
|
<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}
|
|
statusWorkspacePlacements={statusWorkspacePlacements}
|
|
projects={projects}
|
|
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>
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function DesktopSidebar({
|
|
theme,
|
|
statusWorkspacePlacements,
|
|
projects,
|
|
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}
|
|
statusWorkspacePlacements={statusWorkspacePlacements}
|
|
projects={projects}
|
|
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}>
|
|
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<View>
|
|
<SidebarDisplayPreferencesMenu />
|
|
</View>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" align="center" offset={8}>
|
|
<HeaderIconTooltipContent 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({
|
|
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) => ({
|
|
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,
|
|
},
|
|
}));
|