mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
Streamline sidebar usage and stacked sheet interactions (#5805)
* feat(app): streamline sidebar usage and stacked sheets * fix(app): restore compact sidebar navigation clicks The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript. * fix(app): cap menu sheets at eighty percent height Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing.
This commit is contained in:
@@ -14,11 +14,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetFlatList,
|
||||
type BottomSheetFlatListMethods,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -718,12 +714,6 @@ export function CommandCenter() {
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
const backdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
@@ -735,7 +725,7 @@ export function CommandCenter() {
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={backdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.sheetBackground}
|
||||
handleIndicatorStyle={styles.sheetHandle}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
KEYBOARD_STATUS,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
@@ -550,13 +549,6 @@ export function AdaptiveModalSheet({
|
||||
onDismiss?.();
|
||||
}, [handleSheetDismiss, onDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const desktopCardStyle = useMemo(
|
||||
() => [
|
||||
styles.desktopCard,
|
||||
@@ -649,7 +641,7 @@ export function AdaptiveModalSheet({
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
||||
import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
@@ -45,6 +44,7 @@ import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model
|
||||
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { PluginSidebarItem } from "@/plugins/sidebar-items";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
@@ -52,7 +52,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem } from "@/usage";
|
||||
import { UsageSidebarItem, useOpenUsageScreen } from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
@@ -89,9 +89,9 @@ interface SidebarSharedProps {
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
hosts: string;
|
||||
importSession: string;
|
||||
settings: string;
|
||||
searchHosts: string;
|
||||
usage: string;
|
||||
closeSidebar: string;
|
||||
}
|
||||
|
||||
@@ -196,9 +196,9 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
hosts: t("sidebar.actions.hosts"),
|
||||
importSession: t("importSession.title"),
|
||||
settings: t("sidebar.actions.settings"),
|
||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||
usage: t(builtinSidebarNavLabelKey("usage")),
|
||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||
}),
|
||||
[t],
|
||||
@@ -283,6 +283,7 @@ function FooterIconButton({
|
||||
testID: string;
|
||||
label: string;
|
||||
icon: typeof FolderPlus;
|
||||
/** Only for a glyph that reads larger than the others at the same size. */
|
||||
iconSize?: number;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
@@ -317,64 +318,6 @@ function FooterIconButton({
|
||||
);
|
||||
}
|
||||
|
||||
function footerAddProjectButtonStyle({
|
||||
hovered,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered];
|
||||
}
|
||||
|
||||
function FooterAddProjectButton({
|
||||
onPress,
|
||||
label,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
shortcutKeys: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={footerAddProjectButtonStyle}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const isHovered = Boolean(hovered);
|
||||
return (
|
||||
<>
|
||||
<FolderPlus
|
||||
size={theme.iconSize.sm}
|
||||
color={isHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.footerAddProjectLabel,
|
||||
isHovered && styles.footerAddProjectLabelHovered,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8} testID="sidebar-add-project-tooltip">
|
||||
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHostPicker({
|
||||
theme,
|
||||
label,
|
||||
@@ -423,7 +366,8 @@ function SidebarHostPicker({
|
||||
testID="sidebar-hosts-trigger"
|
||||
label={label}
|
||||
icon={Server}
|
||||
iconSize={theme.iconSize.sm}
|
||||
// Server's two boxes fill more of the square than the other glyphs.
|
||||
iconSize={theme.iconSize.md - 1}
|
||||
theme={theme}
|
||||
/>
|
||||
</HostPicker>
|
||||
@@ -448,7 +392,6 @@ function IconTooltipContent({
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
handleOpenProject,
|
||||
handleImportSession,
|
||||
handleSettings,
|
||||
labels,
|
||||
handleAddHost,
|
||||
@@ -457,14 +400,13 @@ function SidebarFooter({
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
handleOpenProject: () => void;
|
||||
handleImportSession: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: {
|
||||
addProject: string;
|
||||
hosts: string;
|
||||
importSession: string;
|
||||
settings: string;
|
||||
searchHosts: string;
|
||||
usage: string;
|
||||
};
|
||||
handleAddHost: () => void;
|
||||
handleOpenHostSettings: (serverId: string) => void;
|
||||
@@ -472,41 +414,44 @@ function SidebarFooter({
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
const openUsageScreen = useOpenUsageScreen();
|
||||
|
||||
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
|
||||
return (
|
||||
<View style={styles.footerContainer} testID="sidebar-footer">
|
||||
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
|
||||
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
|
||||
<FooterAddProjectButton
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={styles.footerIconRow}>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleImportSession}
|
||||
testID="sidebar-import-session"
|
||||
label={labels.importSession}
|
||||
icon={Import}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
<FooterIconButton
|
||||
onPress={openUsageScreen}
|
||||
testID="sidebar-usage-icon"
|
||||
label={labels.usage}
|
||||
icon={CircleGauge}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<View style={styles.footerSpacer} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -593,7 +538,11 @@ function MobileSidebar({
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
|
||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
pointerEvents="box-none"
|
||||
style={styles.mobileCloseButtonRow}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
@@ -642,7 +591,6 @@ function MobileSidebar({
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
@@ -820,7 +768,6 @@ function DesktopSidebar({
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
@@ -919,7 +866,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
alignItems: "flex-end",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
mobileCloseButton: {
|
||||
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
|
||||
@@ -971,48 +917,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
// The icons' 28pt buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
footerIconRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
footerAddProjectButton: {
|
||||
minWidth: 0,
|
||||
minHeight: 32,
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
footerAddProjectButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
// Pushes Help and Settings to the end of the icon line.
|
||||
footerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
footerAddProjectLabel: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
footerAddProjectLabelHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
// Usage and plugin rows sit above the restored footer line.
|
||||
// Usage and plugin rows sit above the footer's icon line, spaced like the header nav rows.
|
||||
footerRows: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
gap: 2,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
|
||||
@@ -89,7 +89,7 @@ export function SidebarHeaderRow({
|
||||
accessibilityLabel={accessibilityLabel ?? label}
|
||||
accessibilityState={isActive ? SELECTED_STATE : undefined}
|
||||
aria-selected={isActive}
|
||||
style={variant === "inline" ? styles.buttonInline : styles.button}
|
||||
style={styles.button}
|
||||
>
|
||||
{ThemedIcon ? (
|
||||
<ThemedIcon
|
||||
@@ -158,17 +158,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// edge with the list below.
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
// Footer rows put their icon on the footer's rail: where a 16px icon sits in a 28px button.
|
||||
buttonInline: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1.5],
|
||||
},
|
||||
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
|
||||
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
|
||||
label: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { X } from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
@@ -115,13 +115,6 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
setSheetData(null);
|
||||
}, [handleSheetDismiss]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ openToolCall, closeToolCall }),
|
||||
[openToolCall, closeToolCall],
|
||||
@@ -138,7 +131,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleToolCallSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
backdropOpacity={0.5}
|
||||
enablePanDownToClose
|
||||
>
|
||||
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
|
||||
|
||||
@@ -28,11 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { Check, File, Folder, Search } from "lucide-react-native";
|
||||
@@ -948,18 +944,6 @@ interface MobileBodyProps {
|
||||
}
|
||||
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...backdropProps}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const comboboxTitleStyle = useMemo(
|
||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||
[props.titleColor],
|
||||
@@ -991,7 +975,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
enableDynamicSizing={false}
|
||||
onChange={props.handleSheetChange}
|
||||
onDismiss={props.handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
handleIndicatorStyle={props.handleIndicatorStyle}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
BottomSheetModal as GorhomBottomSheetModal,
|
||||
type BottomSheetModalProps,
|
||||
type BottomSheetBackdropProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import React from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import type { ElementRef, ReactNode } from "react";
|
||||
import { systemBackPress } from "./back-press";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
createBottomSheetVisibilityTracker,
|
||||
} from "./visibility-tracker";
|
||||
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
|
||||
import { SheetBackdrop } from "./sheet-backdrop";
|
||||
|
||||
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
|
||||
|
||||
@@ -36,7 +38,7 @@ export type ContextBridge = (children: ReactNode) => ReactNode;
|
||||
|
||||
type IsolatedBottomSheetModalProps = Omit<
|
||||
BottomSheetModalProps,
|
||||
"enableDismissOnClose" | "stackBehavior" | "children"
|
||||
"enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent"
|
||||
> & {
|
||||
/**
|
||||
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
|
||||
@@ -44,6 +46,8 @@ type IsolatedBottomSheetModalProps = Omit<
|
||||
*/
|
||||
children?: ReactNode;
|
||||
presentation?: "push" | "replace";
|
||||
/** Show a dismissible backdrop. Its interaction belongs to the modal stack. */
|
||||
backdropOpacity?: number;
|
||||
/**
|
||||
* Required, and `null` is a real answer: a sheet that needs nothing from its call site should
|
||||
* have to say so. The failure it prevents is invisible until someone adds a `useContext` deep
|
||||
@@ -58,13 +62,43 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
IsolatedBottomSheetModalRef,
|
||||
IsolatedBottomSheetModalProps
|
||||
>(function IsolatedBottomSheetModal(props, ref) {
|
||||
const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props;
|
||||
// Gorhom puts this ref into its provider queue and reads `.current` for stack operations.
|
||||
// A callback ref accepts the handle but leaves that queue unable to dismiss or restore it.
|
||||
const modalRef = useRef<GorhomBottomSheetModalMethods>(null);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
present: (...args) => modalRef.current?.present(...args),
|
||||
dismiss: (...args) => modalRef.current?.dismiss(...args),
|
||||
snapToIndex: (...args) => modalRef.current?.snapToIndex(...args),
|
||||
snapToPosition: (...args) => modalRef.current?.snapToPosition(...args),
|
||||
expand: (...args) => modalRef.current?.expand(...args),
|
||||
collapse: (...args) => modalRef.current?.collapse(...args),
|
||||
close: (...args) => modalRef.current?.close(...args),
|
||||
forceClose: (...args) => modalRef.current?.forceClose(...args),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
children,
|
||||
presentation = "push",
|
||||
contextBridge,
|
||||
backdropOpacity,
|
||||
...bottomSheetProps
|
||||
} = props;
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: BottomSheetBackdropProps) => (
|
||||
<SheetBackdrop {...backdropProps} opacity={backdropOpacity} />
|
||||
),
|
||||
[backdropOpacity],
|
||||
);
|
||||
const modal = (
|
||||
<GorhomBottomSheetModal
|
||||
{...bottomSheetProps}
|
||||
ref={ref}
|
||||
ref={modalRef}
|
||||
enableDismissOnClose
|
||||
stackBehavior={presentation}
|
||||
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
|
||||
>
|
||||
<BottomSheetScope>{contextBridge ? contextBridge(children) : children}</BottomSheetScope>
|
||||
</GorhomBottomSheetModal>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
|
||||
|
||||
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
|
||||
return <BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
useBottomSheetModal,
|
||||
type BottomSheetBackdropProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, StyleSheet, View } from "react-native";
|
||||
|
||||
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
|
||||
const { dismiss } = useBottomSheetModal();
|
||||
const { t } = useTranslation();
|
||||
const dismissTopSheet = useCallback(() => dismiss(), [dismiss]);
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
pressBehavior="none"
|
||||
accessible={false}
|
||||
accessibilityRole={null}
|
||||
accessibilityLabel={null}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("common.bottomSheetBackdrop")}
|
||||
// The provider owns presentation order, including when a lower backdrop remounts.
|
||||
// Keep this target until the portal unmounts: animation must not expose the sheet below.
|
||||
onPress={dismissTopSheet}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,13 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ComponentProps,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
@@ -345,6 +344,7 @@ function MenuSheetSurface({
|
||||
testID,
|
||||
keyboardFocusScope,
|
||||
}: MenuSurfaceProps): ReactElement | null {
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const menu = useMenuContext("MenuSurface");
|
||||
const { value: surfaceValue } = useSubAnchors();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
@@ -370,18 +370,6 @@ function MenuSheetSurface({
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...backdropProps}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
opacity={0.45}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const openPageId = currentPageId(menu.path);
|
||||
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
|
||||
const depth = menu.path.length;
|
||||
@@ -405,9 +393,10 @@ function MenuSheetSurface({
|
||||
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
|
||||
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
|
||||
enableDynamicSizing
|
||||
maxDynamicContentSize={windowHeight * 0.8}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
// `interactive` rather than `extend`, which is what every other sheet in the app uses.
|
||||
// `extend` grows the sheet to its largest snap point, and with `enableDynamicSizing` that
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ar: TranslationResources = {
|
||||
total: "{{total}} تطابقات",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "خلفية اللوحة السفلية",
|
||||
back: "خلف",
|
||||
loading: "تحميل...",
|
||||
actions: {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const en = {
|
||||
total: "{{total}} matches",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "Bottom sheet backdrop",
|
||||
back: "Back",
|
||||
loading: "Loading...",
|
||||
actions: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const es: TranslationResources = {
|
||||
total: "{{total}} coincidencias",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "Fondo del panel inferior",
|
||||
back: "Atrás",
|
||||
loading: "Cargando...",
|
||||
actions: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const fr: TranslationResources = {
|
||||
total: "{{total}} résultats",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "Arrière-plan du panneau inférieur",
|
||||
back: "Dos",
|
||||
loading: "Chargement...",
|
||||
actions: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ja: TranslationResources = {
|
||||
total: "{{total}} 件の一致",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "ボトムシートの背景",
|
||||
back: "戻る",
|
||||
loading: "読み込み中...",
|
||||
actions: {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ko: TranslationResources = {
|
||||
total: "일치 항목 {{total}}개",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "하단 시트 배경",
|
||||
back: "뒤로",
|
||||
loading: "불러오는 중...",
|
||||
actions: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ptBR: TranslationResources = {
|
||||
total: "{{total}} correspondências",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "Fundo do painel inferior",
|
||||
back: "Voltar",
|
||||
loading: "Carregando...",
|
||||
actions: {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ru: TranslationResources = {
|
||||
total: "Совпадений: {{total}}",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "Фон нижней панели",
|
||||
back: "Назад",
|
||||
loading: "Загрузка...",
|
||||
actions: {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const zhCN: TranslationResources = {
|
||||
total: "{{total}} 个匹配项",
|
||||
},
|
||||
common: {
|
||||
bottomSheetBackdrop: "底部面板背景",
|
||||
back: "返回",
|
||||
loading: "加载中...",
|
||||
actions: {
|
||||
|
||||
@@ -122,6 +122,8 @@ import {
|
||||
} from "./new-workspace-initial-context";
|
||||
import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets";
|
||||
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
|
||||
import { ImportSessionButton } from "./new-workspace/import-session-button";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import {
|
||||
buildTerminalsQueryKey,
|
||||
type ListTerminalsPayload,
|
||||
@@ -2367,6 +2369,7 @@ export function NewWorkspaceScreen({
|
||||
});
|
||||
|
||||
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||
const importSession = useImportSession({ serverId: selectedServerId });
|
||||
|
||||
const composer = isTerminalLaunch ? (
|
||||
<Composer
|
||||
@@ -2435,11 +2438,13 @@ export function NewWorkspaceScreen({
|
||||
isCompact={isCompact}
|
||||
title={t("newWorkspace.title")}
|
||||
formStack={formStack}
|
||||
onImportSession={importSession.open}
|
||||
>
|
||||
{composer}
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</NewWorkspaceLayout>
|
||||
</View>
|
||||
{importSession.sheet}
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
@@ -2448,15 +2453,20 @@ function NewWorkspaceLayout({
|
||||
isCompact,
|
||||
title,
|
||||
formStack,
|
||||
onImportSession,
|
||||
children,
|
||||
}: {
|
||||
isCompact: boolean;
|
||||
title: string;
|
||||
formStack: ReactNode;
|
||||
onImportSession: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// At the top of the screen on compact layouts, under the composer otherwise.
|
||||
const importSessionButton = <ImportSessionButton compact={isCompact} onPress={onImportSession} />;
|
||||
const setupFields = (
|
||||
<>
|
||||
{isCompact ? <View style={styles.compactTopActions}>{importSessionButton}</View> : null}
|
||||
<View style={styles.composerTitleContainer} pointerEvents="none">
|
||||
<Text style={styles.composerTitle}>{title}</Text>
|
||||
</View>
|
||||
@@ -2466,7 +2476,10 @@ function NewWorkspaceLayout({
|
||||
return (
|
||||
<ComposerDock centered={!isCompact}>
|
||||
{setupFields}
|
||||
{children}
|
||||
<>
|
||||
{children}
|
||||
{isCompact ? null : importSessionButton}
|
||||
</>
|
||||
</ComposerDock>
|
||||
);
|
||||
}
|
||||
@@ -2481,6 +2494,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
},
|
||||
// Takes the free space above the setup fields, so its button sits at the top of the screen.
|
||||
// The inset puts the ghost button's icon on the setup rows' icon rail.
|
||||
compactTopActions: {
|
||||
flex: 1,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
composerTitleContainer: {
|
||||
marginBottom: theme.spacing[8],
|
||||
paddingLeft: theme.spacing[6],
|
||||
@@ -2503,7 +2522,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
formStackDesktop: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: theme.spacing[8],
|
||||
// Matches the gap between the composer and the Import session pill below it.
|
||||
marginBottom: theme.spacing[4],
|
||||
// The badge adds its own left padding; offset it so the project icon's left
|
||||
// edge lands exactly on the "New workspace" title's left edge. The trailing
|
||||
// inset mirrors it so the launch chip stops on the composer's inner content
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Import } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* The new workspace screen's way into Import session: a ghost button at the top on compact
|
||||
* layouts, an outlined pill under the composer otherwise. Both are muted until hovered.
|
||||
*/
|
||||
export function ImportSessionButton({
|
||||
compact,
|
||||
onPress,
|
||||
}: {
|
||||
compact: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={compact ? styles.compact : styles.wide}>
|
||||
<Button
|
||||
// Ghost for its muted text that turns foreground on hover; the pill adds the outline.
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={Import}
|
||||
onPress={onPress}
|
||||
style={compact ? null : styles.pill}
|
||||
testID="new-workspace-import-session"
|
||||
>
|
||||
{t("importSession.title")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
compact: {
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
// The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's
|
||||
// border lines up with the composer's border.
|
||||
wide: {
|
||||
alignItems: "flex-start",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
pill: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
}));
|
||||
@@ -34,6 +34,7 @@ describe("resolveSidebarNavItems", () => {
|
||||
it("yields builtins then plugins, all visible, when nothing is stored", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [kanban, notes],
|
||||
preferences: [],
|
||||
});
|
||||
@@ -58,6 +59,7 @@ describe("resolveSidebarNavItems", () => {
|
||||
it("keeps the stored order and appends newly available items as visible", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [notes, kanban],
|
||||
preferences: [
|
||||
{ key: kanbanKey, visible: false },
|
||||
@@ -79,6 +81,7 @@ describe("resolveSidebarNavItems", () => {
|
||||
it("skips keys that are unknown or not currently available", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: [
|
||||
{ key: notesKey, visible: false },
|
||||
@@ -98,6 +101,7 @@ describe("resolveSidebarNavItems", () => {
|
||||
it("lets the first of duplicate keys win", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: [
|
||||
{ key: "history", visible: false },
|
||||
@@ -114,10 +118,26 @@ describe("resolveSidebarNavItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSidebarNavItems on compact layouts", () => {
|
||||
it("hides the Usage item until it is turned on", () => {
|
||||
const resolve = (compact: boolean, preferences: SidebarNavPreference[]) =>
|
||||
summarize(
|
||||
resolveSidebarNavItems({ section: "footer", compact, pluginGroups: [], preferences }),
|
||||
);
|
||||
|
||||
expect(resolve(true, [])).toEqual([{ key: "usage", visible: false }]);
|
||||
expect(resolve(false, [])).toEqual([{ key: "usage", visible: true }]);
|
||||
expect(resolve(true, [{ key: "usage", visible: true }])).toEqual([
|
||||
{ key: "usage", visible: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSidebarNavItemVisible", () => {
|
||||
it("toggles one item and writes the full resolved order", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [kanban],
|
||||
preferences: [],
|
||||
});
|
||||
@@ -140,6 +160,7 @@ describe("setSidebarNavItemVisible", () => {
|
||||
];
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: previous,
|
||||
});
|
||||
@@ -165,6 +186,7 @@ describe("setSidebarNavItemVisible", () => {
|
||||
];
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: previous,
|
||||
});
|
||||
@@ -180,13 +202,23 @@ describe("setSidebarNavItemVisible", () => {
|
||||
]);
|
||||
expect(
|
||||
summarize(
|
||||
resolveSidebarNavItems({ section: "header", pluginGroups: [notes], preferences: next }),
|
||||
resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [notes],
|
||||
preferences: next,
|
||||
}),
|
||||
),
|
||||
).toEqual(next);
|
||||
});
|
||||
|
||||
it("returns the normalized list unchanged for an unknown key", () => {
|
||||
const items = resolveSidebarNavItems({ section: "header", pluginGroups: [], preferences: [] });
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: [],
|
||||
});
|
||||
|
||||
const next = setSidebarNavItemVisible({ items, key: "bogus", visible: false, previous: [] });
|
||||
|
||||
@@ -197,6 +229,7 @@ describe("setSidebarNavItemVisible", () => {
|
||||
describe("moveSidebarNavItem", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [kanban],
|
||||
preferences: [],
|
||||
});
|
||||
@@ -274,6 +307,7 @@ describe("footer section", () => {
|
||||
it("resolves the Usage item first, then plugin rows", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [sync],
|
||||
preferences: [],
|
||||
});
|
||||
@@ -287,6 +321,7 @@ describe("footer section", () => {
|
||||
it("ignores header built-ins stored in footer preferences", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: [{ key: "history", visible: false }],
|
||||
});
|
||||
@@ -297,6 +332,7 @@ describe("footer section", () => {
|
||||
it("ignores the footer icon buttons, which are fixed and not items", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [sync],
|
||||
preferences: [
|
||||
{ key: "add-project", visible: false },
|
||||
@@ -319,6 +355,7 @@ describe("footer section", () => {
|
||||
const previous: SidebarNavPreference[] = [notesPreference];
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [sync],
|
||||
preferences: previous,
|
||||
});
|
||||
@@ -327,6 +364,7 @@ describe("footer section", () => {
|
||||
const hidden = setSidebarNavItemVisible({
|
||||
items: resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [sync],
|
||||
preferences: moved,
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ export type SidebarSection = PluginSidebarSection;
|
||||
|
||||
/**
|
||||
* Each section's built-in items in their default order. The footer's bottom line
|
||||
* (Add project and the Hosts, Import session, Help and support, Settings icons) is fixed.
|
||||
* (Add project and the Hosts, Help and support, Settings icons) is fixed.
|
||||
*/
|
||||
export const BUILTIN_SIDEBAR_ITEM_IDS = {
|
||||
header: ["new-workspace", "history", "search", "schedules"],
|
||||
@@ -69,6 +69,16 @@ export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): strin
|
||||
return BUILTIN_SHORTCUT_ACTIONS[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builtins that start hidden on compact layouts, until the user turns them on. A phone's footer
|
||||
* has no room to spare for the Usage summary.
|
||||
*/
|
||||
const HIDDEN_BY_DEFAULT_ON_COMPACT: ReadonlySet<BuiltinSidebarItemId> = new Set(["usage"]);
|
||||
|
||||
function builtinVisibleByDefault(id: BuiltinSidebarItemId, compact: boolean): boolean {
|
||||
return !(compact && HIDDEN_BY_DEFAULT_ON_COMPACT.has(id));
|
||||
}
|
||||
|
||||
export function pluginSidebarNavKey(
|
||||
group: Pick<PluginSidebarGroup, "pluginId" | "contributionId">,
|
||||
): string {
|
||||
@@ -85,6 +95,8 @@ function isBuiltinSidebarItemId<Section extends SidebarSection>(
|
||||
|
||||
export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
|
||||
section: Section;
|
||||
/** Compact layouts start some builtins hidden; a stored preference always wins. */
|
||||
compact: boolean;
|
||||
pluginGroups: readonly PluginSidebarGroup[];
|
||||
preferences: readonly SidebarNavPreference[];
|
||||
}): SidebarNavItem<Section>[] {
|
||||
@@ -115,7 +127,12 @@ export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
|
||||
|
||||
for (const id of builtinIds) {
|
||||
if (placed.has(id)) continue;
|
||||
items.push({ kind: "builtin", key: id, id, visible: true });
|
||||
items.push({
|
||||
kind: "builtin",
|
||||
key: id,
|
||||
id,
|
||||
visible: builtinVisibleByDefault(id, input.compact),
|
||||
});
|
||||
}
|
||||
for (const [key, group] of groupsByKey) {
|
||||
if (placed.has(key)) continue;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { AppSettings } from "@/hooks/use-settings/storage";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
@@ -27,14 +28,15 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
section: Section,
|
||||
): UseSidebarNavItemsReturn<Section> {
|
||||
const plugins = useInstalledPlugins();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const field = PREFERENCE_FIELDS[section];
|
||||
const preferences = settings[field];
|
||||
const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]);
|
||||
|
||||
const items = useMemo(
|
||||
() => resolveSidebarNavItems({ section, pluginGroups, preferences }),
|
||||
[pluginGroups, preferences, section],
|
||||
() => resolveSidebarNavItems({ section, compact, pluginGroups, preferences }),
|
||||
[compact, pluginGroups, preferences, section],
|
||||
);
|
||||
|
||||
const setVisible = useCallback(
|
||||
@@ -43,6 +45,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
const previous = current[field];
|
||||
const currentItems = resolveSidebarNavItems({
|
||||
section,
|
||||
compact,
|
||||
pluginGroups,
|
||||
preferences: previous,
|
||||
});
|
||||
@@ -51,7 +54,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
};
|
||||
});
|
||||
},
|
||||
[field, pluginGroups, section, updateSettings],
|
||||
[compact, field, pluginGroups, section, updateSettings],
|
||||
);
|
||||
|
||||
const move = useCallback(
|
||||
@@ -60,6 +63,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
const previous = current[field];
|
||||
const currentItems = resolveSidebarNavItems({
|
||||
section,
|
||||
compact,
|
||||
pluginGroups,
|
||||
preferences: previous,
|
||||
});
|
||||
@@ -68,7 +72,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
};
|
||||
});
|
||||
},
|
||||
[field, pluginGroups, section, updateSettings],
|
||||
[compact, field, pluginGroups, section, updateSettings],
|
||||
);
|
||||
|
||||
return { items, setVisible, move };
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
type BottomSheetScrollViewMethods,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorhom/bottom-sheet";
|
||||
import { Wrench, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -38,12 +34,7 @@ export function OverviewToolCallGroupSheet({
|
||||
visible,
|
||||
onClose,
|
||||
});
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollToLatest = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
}, []);
|
||||
@@ -64,7 +55,7 @@ export function OverviewToolCallGroupSheet({
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
backdropOpacity={0.5}
|
||||
enablePanDownToClose
|
||||
>
|
||||
<View style={styles.container} testID="tool-call-group-sheet">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct, formatAmount, formatResetLabel } from "./format";
|
||||
@@ -9,19 +10,20 @@ interface ResolvedBalance {
|
||||
usedPct: number | null;
|
||||
}
|
||||
|
||||
function resolveBalance(balance: UsageBalance): ResolvedBalance {
|
||||
function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance {
|
||||
const { used, remaining, limit, unit } = balance;
|
||||
const format = (value: number) => formatAmount(value, unit, locale);
|
||||
if (limit != null && limit > 0) {
|
||||
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
|
||||
const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null;
|
||||
const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—";
|
||||
return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct };
|
||||
const usedText = usedAmount != null ? format(usedAmount) : "—";
|
||||
return { amountText: `${usedText} / ${format(limit)}`, usedPct };
|
||||
}
|
||||
if (remaining != null) {
|
||||
return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null };
|
||||
return { amountText: `${format(remaining)} left`, usedPct: null };
|
||||
}
|
||||
if (used != null) {
|
||||
return { amountText: formatAmount(used, unit), usedPct: null };
|
||||
return { amountText: format(used), usedPct: null };
|
||||
}
|
||||
return { amountText: "—", usedPct: null };
|
||||
}
|
||||
@@ -40,7 +42,8 @@ function fillToneStyle(tone: UsageTone) {
|
||||
}
|
||||
|
||||
export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
|
||||
const { amountText, usedPct } = resolveBalance(balance);
|
||||
const { i18n } = useTranslation();
|
||||
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
|
||||
const tone = balance.tone ?? "default";
|
||||
const resetLabel = formatResetLabel(balance.resetsAt);
|
||||
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { UsageDisplayToggle } from "./display-toggle";
|
||||
import { UsageOptionsMenu } from "./options-menu";
|
||||
import type { UsageHost } from "./model";
|
||||
import { useHostUsage } from "./queries";
|
||||
import type { UsageView } from "./types";
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
/** The hosts to choose between, and which one is shown. */
|
||||
export interface UsageHostSelection {
|
||||
hosts: UsageHost[];
|
||||
@@ -26,8 +17,7 @@ export interface UsageHostSelection {
|
||||
|
||||
/**
|
||||
* The controls on the right of every usage title row: the host filter when there is more than one
|
||||
* host, the used/remaining toggle and Refresh. A host that cannot report usage keeps only the
|
||||
* host filter.
|
||||
* host, and the options menu. A host that cannot report usage keeps only the host filter.
|
||||
*/
|
||||
export function UsageControls({
|
||||
view,
|
||||
@@ -41,8 +31,6 @@ export function UsageControls({
|
||||
hostSelection?: UsageHostSelection;
|
||||
}) {
|
||||
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
|
||||
const compact = useIsCompactFormFactor();
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
return (
|
||||
<View style={styles.controls}>
|
||||
{hostSelection && hostSelection.hosts.length > 1 ? (
|
||||
@@ -56,23 +44,7 @@ export function UsageControls({
|
||||
/>
|
||||
) : null}
|
||||
{view.kind === "unavailable" ? null : (
|
||||
<>
|
||||
<UsageDisplayToggle display={display} />
|
||||
{/* The Changes panel's refresh: an icon, with the label in its tooltip. */}
|
||||
<ToolbarButton
|
||||
label={busy ? usageCopy.refreshing : usageCopy.refresh}
|
||||
compact={compact}
|
||||
disabled={busy}
|
||||
onPress={onRefresh}
|
||||
testID="usage-refresh-all"
|
||||
>
|
||||
{busy ? (
|
||||
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
|
||||
) : (
|
||||
<ThemedRotateCw size={iconSize} uniProps={extraMutedIconColorMapping} />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
<UsageOptionsMenu display={display} busy={busy} onRefresh={onRefresh} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export const usageCopy = {
|
||||
title: "Usage",
|
||||
planUsage: "Plan usage",
|
||||
options: "Usage options",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
refreshFailed: "Unable to refresh usage",
|
||||
@@ -16,6 +17,8 @@ export const usageCopy = {
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
retry: "Try again",
|
||||
pin: "Pin",
|
||||
displayAs: "Show",
|
||||
displayUsed: "Used",
|
||||
displayRemaining: "Remaining",
|
||||
showInSidebar: "Show in sidebar",
|
||||
} as const;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
|
||||
const OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
{ value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
|
||||
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
|
||||
];
|
||||
|
||||
/** Switches every usage surface between the share used and the share left. */
|
||||
export function UsageDisplayToggle({ display }: { display: UsageDisplay }) {
|
||||
return (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
options={OPTIONS}
|
||||
value={display.displayAs}
|
||||
onValueChange={display.setDisplayAs}
|
||||
testID="usage-display-toggle"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAmount } from "./format";
|
||||
|
||||
describe("formatAmount", () => {
|
||||
it("groups thousands in the app's language", () => {
|
||||
expect(formatAmount(12345, "credits", "en")).toBe("12,345");
|
||||
expect(formatAmount(12345, "requests", "en")).toBe("12,345");
|
||||
expect(formatAmount(12345, "credits", "fr").replace(/\s/g, " ")).toBe("12 345");
|
||||
});
|
||||
|
||||
it("formats dollars as the language writes currency", () => {
|
||||
expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50");
|
||||
expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US");
|
||||
});
|
||||
});
|
||||
@@ -34,13 +34,14 @@ export function formatResetLabel(iso: string | null | undefined): string | null
|
||||
return rel === "now" ? "resetting now" : `resets ${rel}`;
|
||||
}
|
||||
|
||||
export function formatAmount(value: number, unit: UsageBalanceUnit): string {
|
||||
/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */
|
||||
export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string {
|
||||
switch (unit) {
|
||||
case "usd":
|
||||
return `$${value.toFixed(2)}`;
|
||||
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value);
|
||||
case "tokens":
|
||||
return formatTokenCount(value);
|
||||
default:
|
||||
return value.toLocaleString();
|
||||
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { HostUsageSection } from "./host-usage-section";
|
||||
export { UsageScreen } from "./usage-screen";
|
||||
export { UsageSidebarItem } from "./sidebar-item";
|
||||
export { UsageSidebarItem, useOpenUsageScreen } from "./sidebar-item";
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct } from "./format";
|
||||
import type { UsageTone } from "./types";
|
||||
|
||||
function fillToneStyle(tone: UsageTone) {
|
||||
switch (tone) {
|
||||
case "ok":
|
||||
return styles.fillOk;
|
||||
case "warning":
|
||||
return styles.fillWarning;
|
||||
case "danger":
|
||||
return styles.fillDanger;
|
||||
default:
|
||||
return styles.fillDefault;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A window's share as a bar, coloured by its tone. `quiet` fills it with a surface colour instead,
|
||||
* for summaries where a row of coloured bars would be noise. The caller sizes the track.
|
||||
*/
|
||||
export function UsageMeter({
|
||||
percent,
|
||||
tone,
|
||||
quiet = false,
|
||||
style,
|
||||
}: {
|
||||
percent: number;
|
||||
tone: UsageTone;
|
||||
quiet?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const fillWidth = clampPct(percent);
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, quiet ? styles.fillQuiet : fillToneStyle(tone), { width: `${fillWidth}%` }],
|
||||
[fillWidth, quiet, tone],
|
||||
);
|
||||
return (
|
||||
<View style={[styles.track, style]}>
|
||||
<View style={fillStyle} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
track: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
// Follows the track, so a caller that sizes the track sizes the bar.
|
||||
fill: {
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
fillQuiet: {
|
||||
backgroundColor: theme.colors.surface4,
|
||||
},
|
||||
fillDefault: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
fillOk: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
fillWarning: {
|
||||
backgroundColor: theme.colors.statusWarning,
|
||||
},
|
||||
fillDanger: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Settings } from "lucide-react-native";
|
||||
import { useCallback } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
/**
|
||||
* The usage title row's options: Refresh every report, whether percents read as used or
|
||||
* remaining, and whether the sidebar shows the Usage item (the same switch as Settings > Sidebar). The trigger spins while a refresh is in flight, since the menu hides the one it came
|
||||
* from.
|
||||
*/
|
||||
export function UsageOptionsMenu({
|
||||
display,
|
||||
busy,
|
||||
onRefresh,
|
||||
}: {
|
||||
display: UsageDisplay;
|
||||
busy: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
const { displayAs, setDisplayAs } = display;
|
||||
const showUsed = useCallback(() => setDisplayAs("used"), [setDisplayAs]);
|
||||
const showRemaining = useCallback(() => setDisplayAs("remaining"), [setDisplayAs]);
|
||||
const sidebarItems = useSidebarNavItems("footer");
|
||||
const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible);
|
||||
const { setVisible } = sidebarItems;
|
||||
const toggleInSidebar = useCallback(
|
||||
() => setVisible("usage", !inSidebar),
|
||||
[inSidebar, setVisible],
|
||||
);
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<ToolbarButton
|
||||
kind="menu"
|
||||
label={usageCopy.options}
|
||||
compact={compact}
|
||||
testID="usage-options-menu"
|
||||
>
|
||||
{busy ? (
|
||||
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
|
||||
) : (
|
||||
<ThemedSettings size={iconSize} uniProps={extraMutedIconColorMapping} />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
width={200}
|
||||
sheetTitle={usageCopy.options}
|
||||
testID="usage-options-menu-content"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
status={busy ? "pending" : "idle"}
|
||||
pendingLabel={usageCopy.refreshing}
|
||||
onSelect={onRefresh}
|
||||
testID="usage-refresh-all"
|
||||
>
|
||||
{usageCopy.refresh}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{usageCopy.displayAs}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
selected={displayAs === "used"}
|
||||
onSelect={showUsed}
|
||||
testID="usage-display-used"
|
||||
>
|
||||
{usageCopy.displayUsed}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
selected={displayAs === "remaining"}
|
||||
onSelect={showRemaining}
|
||||
testID="usage-display-remaining"
|
||||
>
|
||||
{usageCopy.displayRemaining}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
selected={inSidebar}
|
||||
onSelect={toggleInSidebar}
|
||||
testID="usage-show-in-sidebar"
|
||||
>
|
||||
{usageCopy.showInSidebar}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UsagePreferences } from "./preferences";
|
||||
import { resolvePinnedUsage } from "./pinned";
|
||||
import {
|
||||
choosePinnedUsageLayout,
|
||||
resolvePinnedUsage,
|
||||
type PinnedUsageSource,
|
||||
type PinnedUsageWindow,
|
||||
} from "./pinned";
|
||||
import type { UsageReportEntry, UsageWindow } from "./types";
|
||||
|
||||
function report(input: {
|
||||
@@ -26,8 +31,8 @@ const claude = report({
|
||||
sourceLabel: "Claude",
|
||||
icon: "<svg/>",
|
||||
windows: [
|
||||
{ id: "five-hour", label: "5-hour", usedPct: 31 },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 80 },
|
||||
{ id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31 },
|
||||
{ id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80 },
|
||||
],
|
||||
});
|
||||
const codex = report({
|
||||
@@ -36,6 +41,10 @@ const codex = report({
|
||||
windows: [{ id: "weekly", label: "Weekly", remainingPct: 88 }],
|
||||
});
|
||||
|
||||
function windows(reports: UsageReportEntry[], prefs: UsagePreferences): PinnedUsageWindow[] {
|
||||
return resolvePinnedUsage(reports, prefs).flatMap((source) => source.windows);
|
||||
}
|
||||
|
||||
function preferences(
|
||||
pinned: UsagePreferences["pinned"],
|
||||
displayAs: UsagePreferences["displayAs"] = "used",
|
||||
@@ -43,6 +52,26 @@ function preferences(
|
||||
return { displayAs, pinned, serverId: null };
|
||||
}
|
||||
|
||||
describe("choosePinnedUsageLayout", () => {
|
||||
const widths = { labelsWidth: 200, percentsWidth: 120, windowCount: 2 };
|
||||
|
||||
it("shows meters only when every window's meter fits beside the labels", () => {
|
||||
expect(choosePinnedUsageLayout({ ...widths, available: 240 })).toBe("meters");
|
||||
expect(choosePinnedUsageLayout({ ...widths, available: 239 })).toBe("labels");
|
||||
});
|
||||
|
||||
it("drops the labels before it lets the line overflow", () => {
|
||||
expect(choosePinnedUsageLayout({ ...widths, available: 199 })).toBe("percents");
|
||||
});
|
||||
|
||||
it("shows labels until the widths are measured", () => {
|
||||
expect(choosePinnedUsageLayout({ ...widths, available: null })).toBe("labels");
|
||||
expect(choosePinnedUsageLayout({ ...widths, available: 999, labelsWidth: null })).toBe(
|
||||
"labels",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePinnedUsage", () => {
|
||||
it("defaults to the first window with a percent for each source account", () => {
|
||||
const work = report({
|
||||
@@ -60,57 +89,113 @@ describe("resolvePinnedUsage", () => {
|
||||
windows: [{ id: "empty", label: "Empty" }],
|
||||
});
|
||||
expect(
|
||||
resolvePinnedUsage([claude, codex, work, empty], preferences([])).map((item) => [
|
||||
windows([claude, codex, work, empty], preferences([])).map((item) => [
|
||||
item.key,
|
||||
item.percentText,
|
||||
]),
|
||||
).toEqual([
|
||||
["claude:default/five-hour", "31%"],
|
||||
["codex:default/weekly", "12%"],
|
||||
["claude:work/weekly", "90%"],
|
||||
["codex:default/weekly", "12%"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("pins replace defaults, and removing all pins restores defaults", () => {
|
||||
const reports = [claude, codex];
|
||||
expect(
|
||||
resolvePinnedUsage(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map(
|
||||
windows(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map(
|
||||
(item) => item.key,
|
||||
),
|
||||
).toEqual(["claude:default/weekly"]);
|
||||
expect(resolvePinnedUsage(reports, preferences([])).map((item) => item.key)).toEqual([
|
||||
expect(windows(reports, preferences([])).map((item) => item.key)).toEqual([
|
||||
"claude:default/five-hour",
|
||||
"codex:default/weekly",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows pinned windows in pin order, not report order", () => {
|
||||
const items = resolvePinnedUsage(
|
||||
[claude, codex],
|
||||
it("groups pinned windows by source in report order, not pin order", () => {
|
||||
const work = report({
|
||||
sourceId: "claude",
|
||||
sourceLabel: "Claude",
|
||||
account: "work",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 90 }],
|
||||
});
|
||||
const sources = resolvePinnedUsage(
|
||||
[claude, codex, work],
|
||||
preferences([
|
||||
{ sourceId: "claude", windowId: "weekly" },
|
||||
{ sourceId: "codex", windowId: "weekly" },
|
||||
{ sourceId: "claude", windowId: "five-hour" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
key: "codex:default/weekly",
|
||||
icon: null,
|
||||
label: "Codex Weekly 12% used",
|
||||
percentText: "12%",
|
||||
},
|
||||
{
|
||||
key: "claude:default/five-hour",
|
||||
icon: "<svg/>",
|
||||
label: "Claude 5-hour 31% used",
|
||||
percentText: "31%",
|
||||
},
|
||||
const windowKeys = (source: PinnedUsageSource) => source.windows.map((window) => window.key);
|
||||
expect(sources.map((source) => [source.key, windowKeys(source)])).toEqual([
|
||||
["claude:default", ["claude:default/five-hour", "claude:default/weekly"]],
|
||||
["claude:work", ["claude:work/weekly"]],
|
||||
["codex:default", ["codex:default/weekly"]],
|
||||
]);
|
||||
expect(sources[0]).toEqual({
|
||||
key: "claude:default",
|
||||
icon: "<svg/>",
|
||||
windows: [
|
||||
{
|
||||
key: "claude:default/five-hour",
|
||||
label: "Claude Session 31% used",
|
||||
shortLabel: "5h",
|
||||
percent: 31,
|
||||
percentText: "31%",
|
||||
tone: "default",
|
||||
},
|
||||
{
|
||||
key: "claude:default/weekly",
|
||||
label: "Claude Weekly 80% used",
|
||||
shortLabel: "wk",
|
||||
percent: 80,
|
||||
percentText: "80%",
|
||||
tone: "warning",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("names a window by its label when the source gives no short label", () => {
|
||||
const items = windows([codex], preferences([{ sourceId: "codex", windowId: "weekly" }]));
|
||||
|
||||
expect(items.map((item) => item.shortLabel)).toEqual(["Weekly"]);
|
||||
});
|
||||
|
||||
it("defaults to every window the source marks as summary, when it marks any", () => {
|
||||
const marked = report({
|
||||
sourceId: "claude",
|
||||
sourceLabel: "Claude",
|
||||
windows: [
|
||||
{ id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31, summary: true },
|
||||
{ id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80, summary: true },
|
||||
{ id: "weekly-fable", label: "Weekly · Fable", usedPct: 8 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(windows([marked, codex], preferences([])).map((item) => item.key)).toEqual([
|
||||
"claude:default/five-hour",
|
||||
"claude:default/weekly",
|
||||
"codex:default/weekly",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an empty short label empty, for a percent that needs no name", () => {
|
||||
const opencode = report({
|
||||
sourceId: "opencode",
|
||||
sourceLabel: "OpenCode Go",
|
||||
windows: [{ id: "rolling", label: "Rolling", shortLabel: "", usedPct: 21 }],
|
||||
});
|
||||
const items = windows([opencode], preferences([]));
|
||||
|
||||
expect(items.map((item) => item.shortLabel)).toEqual([""]);
|
||||
});
|
||||
|
||||
it("formats the share left when the user reads usage as remaining", () => {
|
||||
const items = resolvePinnedUsage(
|
||||
const items = windows(
|
||||
[claude, codex],
|
||||
preferences(
|
||||
[
|
||||
@@ -123,7 +208,7 @@ describe("resolvePinnedUsage", () => {
|
||||
|
||||
expect(items.map((item) => item.percentText)).toEqual(["69%", "88%"]);
|
||||
expect(items.map((item) => item.label)).toEqual([
|
||||
"Claude 5-hour 69% left",
|
||||
"Claude Session 69% left",
|
||||
"Codex Weekly 88% left",
|
||||
]);
|
||||
});
|
||||
@@ -134,7 +219,7 @@ describe("resolvePinnedUsage", () => {
|
||||
sourceLabel: "OpenCode",
|
||||
windows: [{ id: "monthly", label: "Monthly" }],
|
||||
});
|
||||
const items = resolvePinnedUsage(
|
||||
const items = windows(
|
||||
[claude, noPercent],
|
||||
preferences([
|
||||
{ sourceId: "claude", windowId: "monthly" },
|
||||
@@ -159,7 +244,7 @@ describe("resolvePinnedUsage", () => {
|
||||
account: "work",
|
||||
windows: [{ id: "five-hour", label: "5-hour", usedPct: 90 }],
|
||||
});
|
||||
const items = resolvePinnedUsage(
|
||||
const items = windows(
|
||||
[personal, codex, work],
|
||||
preferences([{ sourceId: "claude", windowId: "five-hour" }]),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,53 @@
|
||||
import { formatPct } from "./format";
|
||||
import { displayPercent } from "./model";
|
||||
import type { UsagePreferences } from "./preferences";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
import { windowTone } from "./tone";
|
||||
import type { UsageReportEntry, UsageTone, UsageWindow } from "./types";
|
||||
|
||||
/** One summary window of one account, as the sidebar Usage item shows it. */
|
||||
export interface PinnedUsageWindow {
|
||||
key: string;
|
||||
icon: string | null;
|
||||
/** Accessible description, including the source, account, window and percent meaning. */
|
||||
label: string;
|
||||
/** The window's short name ("5h"), empty for none, or its label when the source sends none. */
|
||||
shortLabel: string;
|
||||
/** The share shown, under the user's used/remaining preference. */
|
||||
percent: number;
|
||||
percentText: string;
|
||||
tone: UsageTone;
|
||||
}
|
||||
|
||||
/** One account's summary windows, under its source's icon. */
|
||||
export interface PinnedUsageSource {
|
||||
key: string;
|
||||
icon: string | null;
|
||||
windows: PinnedUsageWindow[];
|
||||
}
|
||||
|
||||
/** How much of each window the one-line summary has room for. */
|
||||
export type PinnedUsageLayout = "meters" | "labels" | "percents";
|
||||
|
||||
// A meter narrower than this is a sliver that reads as noise rather than a share.
|
||||
export const MIN_METER_WIDTH = 16;
|
||||
/** The space between a meter and its text. */
|
||||
export const METER_GAP = 4;
|
||||
|
||||
/**
|
||||
* The richest layout that fits on one line: every window's meter, else its short label, else its
|
||||
* percent alone. Meters are all or none; a line never mixes them. Until the widths are known it
|
||||
* shows labels, which never flashes meters that turn out not to fit.
|
||||
*/
|
||||
export function choosePinnedUsageLayout(input: {
|
||||
available: number | null;
|
||||
labelsWidth: number | null;
|
||||
percentsWidth: number | null;
|
||||
windowCount: number;
|
||||
}): PinnedUsageLayout {
|
||||
const { available, labelsWidth, percentsWidth, windowCount } = input;
|
||||
if (available === null || labelsWidth === null || percentsWidth === null) return "labels";
|
||||
if (labelsWidth + windowCount * (MIN_METER_WIDTH + METER_GAP) <= available) return "meters";
|
||||
if (labelsWidth <= available) return "labels";
|
||||
return percentsWidth < labelsWidth ? "percents" : "labels";
|
||||
}
|
||||
|
||||
function describe(entry: UsageReportEntry, windowLabel: string): string {
|
||||
@@ -17,40 +55,52 @@ function describe(entry: UsageReportEntry, windowLabel: string): string {
|
||||
return `${entry.sourceLabel}${account} ${windowLabel}`;
|
||||
}
|
||||
|
||||
/** Pins replace defaults; without pins, summarize each account's first window with a percent. */
|
||||
/** Reports with every account of a source together, sources in the order they first appear. */
|
||||
function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] {
|
||||
const sourceIds = [...new Set(reports.map((entry) => entry.sourceId))];
|
||||
return sourceIds.flatMap((sourceId) => reports.filter((entry) => entry.sourceId === sourceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins replace defaults. Without pins, an account shows the windows its source marks as summary,
|
||||
* or its first window with a percent when the source marks none.
|
||||
*/
|
||||
function summaryWindows(entry: UsageReportEntry, preferences: UsagePreferences): UsageWindow[] {
|
||||
const withPercent = entry.report.windows.filter(
|
||||
(window) => displayPercent(window, preferences.displayAs) !== null,
|
||||
);
|
||||
if (preferences.pinned.length === 0) {
|
||||
const marked = withPercent.filter((window) => window.summary);
|
||||
return marked.length > 0 ? marked : withPercent.slice(0, 1);
|
||||
}
|
||||
return withPercent.filter((window) =>
|
||||
preferences.pinned.some((pin) => pin.sourceId === entry.sourceId && pin.windowId === window.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar Usage item's summary: one group per account, grouped by source, each with its
|
||||
* windows in its report's order. Accounts with nothing to show are left out.
|
||||
*/
|
||||
export function resolvePinnedUsage(
|
||||
reports: readonly UsageReportEntry[],
|
||||
preferences: UsagePreferences,
|
||||
): PinnedUsageWindow[] {
|
||||
const selections =
|
||||
preferences.pinned.length === 0
|
||||
? reports.map((entry) => ({
|
||||
entry,
|
||||
window: entry.report.windows.find(
|
||||
(window) => displayPercent(window, preferences.displayAs) !== null,
|
||||
),
|
||||
}))
|
||||
: preferences.pinned.flatMap((pin) =>
|
||||
reports
|
||||
.filter((entry) => entry.sourceId === pin.sourceId)
|
||||
.map((entry) => ({
|
||||
entry,
|
||||
window: entry.report.windows.find((window) => window.id === pin.windowId),
|
||||
})),
|
||||
);
|
||||
return selections.flatMap(({ entry, window }) => {
|
||||
if (!window) return [];
|
||||
const percent = displayPercent(window, preferences.displayAs);
|
||||
if (percent === null) return [];
|
||||
const percentText = formatPct(percent);
|
||||
const meaning = preferences.displayAs === "remaining" ? "left" : "used";
|
||||
return [
|
||||
{
|
||||
): PinnedUsageSource[] {
|
||||
const meaning = preferences.displayAs === "remaining" ? "left" : "used";
|
||||
return groupBySource(reports).flatMap((entry) => {
|
||||
const windows = summaryWindows(entry, preferences).map((window) => {
|
||||
const percent = displayPercent(window, preferences.displayAs) ?? 0;
|
||||
const percentText = formatPct(percent);
|
||||
return {
|
||||
key: `${entry.id}/${window.id}`,
|
||||
icon: entry.icon ?? null,
|
||||
label: `${describe(entry, window.label)} ${percentText} ${meaning}`,
|
||||
shortLabel: window.shortLabel ?? window.label,
|
||||
percent,
|
||||
percentText,
|
||||
},
|
||||
];
|
||||
tone: windowTone(window),
|
||||
};
|
||||
});
|
||||
if (windows.length === 0) return [];
|
||||
return [{ key: entry.id, icon: entry.icon ?? null, windows }];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { router } from "expo-router";
|
||||
import { Gauge } from "lucide-react-native";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { CircleGauge } from "lucide-react-native";
|
||||
import { Fragment, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover";
|
||||
@@ -16,13 +22,21 @@ import { useUsageHostId, useUsageHostSelection } from "./hosts";
|
||||
import type { UsagePreferences } from "./preferences";
|
||||
import { useHostUsage } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
import { resolvePinnedUsage, type PinnedUsageWindow } from "./pinned";
|
||||
import { UsageMeter } from "./meter";
|
||||
import {
|
||||
choosePinnedUsageLayout,
|
||||
METER_GAP,
|
||||
MIN_METER_WIDTH,
|
||||
resolvePinnedUsage,
|
||||
type PinnedUsageLayout,
|
||||
type PinnedUsageSource,
|
||||
} from "./pinned";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
import type { UsageHost } from "./model";
|
||||
import { UsageBody } from "./usage-section";
|
||||
|
||||
const NO_REPORTS: UsageReportEntry[] = [];
|
||||
const NO_ITEMS: PinnedUsageWindow[] = [];
|
||||
const NO_SOURCES: PinnedUsageSource[] = [];
|
||||
|
||||
/**
|
||||
* The sidebar footer's usage entry: each summary window's source icon and percent, or a plain
|
||||
@@ -33,7 +47,7 @@ export function UsageSidebarItem() {
|
||||
const { preferences, display } = useUsagePreferences();
|
||||
const serverId = useUsageHostId();
|
||||
if (!serverId) {
|
||||
return <UsageEntry serverId={serverId} items={NO_ITEMS} display={display} />;
|
||||
return <UsageEntry serverId={serverId} sources={NO_SOURCES} display={display} />;
|
||||
}
|
||||
return (
|
||||
<PinnedUsageItem
|
||||
@@ -56,11 +70,12 @@ function PinnedUsageItem({
|
||||
}) {
|
||||
const { view } = useHostUsage(serverId);
|
||||
const reports = view.kind === "ready" ? view.reports : NO_REPORTS;
|
||||
const items = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
|
||||
return <UsageEntry serverId={serverId} items={items} display={display} />;
|
||||
const sources = useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
|
||||
return <UsageEntry serverId={serverId} sources={sources} display={display} />;
|
||||
}
|
||||
|
||||
function useOpenUsageScreen(): () => void {
|
||||
/** Opens the Usage screen, over the sidebar on compact layouts. */
|
||||
export function useOpenUsageScreen(): () => void {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
return useCallback(() => {
|
||||
@@ -71,11 +86,11 @@ function useOpenUsageScreen(): () => void {
|
||||
|
||||
function UsageEntry({
|
||||
serverId,
|
||||
items,
|
||||
sources,
|
||||
display,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
items: readonly PinnedUsageWindow[];
|
||||
sources: readonly PinnedUsageSource[];
|
||||
display: UsageDisplay;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -97,12 +112,12 @@ function UsageEntry({
|
||||
}, [openUsageScreen, usesSheet]);
|
||||
|
||||
const trigger =
|
||||
items.length > 0 ? (
|
||||
<PinnedUsageTrigger label={label} items={items} onPress={handlePress} />
|
||||
sources.length > 0 ? (
|
||||
<PinnedUsageTrigger label={label} sources={sources} onPress={handlePress} />
|
||||
) : (
|
||||
<SidebarHeaderRow
|
||||
variant="inline"
|
||||
icon={Gauge}
|
||||
icon={CircleGauge}
|
||||
label={label}
|
||||
onPress={handlePress}
|
||||
testID="sidebar-usage"
|
||||
@@ -168,72 +183,197 @@ function HostUsageSheet({
|
||||
);
|
||||
}
|
||||
|
||||
function pinnedUsageLabel(label: string, items: readonly PinnedUsageWindow[]): string {
|
||||
return `${label}: ${items.map((item) => item.label).join(", ")}`;
|
||||
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
|
||||
const windows = sources.flatMap((source) => source.windows);
|
||||
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
|
||||
}
|
||||
|
||||
function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return hovered ? [styles.trigger, styles.triggerHovered] : styles.trigger;
|
||||
}
|
||||
|
||||
function layoutWidth(event: LayoutChangeEvent): number {
|
||||
return event.nativeEvent.layout.width;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line: each account's icon, then per window a meter and "31% 5h". Off-screen copies measure
|
||||
* the line with labels and with percents alone; the richest layout that fits is shown, and what
|
||||
* it leaves out takes no space at all.
|
||||
*/
|
||||
function PinnedUsageTrigger({
|
||||
label,
|
||||
items,
|
||||
sources,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
items: readonly PinnedUsageWindow[];
|
||||
sources: readonly PinnedUsageSource[];
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const [available, setAvailable] = useState<number | null>(null);
|
||||
const [labelsWidth, setLabelsWidth] = useState<number | null>(null);
|
||||
const [percentsWidth, setPercentsWidth] = useState<number | null>(null);
|
||||
const handleAvailableLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setAvailable(layoutWidth(event));
|
||||
}, []);
|
||||
const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setLabelsWidth(layoutWidth(event));
|
||||
}, []);
|
||||
const handlePercentsLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setPercentsWidth(layoutWidth(event));
|
||||
}, []);
|
||||
const layout = choosePinnedUsageLayout({
|
||||
available,
|
||||
labelsWidth,
|
||||
percentsWidth,
|
||||
windowCount: sources.reduce((count, source) => count + source.windows.length, 0),
|
||||
});
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={pinnedUsageLabel(label, items)}
|
||||
accessibilityLabel={pinnedUsageLabel(label, sources)}
|
||||
style={triggerStyle}
|
||||
testID="sidebar-usage"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<View key={item.key} style={styles.item} testID="sidebar-usage-pinned-window">
|
||||
<UsageSourceIcon svg={item.icon} size={14} />
|
||||
<Text style={styles.percent} numberOfLines={1}>
|
||||
{item.percentText}
|
||||
</Text>
|
||||
<View style={styles.line} onLayout={handleAvailableLayout}>
|
||||
<PinnedUsageLine sources={sources} layout={layout} />
|
||||
</View>
|
||||
<View
|
||||
style={styles.measure}
|
||||
pointerEvents="none"
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
aria-hidden
|
||||
>
|
||||
<View style={styles.measured} onLayout={handleLabelsLayout}>
|
||||
<PinnedUsageLine sources={sources} layout="labels" measuring />
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.measured} onLayout={handlePercentsLayout}>
|
||||
<PinnedUsageLine sources={sources} layout="percents" measuring />
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The line's items as direct children of one row, so every meter shares the leftover width
|
||||
* equally.
|
||||
*/
|
||||
function PinnedUsageLine({
|
||||
sources,
|
||||
layout,
|
||||
measuring = false,
|
||||
}: {
|
||||
sources: readonly PinnedUsageSource[];
|
||||
layout: PinnedUsageLayout;
|
||||
measuring?: boolean;
|
||||
}) {
|
||||
return sources.map((source, sourceIndex) => (
|
||||
<Fragment key={source.key}>
|
||||
<View
|
||||
style={sourceIndex === 0 ? null : styles.sourceGap}
|
||||
testID={measuring ? undefined : "sidebar-usage-source"}
|
||||
>
|
||||
<UsageSourceIcon svg={source.icon} size={14} />
|
||||
</View>
|
||||
{source.windows.map((window, windowIndex) => {
|
||||
const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap;
|
||||
return (
|
||||
<Fragment key={window.key}>
|
||||
{layout === "meters" ? (
|
||||
<View style={[styles.meterSlot, leadStyle]}>
|
||||
<UsageMeter
|
||||
percent={window.percent}
|
||||
tone={window.tone}
|
||||
quiet
|
||||
style={styles.meter}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<Text
|
||||
style={[styles.percent, layout === "meters" ? null : leadStyle]}
|
||||
numberOfLines={1}
|
||||
testID={measuring ? undefined : "sidebar-usage-pinned-window"}
|
||||
>
|
||||
{window.percentText}
|
||||
{layout === "percents" || window.shortLabel === "" ? null : (
|
||||
<Text style={styles.windowLabel}>{` ${window.shortLabel}`}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
// A wrapping row packs its lines at the top; center them in the 28px row instead.
|
||||
alignContent: "center",
|
||||
columnGap: theme.spacing[3],
|
||||
rowGap: theme.spacing[1],
|
||||
overflow: "hidden",
|
||||
// Same row geometry and leading rail as Add project and the footer icons.
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
item: {
|
||||
line: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
overflow: "hidden",
|
||||
},
|
||||
// Wide enough that nothing inside wraps or shrinks; each child reports its natural width.
|
||||
measure: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 10_000,
|
||||
opacity: 0,
|
||||
},
|
||||
measured: {
|
||||
alignSelf: "flex-start",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
sourceGap: {
|
||||
marginLeft: theme.spacing[3],
|
||||
},
|
||||
iconGap: {
|
||||
marginLeft: theme.spacing[1.5],
|
||||
},
|
||||
windowGap: {
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
// Every slot takes an equal share of the leftover width, so the line fills the row.
|
||||
meterSlot: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
flexBasis: 0,
|
||||
minWidth: MIN_METER_WIDTH,
|
||||
marginRight: METER_GAP,
|
||||
},
|
||||
meter: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
percent: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
windowLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sheetBody: {
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { UsageTone } from "./types";
|
||||
import { usedPercent } from "./model";
|
||||
import type { UsageTone, UsageWindow } from "./types";
|
||||
|
||||
export function deriveTone(usedPct: number | null | undefined): UsageTone {
|
||||
/** The source's tone for a window, or one derived from how much of it is used. */
|
||||
export function windowTone(window: UsageWindow): UsageTone {
|
||||
if (window.tone) return window.tone;
|
||||
const usedPct = usedPercent(window);
|
||||
if (usedPct == null) return "default";
|
||||
if (usedPct > 90) return "danger";
|
||||
if (usedPct >= 70) return "warning";
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct, formatDisplayPct, formatResetLabel } from "./format";
|
||||
import { displayPercent, usageWindowRowLabel, usedPercent } from "./model";
|
||||
import { formatDisplayPct, formatResetLabel } from "./format";
|
||||
import { UsageMeter } from "./meter";
|
||||
import { displayPercent, usageWindowRowLabel } from "./model";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import { deriveTone } from "./tone";
|
||||
import { windowTone } from "./tone";
|
||||
import type { UsageTone, UsageWindow } from "./types";
|
||||
|
||||
function fillToneStyle(tone: UsageTone) {
|
||||
switch (tone) {
|
||||
case "ok":
|
||||
return styles.fillOk;
|
||||
case "warning":
|
||||
return styles.fillWarning;
|
||||
case "danger":
|
||||
return styles.fillDanger;
|
||||
default:
|
||||
return styles.fillDefault;
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
|
||||
// so a hover never reads as the selection. Pinned rows do not react to hover.
|
||||
function highlightStyle(pinned: boolean, hovered: boolean) {
|
||||
@@ -43,15 +31,8 @@ export function UsageWindowBar({
|
||||
pinLabel: string;
|
||||
pinTestID: string;
|
||||
}) {
|
||||
const usedPct = usedPercent(window);
|
||||
const shownPct = displayPercent(window, displayAs);
|
||||
const tone = window.tone ?? deriveTone(usedPct);
|
||||
|
||||
const fillWidth = clampPct(shownPct ?? 0);
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }],
|
||||
[fillWidth, tone],
|
||||
);
|
||||
const tone = windowTone(window);
|
||||
|
||||
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
|
||||
const trailing = isAtRisk
|
||||
@@ -80,7 +61,8 @@ export function UsageWindowBar({
|
||||
value={value}
|
||||
trailing={trailing}
|
||||
isAtRisk={isAtRisk}
|
||||
fillStyle={fillStyle}
|
||||
percent={shownPct ?? 0}
|
||||
tone={tone}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -93,14 +75,16 @@ function WindowRowContent({
|
||||
value,
|
||||
trailing,
|
||||
isAtRisk,
|
||||
fillStyle,
|
||||
percent,
|
||||
tone,
|
||||
}: {
|
||||
highlight: StyleProp<ViewStyle>;
|
||||
label: string;
|
||||
value: string;
|
||||
trailing: string | null | undefined;
|
||||
isAtRisk: boolean;
|
||||
fillStyle: StyleProp<ViewStyle>;
|
||||
percent: number;
|
||||
tone: UsageTone;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -116,9 +100,7 @@ function WindowRowContent({
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.track}>
|
||||
<View style={fillStyle} />
|
||||
</View>
|
||||
<UsageMeter percent={percent} tone={tone} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -174,26 +156,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.statusDanger,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
track: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
fillDefault: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
fillOk: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
fillWarning: {
|
||||
backgroundColor: theme.colors.statusWarning,
|
||||
},
|
||||
fillDanger: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user