Files
paseo/packages/app/src/composer/agent-controls/model-sheet.tsx
T
Mohamed Boudra 01f9bc8291 Modernize compact headers and sidebar affordances (#6431)
* feat(app): modernize compact headers and sidebar affordances

Use a shared island header with quick tab creation and keep sheet footers inside their visible layout. Opt sidebar rows into touch sizing without changing global appearance settings.

* test(app): match darker light-theme workspace selection

* test(app): align plugin journeys with compact header
2026-10-10 20:53:09 +01:00

458 lines
14 KiB
TypeScript

import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { useProviderIcon } from "@/components/provider-icons";
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
import { resolveModelBrowserScrolling } from "@/components/model-browser-view";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import { resolveModelSheetOpening } from "@/composer/agent-controls/model-sheet-flow";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
const SNAP_POINTS = ["80%", "90%"];
const MODEL_LIST_TOP_INSET = 4;
const MODEL_ROW_STRIDE = 44;
const MODEL_VIEWPORT_VISIBLE_ROWS = 4.5;
const FIXED_MODEL_VIEWPORT_HEIGHT =
MODEL_LIST_TOP_INSET + MODEL_ROW_STRIDE * MODEL_VIEWPORT_VISIBLE_ROWS;
interface CompactModelSheetProps {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
thinkingLabel: string | null;
onSelect: (provider: string, modelId: string) => void;
isLoading: boolean;
profiles?: AgentProfilePicker | null;
onApplyProfile?: (profileId: string) => void;
onEditProfiles?: () => void;
onCreateProfile?: (seed: AgentProfileSeed) => void;
onEditProfile?: (profileId: string) => void;
onOpen?: () => void;
onClose?: () => void;
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
glyphSize: number;
canSwitchProvider: boolean;
children: ReactNode;
}
function shortModelLabel(label: string): string {
const separatorIndex = label.lastIndexOf("/");
return separatorIndex === -1 ? label : label.slice(separatorIndex + 1);
}
function resolveModelSheetProfileActions(
onCreateProfile: ((seed: AgentProfileSeed) => void) | undefined,
onEditProfile: ((profileId: string) => void) | undefined,
close: () => void,
): {
create?: (seed: AgentProfileSeed) => void;
edit?: (profileId: string) => void;
} {
return {
create: onCreateProfile
? (seed: AgentProfileSeed) => {
close();
onCreateProfile(seed);
}
: undefined,
edit: onEditProfile
? (profileId: string) => {
close();
onEditProfile(profileId);
}
: undefined,
};
}
export function CompactModelSheet({
providers,
selectedProvider,
selectedModel,
thinkingLabel,
onSelect,
isLoading,
profiles = null,
onApplyProfile,
onEditProfiles,
onCreateProfile,
onEditProfile,
onOpen,
onClose,
onRetryProvider,
isRetryingProvider = false,
disabled = false,
serverId = null,
glyphSize,
canSwitchProvider,
children,
}: CompactModelSheetProps) {
const { t } = useTranslation();
const usesBottomSheet = useIsCompactFormFactor();
const modelBrowserScrolling = resolveModelBrowserScrolling({
isNative,
isCompact: usesBottomSheet,
});
const [isOpen, setIsOpen] = useState(false);
const [isModelBrowserOpen, setIsModelBrowserOpen] = useState(false);
const availableProviders = useMemo(() => {
if (canSwitchProvider) return providers;
const fixedProvider =
providers.find((entry) => entry.id === selectedProvider) ?? providers[0] ?? null;
return fixedProvider ? [fixedProvider] : [];
}, [canSwitchProvider, providers, selectedProvider]);
const rootBrowser = useModelBrowser({
providers: availableProviders,
selectedProvider,
selectedModel,
isLoading,
autoFocusSearch: isWeb && !usesBottomSheet,
profiles,
serverId,
});
const modelBrowser = useModelBrowser({
providers: availableProviders,
selectedProvider,
selectedModel,
isLoading,
autoFocusSearch: isWeb && !usesBottomSheet,
profiles,
serverId,
});
const ModelIcon = useProviderIcon(selectedProvider, serverId);
const hasSelectedProvider = selectedProvider.trim().length > 0;
const rootHeader = useMemo(
() => ({
...rootBrowser.header,
title: t("modelSelector.selectModel"),
search:
rootBrowser.header.search && !canSwitchProvider
? {
...rootBrowser.header.search,
placeholder: t("modelSelector.searchPlaceholder"),
}
: rootBrowser.header.search,
}),
[canSwitchProvider, rootBrowser.header, t],
);
const open = useCallback(() => {
Keyboard.dismiss();
rootBrowser.showAll();
setIsOpen(true);
onOpen?.();
}, [onOpen, rootBrowser]);
const close = useCallback(() => {
setIsModelBrowserOpen(false);
setIsOpen(false);
rootBrowser.reset();
modelBrowser.reset();
onClose?.();
}, [modelBrowser, onClose, rootBrowser]);
const handleSearchSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider, modelId);
Keyboard.dismiss();
rootBrowser.showAll();
},
[onSelect, rootBrowser],
);
const openModelBrowser = useCallback(() => {
Keyboard.dismiss();
const destination = resolveModelSheetOpening({
canSwitchProvider,
providers: availableProviders,
selectedProvider,
});
if (destination.kind === "all") {
modelBrowser.showAll();
} else {
modelBrowser.drillDown(destination.providerId, destination.providerLabel);
}
setIsModelBrowserOpen(true);
}, [availableProviders, canSwitchProvider, modelBrowser, selectedProvider]);
const closeModelBrowser = useCallback(() => {
setIsModelBrowserOpen(false);
modelBrowser.reset();
}, [modelBrowser]);
const handleBrowserSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider, modelId);
closeModelBrowser();
},
[closeModelBrowser, onSelect],
);
const handleDesktopSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider, modelId);
close();
},
[close, onSelect],
);
const handleApplyProfile = useCallback(
(profileId: string) => {
onApplyProfile?.(profileId);
close();
},
[close, onApplyProfile],
);
const handleEditProfiles = useCallback(() => {
close();
onEditProfiles?.();
}, [close, onEditProfiles]);
const profileActions = resolveModelSheetProfileActions(onCreateProfile, onEditProfile, close);
const toggle = useCallback(() => {
if (isOpen) {
close();
return;
}
open();
}, [close, isOpen, open]);
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.trigger,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
],
[disabled, isOpen],
);
const mobileRootContent = useMemo(
() => (
<View style={styles.mobileRootContent} testID="agent-controls-settings-list">
<View style={styles.controlsContent}>
<AgentControlTrigger
icon={ModelIcon}
surface="sheet"
label={t("modelSelector.model")}
value={rootBrowser.selectedModelLabel}
disabled={disabled}
onPress={openModelBrowser}
accessibilityLabel={t("modelSelector.selectedModel", {
model: rootBrowser.selectedModelLabel,
})}
testID="agent-controls-model"
/>
{children}
</View>
</View>
),
[ModelIcon, children, disabled, openModelBrowser, rootBrowser.selectedModelLabel, t],
);
return (
<>
<ComboboxTrigger
collapsable={false}
disabled={disabled}
onPress={toggle}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.selectedModel", {
model: rootBrowser.selectedModelLabel,
})}
testID="combined-model-selector"
chevron={null}
>
{hasSelectedProvider ? (
<ComposerToolbarGlyph size={glyphSize}>
<ModelIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph>
) : null}
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(rootBrowser.triggerLabel)}
</Text>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
</Text>
) : null}
</View>
</ComboboxTrigger>
<AdaptiveModalSheet
header={rootHeader}
visible={isOpen}
onClose={close}
snapPoints={SNAP_POINTS}
scrollable={false}
contentStyle={styles.sheetBody}
testID="agent-controls-model-sheet"
>
<View
style={[
styles.modelViewport,
usesBottomSheet ? styles.flexibleModelViewport : styles.fixedModelViewport,
]}
testID="agent-controls-model-viewport"
>
<ModelBrowser
state={rootBrowser}
onSelect={usesBottomSheet ? handleSearchSelect : handleDesktopSelect}
onApplyProfile={handleApplyProfile}
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
onCreateProfile={profileActions.create}
onEditProfile={profileActions.edit}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
scrolling={modelBrowserScrolling}
searchAllOnFocus={usesBottomSheet}
rootBrowseContent={usesBottomSheet ? mobileRootContent : undefined}
/>
</View>
{!usesBottomSheet ? (
<>
<View style={styles.modelViewportDivider} />
<ScrollView
style={styles.controlsScroll}
contentContainerStyle={styles.controlsContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID="agent-controls-settings-list"
>
{children}
</ScrollView>
</>
) : null}
</AdaptiveModalSheet>
{usesBottomSheet ? (
<AdaptiveModalSheet
header={modelBrowser.header}
visible={isModelBrowserOpen}
onClose={closeModelBrowser}
snapPoints={SNAP_POINTS}
scrollable={false}
presentation="push"
contentStyle={styles.sheetBody}
testID="agent-controls-model-browser-sheet"
>
<View
style={[styles.modelViewport, styles.flexibleModelViewport]}
testID="agent-controls-model-browser-viewport"
>
<ModelBrowser
state={modelBrowser}
onSelect={handleBrowserSelect}
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
onCreateProfile={profileActions.create}
onEditProfile={profileActions.edit}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
scrolling={modelBrowserScrolling}
searchAllOnFocus
showProfilesSection={false}
/>
</View>
</AdaptiveModalSheet>
) : null}
</>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
backgroundColor: "transparent",
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
triggerPressed: {
backgroundColor: theme.colors.surface0,
},
triggerDisabled: {
opacity: 0.5,
},
triggerText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
triggerLabels: {
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
triggerThinking: {
flexShrink: 0,
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
providerIcon: {
color: theme.colors.foregroundMuted,
},
sheetBody: {
paddingHorizontal: 0,
paddingTop: 0,
paddingBottom: 0,
gap: 0,
},
modelViewport: {
overflow: "hidden",
backgroundColor: theme.colors.surfaceSidebar,
},
flexibleModelViewport: {
flex: 1,
minHeight: 0,
},
fixedModelViewport: {
height: FIXED_MODEL_VIEWPORT_HEIGHT,
minHeight: FIXED_MODEL_VIEWPORT_HEIGHT,
},
modelViewportDivider: {
height: 1,
flexShrink: 0,
backgroundColor: theme.colors.border,
},
controlsScroll: {
flex: 1,
minHeight: 0,
},
mobileRootContent: {
backgroundColor: theme.colors.surfaceSidebar,
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
controlsContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[3],
gap: theme.spacing[1],
},
}));