mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
* feat(app): create agent profiles from the model chooser Restore quick model pinning by creating an agent profile directly from a model row. A trailing plus on unprofiled model rows opens the pre-filled profile form; rows that already name the provider+model show the profile glyph and open it for edit. - seed the profile form model with provider, model, and suggested name - expose useAgentProfileEditor from @/agent-profiles for the write path - hover-refactor model rows per docs/hover.md and add the trailing action - wire the composer desktop selector and compact model sheet (close-then-open) Closes #3529 * fix(app): match favourites star position for model row profile action The star sat after the selection checkmark at the far-right edge. Put the profile create/edit action in the same slot instead of before the checkmark. * fix(app): disambiguate per-model create profile action The per-model + buttons shared the accessible name "Create profile" with the empty-state row, which broke the empty-host picker test (strict mode matched five buttons). Name the action "Create profile from <model>" so it is specific and screen readers get the model context. * fix(app): keep per-model create label out of model row names Naming the action "Create profile from <model>" put the model label into a second button on the row, so regex selectors targeting a model row by name matched both. Use a generic "Create profile from this model" label that neither collides with the empty-state "Create profile" row nor with model row names.
350 lines
10 KiB
TypeScript
350 lines
10 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
|
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
|
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
|
|
import { ModelBrowser, ModelProviderGlyph, useModelBrowser } from "@/components/model-browser";
|
|
import { isNative, isWeb } from "@/constants/platform";
|
|
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
|
|
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
|
|
const foregroundMutedMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
|
|
function noop() {}
|
|
|
|
interface CombinedModelSelectorProps {
|
|
providers: ProviderSelectorProvider[];
|
|
selectedProvider: string;
|
|
selectedModel: string;
|
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
|
isLoading: boolean;
|
|
profiles?: AgentProfilePicker | null;
|
|
onApplyProfile?: (profileId: string) => void;
|
|
onEditProfiles?: () => void;
|
|
onCreateProfile?: (seed: AgentProfileSeed) => void;
|
|
onEditProfile?: (profileId: string) => void;
|
|
renderTrigger?: (input: {
|
|
selectedModelLabel: string;
|
|
onPress: () => void;
|
|
disabled: boolean;
|
|
isOpen: boolean;
|
|
hovered: boolean;
|
|
pressed: boolean;
|
|
}) => React.ReactNode;
|
|
onOpen?: () => void;
|
|
onClose?: () => void;
|
|
onRetryProvider?: (provider: AgentProvider) => void;
|
|
isRetryingProvider?: boolean;
|
|
disabled?: boolean;
|
|
serverId?: string | null;
|
|
desktopPlacement?: ComboboxProps["desktopPlacement"];
|
|
desktopMinWidth?: number;
|
|
/**
|
|
* Render the custom trigger as a full-width form field: the outer Pressable
|
|
* becomes a transparent passthrough that stretches its child edge-to-edge and
|
|
* stops painting its own hover/pressed background and rounded corners. The
|
|
* trigger itself owns the field visuals and reads hovered/pressed to show its
|
|
* active state. Without this the trigger stays a content-width toolbar chip
|
|
* (the composer's layout).
|
|
*/
|
|
triggerFill?: boolean;
|
|
toolbar?: {
|
|
glyphSize: number;
|
|
showCaret: boolean;
|
|
};
|
|
}
|
|
|
|
export function CombinedModelSelector({
|
|
providers,
|
|
selectedProvider,
|
|
selectedModel,
|
|
onSelect,
|
|
isLoading,
|
|
profiles = null,
|
|
onApplyProfile,
|
|
onEditProfiles,
|
|
onCreateProfile,
|
|
onEditProfile,
|
|
renderTrigger,
|
|
onOpen,
|
|
onClose,
|
|
onRetryProvider,
|
|
isRetryingProvider = false,
|
|
disabled = false,
|
|
serverId = null,
|
|
desktopPlacement,
|
|
desktopMinWidth,
|
|
triggerFill = false,
|
|
toolbar,
|
|
}: CombinedModelSelectorProps) {
|
|
const { t } = useTranslation();
|
|
const anchorRef = useRef<View>(null);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isContentReady, setIsContentReady] = useState(isWeb);
|
|
const browser = useModelBrowser({
|
|
providers,
|
|
selectedProvider,
|
|
selectedModel,
|
|
isLoading,
|
|
profiles,
|
|
serverId,
|
|
});
|
|
const { prepareToOpen, reset } = browser;
|
|
|
|
const handleOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
setIsOpen(open);
|
|
if (open) {
|
|
prepareToOpen();
|
|
onOpen?.();
|
|
return;
|
|
}
|
|
reset();
|
|
onClose?.();
|
|
},
|
|
[onClose, onOpen, prepareToOpen, reset],
|
|
);
|
|
|
|
const handleSelect = useCallback(
|
|
(provider: string, modelId: string) => {
|
|
onSelect(provider, modelId);
|
|
handleOpenChange(false);
|
|
},
|
|
[handleOpenChange, onSelect],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isWeb) return () => {};
|
|
if (!isOpen) {
|
|
setIsContentReady(false);
|
|
return () => {};
|
|
}
|
|
const frame = requestAnimationFrame(() => {
|
|
setIsContentReady(true);
|
|
});
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [isOpen]);
|
|
|
|
const handleTriggerPress = useCallback(() => {
|
|
handleOpenChange(!isOpen);
|
|
}, [handleOpenChange, isOpen]);
|
|
|
|
const triggerStyle = useCallback(
|
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
|
if (triggerFill) {
|
|
return [
|
|
styles.trigger,
|
|
styles.customTriggerWrapper,
|
|
styles.triggerFill,
|
|
disabled && styles.triggerDisabled,
|
|
];
|
|
}
|
|
return [
|
|
styles.trigger,
|
|
Boolean(hovered) && styles.triggerHovered,
|
|
(pressed || isOpen) && styles.triggerPressed,
|
|
disabled && styles.triggerDisabled,
|
|
renderTrigger ? styles.customTriggerWrapper : null,
|
|
];
|
|
},
|
|
[disabled, isOpen, renderTrigger, triggerFill],
|
|
);
|
|
|
|
const handleApplyProfile = useCallback(
|
|
(profileId: string) => {
|
|
onApplyProfile?.(profileId);
|
|
handleOpenChange(false);
|
|
},
|
|
[handleOpenChange, onApplyProfile],
|
|
);
|
|
|
|
const handleEditProfiles = useCallback(() => {
|
|
handleOpenChange(false);
|
|
onEditProfiles?.();
|
|
}, [handleOpenChange, onEditProfiles]);
|
|
|
|
const handleCreateProfile = useCallback(
|
|
(seed: AgentProfileSeed) => {
|
|
handleOpenChange(false);
|
|
onCreateProfile?.(seed);
|
|
},
|
|
[handleOpenChange, onCreateProfile],
|
|
);
|
|
|
|
const handleEditProfile = useCallback(
|
|
(profileId: string) => {
|
|
handleOpenChange(false);
|
|
onEditProfile?.(profileId);
|
|
},
|
|
[handleOpenChange, onEditProfile],
|
|
);
|
|
|
|
const selectorBody = isContentReady ? (
|
|
<ModelBrowser
|
|
state={browser}
|
|
onSelect={handleSelect}
|
|
onApplyProfile={handleApplyProfile}
|
|
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
|
|
onCreateProfile={onCreateProfile ? handleCreateProfile : undefined}
|
|
onEditProfile={onEditProfile ? handleEditProfile : undefined}
|
|
onRetryProvider={onRetryProvider}
|
|
isRetryingProvider={isRetryingProvider}
|
|
scrolling={isWeb ? "independent" : "sheet"}
|
|
/>
|
|
) : (
|
|
<View style={styles.sheetLoadingState}>
|
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
|
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
|
</View>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{renderTrigger ? (
|
|
<Pressable
|
|
ref={anchorRef}
|
|
collapsable={false}
|
|
disabled={disabled}
|
|
onPress={handleTriggerPress}
|
|
style={triggerStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("modelSelector.selectedModel", {
|
|
model: browser.selectedModelLabel,
|
|
})}
|
|
testID="combined-model-selector"
|
|
>
|
|
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
|
|
renderTrigger({
|
|
selectedModelLabel: browser.triggerLabel,
|
|
onPress: handleTriggerPress,
|
|
disabled,
|
|
isOpen,
|
|
hovered: Boolean(hovered),
|
|
pressed,
|
|
})
|
|
}
|
|
</Pressable>
|
|
) : (
|
|
<ComboboxTrigger
|
|
ref={anchorRef}
|
|
collapsable={false}
|
|
disabled={disabled}
|
|
onPress={handleTriggerPress}
|
|
style={triggerStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("modelSelector.selectedModel", {
|
|
model: browser.selectedModelLabel,
|
|
})}
|
|
testID="combined-model-selector"
|
|
chevron={toolbar?.showCaret === false ? null : undefined}
|
|
>
|
|
{selectedProvider.trim().length > 0 ? (
|
|
<View style={toolbar?.glyphSize === 20 ? styles.toolbarGlyph20 : styles.toolbarGlyph16}>
|
|
<ModelProviderGlyph
|
|
provider={selectedProvider}
|
|
size={toolbar?.glyphSize ?? ICON_SIZE.md}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
|
{browser.triggerLabel}
|
|
</Text>
|
|
</ComboboxTrigger>
|
|
)}
|
|
<Combobox
|
|
options={EMPTY_COMBOBOX_OPTIONS}
|
|
value=""
|
|
onSelect={noop}
|
|
open={isOpen}
|
|
onOpenChange={handleOpenChange}
|
|
anchorRef={anchorRef}
|
|
desktopPlacement={desktopPlacement}
|
|
desktopMinWidth={desktopMinWidth}
|
|
desktopLockWidth
|
|
desktopFixedHeight={browser.desktopFixedHeight}
|
|
desktopChildrenScrollEnabled={false}
|
|
header={browser.header}
|
|
mobileChildrenScrollEnabled={!browser.isProviderView || !isNative}
|
|
mobileChildrenContentContainerStyle={styles.mobileBrowserContent}
|
|
>
|
|
{selectorBody}
|
|
</Combobox>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
mobileBrowserContent: {
|
|
paddingHorizontal: 0,
|
|
},
|
|
trigger: {
|
|
height: 28,
|
|
minWidth: 0,
|
|
flexShrink: 1,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
backgroundColor: "transparent",
|
|
gap: theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[2],
|
|
borderRadius: theme.borderRadius["2xl"],
|
|
},
|
|
triggerHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
toolbarGlyph16: {
|
|
width: 16,
|
|
height: 16,
|
|
flexShrink: 0,
|
|
},
|
|
toolbarGlyph20: {
|
|
width: 20,
|
|
height: 20,
|
|
flexShrink: 0,
|
|
},
|
|
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,
|
|
},
|
|
customTriggerWrapper: {
|
|
paddingHorizontal: 0,
|
|
paddingVertical: 0,
|
|
height: "auto",
|
|
},
|
|
triggerFill: {
|
|
alignSelf: "stretch",
|
|
flexShrink: 0,
|
|
flexDirection: "column",
|
|
alignItems: "stretch",
|
|
backgroundColor: "transparent",
|
|
borderRadius: 0,
|
|
},
|
|
sheetLoadingState: {
|
|
minHeight: 160,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
sheetLoadingText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.base,
|
|
},
|
|
}));
|