* feat(schedules): per-run workspaces, isolation and archive controls Scheduled agents never appeared in the sidebar: each schedule reused one stamped workspace and archived the run's agent on completion. Now every run mints its own workspace — the entity the sidebar shows — with per-schedule controls for isolation (local or worktree) and whether to archive that workspace when the run finishes. The schedule form is rebuilt on a non-React form model (open/commands/close) to end the flicker, edit-into-create contamination, and multi-host hydration bugs that came from effect-choreographed shared state. That lands the reusable foundations it needed: a form kit over a single control-geometry owner, a data-access taxonomy (replica/snapshot/fetch) with real load-state semantics, and lint/boundary guardrails so new screens inherit the paved road. Also fixes desktop combobox popovers truncating options when the trigger is narrower than the content. New schedule protocol fields are optional on both create and update, so old and new clients/daemons stay compatible. * test(app): complete mock themes for control-geometry tokens switch and terminal-profile-edit-modal now style through createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50], and theme.colors.borderAccent. The hand-rolled mock themes in these two suites lacked those tokens, so the eager StyleSheet.create mock threw at import (collection error), not on any assertion. Add the missing tokens. * fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration - Server: archive the run's workspace even when agent creation fails, so a misconfigured provider no longer orphans a sidebar workspace/worktree. - Edit form: keep a stored worktree isolation while worktree eligibility is still resolving, instead of silently downgrading a saved schedule to local. - Schedules screen: show the load error/retry UI when every host fails, rather than spinning forever behind the loading branch. - Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the sheet closes instead of re-opening. - Form model: apply late-loading saved preferences to untouched create-mode fields (never to edited schedules or user-modified fields). * fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety - Push-router: re-send terminal and checkout-diff subscriptions after a reconnect; the old per-hook effect resubscribed on isConnected and the extracted router did not, so daemon restarts silently stopped terminals_changed pushes (root cause of the terminal-activity e2e failures). - Server: pass the run's source repo root when archiving worktree runs so the worktree is unregistered (git worktree remove/prune), not just deleted; also archive the workspace when agent creation fails even with archive-off (an agentless workspace has nothing to inspect). - Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the Android native path too; replace the JSDOM component test with a real Playwright dismiss spec and inline the trivial dismiss decision. - Form model: editing an interval schedule no longer rewrites its cadence to cron unless the cadence UI is touched (90-minute intervals have no cron equivalent); switching projects clears stale provider/model state and gates submit until the new host's snapshot resolves. * chore(lint): drop custom lint wrapper and boundary script, plain oxlint The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports). The receiver-sensitive checks the custom script enforced are not worth a parallel lint pipeline; if they come back it will be as oxlint rules. * fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations - Persist the run's workspace/agent ids on the running-run record so a daemon crash mid-run can be recovered: restart archives the interrupted run's workspace under the same policy as normal cleanup (agentless always archives, otherwise archiveOnFinish is respected). - Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on hosts without workspaceMultiplicity — an old daemon ignores the fields, so offering the switch there would lie. - Optimistic pause/resume/delete no longer throw when the schedules cache holds a still-connecting entry alongside loaded data. - Mode field is gated on provider mode options, not a selected model, so model-less providers can pick their advertised modes.
1073 lines
31 KiB
TypeScript
1073 lines
31 KiB
TypeScript
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
type GestureResponderEvent,
|
|
type PressableStateCallbackType,
|
|
} from "react-native";
|
|
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
|
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
|
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
|
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
import {
|
|
Combobox,
|
|
ComboboxItem,
|
|
type ComboboxOption,
|
|
type ComboboxProps,
|
|
} from "@/components/ui/combobox";
|
|
import { getProviderIcon } from "@/components/provider-icons";
|
|
import {
|
|
buildSelectedTriggerLabel,
|
|
filterAndRankModelRows,
|
|
getAllProviderModelRows,
|
|
getProviderModelRows,
|
|
resolveSelectedModelLabel,
|
|
type ProviderSelectionModelRow,
|
|
type ProviderSelectorProvider,
|
|
} from "@/provider-selection/provider-selection";
|
|
|
|
const IS_WEB = platformIsWeb;
|
|
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
|
|
|
function noop() {}
|
|
|
|
function favoriteButtonStyle({
|
|
hovered,
|
|
pressed,
|
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [
|
|
styles.favoriteButton,
|
|
Boolean(hovered) && styles.favoriteButtonHovered,
|
|
pressed && styles.favoriteButtonPressed,
|
|
];
|
|
}
|
|
|
|
function drillDownRowStyle({
|
|
hovered,
|
|
pressed,
|
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [
|
|
styles.drillDownRow,
|
|
Boolean(hovered) && styles.drillDownRowHovered,
|
|
pressed && styles.drillDownRowPressed,
|
|
];
|
|
}
|
|
|
|
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
|
|
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
|
|
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
|
|
const DESKTOP_MODEL_ROW_HEIGHT = 40;
|
|
|
|
const ThemedAlertTriangle = withUnistyles(AlertTriangle);
|
|
const ThemedChevronRight = withUnistyles(ChevronRight);
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
const ThemedSearch = withUnistyles(Search);
|
|
const ThemedSettings = withUnistyles(Settings);
|
|
const ThemedStar = withUnistyles(Star);
|
|
|
|
const foregroundMutedMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
|
|
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
|
|
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
|
|
});
|
|
|
|
const favoriteStarMapping =
|
|
(isFavorite: boolean, hovered: boolean) =>
|
|
(theme: Theme): { color: string; fill: string } => {
|
|
const favoriteColor = theme.colors.palette.amber[500];
|
|
if (isFavorite) {
|
|
return { color: favoriteColor, fill: favoriteColor };
|
|
}
|
|
return {
|
|
color: hovered ? theme.colors.foregroundMuted : theme.colors.border,
|
|
fill: "transparent",
|
|
};
|
|
};
|
|
|
|
type ProviderGlyphTone = "muted" | "foreground";
|
|
|
|
function ProviderGlyph({
|
|
provider,
|
|
size,
|
|
tone = "muted",
|
|
}: {
|
|
provider: string;
|
|
size: number;
|
|
tone?: ProviderGlyphTone;
|
|
}) {
|
|
const Icon = getProviderIcon(provider);
|
|
const color =
|
|
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
|
|
return <Icon size={size} color={color} />;
|
|
}
|
|
|
|
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
|
|
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
|
|
return <ThemedSettings size={ICON_SIZE.sm} uniProps={uniProps} />;
|
|
}
|
|
|
|
function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) {
|
|
const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]);
|
|
return <ThemedStar size={ICON_SIZE.md} uniProps={uniProps} />;
|
|
}
|
|
|
|
type SelectorView =
|
|
| { kind: "all" }
|
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
|
|
|
interface CombinedModelSelectorProps {
|
|
providers: ProviderSelectorProvider[];
|
|
selectedProvider: string;
|
|
selectedModel: string;
|
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
|
isLoading: boolean;
|
|
favoriteKeys?: Set<string>;
|
|
onToggleFavorite?: (provider: string, modelId: 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;
|
|
}
|
|
|
|
interface SelectorContentProps {
|
|
view: SelectorView;
|
|
providers: ProviderSelectorProvider[];
|
|
selectedProvider: string;
|
|
selectedModel: string;
|
|
searchQuery: string;
|
|
favoriteKeys: Set<string>;
|
|
onSelect: (provider: string, modelId: string) => void;
|
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
|
onRetryProvider?: (provider: AgentProvider) => void;
|
|
isRetryingProvider: boolean;
|
|
}
|
|
|
|
function normalizeSearchQuery(value: string): string {
|
|
return value.trim().toLowerCase();
|
|
}
|
|
|
|
function sortFavoritesFirst(
|
|
rows: ProviderSelectionModelRow[],
|
|
favoriteKeys: Set<string>,
|
|
): ProviderSelectionModelRow[] {
|
|
const favorites: ProviderSelectionModelRow[] = [];
|
|
const rest: ProviderSelectionModelRow[] = [];
|
|
for (const row of rows) {
|
|
if (favoriteKeys.has(row.favoriteKey)) {
|
|
favorites.push(row);
|
|
} else {
|
|
rest.push(row);
|
|
}
|
|
}
|
|
return [...favorites, ...rest];
|
|
}
|
|
|
|
function ModelRow({
|
|
row,
|
|
isSelected,
|
|
isFavorite,
|
|
elevated = false,
|
|
onPress,
|
|
onToggleFavorite,
|
|
}: {
|
|
row: ProviderSelectionModelRow;
|
|
isSelected: boolean;
|
|
isFavorite: boolean;
|
|
elevated?: boolean;
|
|
onPress: () => void;
|
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
const handleToggleFavorite = useCallback(
|
|
(event: GestureResponderEvent) => {
|
|
event.stopPropagation();
|
|
onToggleFavorite?.(row.provider, row.modelId);
|
|
},
|
|
[onToggleFavorite, row.modelId, row.provider],
|
|
);
|
|
|
|
const leadingSlot = useMemo(
|
|
() => <ProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
|
|
[row.provider],
|
|
);
|
|
const trailingSlot = useMemo(
|
|
() =>
|
|
onToggleFavorite ? (
|
|
<Pressable
|
|
onPress={handleToggleFavorite}
|
|
hitSlop={8}
|
|
style={favoriteButtonStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
isFavorite ? t("modelSelector.unfavoriteModel") : t("modelSelector.favoriteModel")
|
|
}
|
|
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
|
>
|
|
{({ hovered }) => <FavoriteStar isFavorite={isFavorite} hovered={Boolean(hovered)} />}
|
|
</Pressable>
|
|
) : null,
|
|
[onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t],
|
|
);
|
|
|
|
return (
|
|
<ComboboxItem
|
|
label={row.modelLabel}
|
|
description={row.description}
|
|
selected={isSelected}
|
|
elevated={elevated}
|
|
onPress={onPress}
|
|
leadingSlot={leadingSlot}
|
|
trailingSlot={trailingSlot}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface SelectableModelRowProps {
|
|
row: ProviderSelectionModelRow;
|
|
isSelected: boolean;
|
|
isFavorite: boolean;
|
|
elevated?: boolean;
|
|
onSelect: (provider: string, modelId: string) => void;
|
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
}
|
|
|
|
function SelectableModelRow({
|
|
row,
|
|
isSelected,
|
|
isFavorite,
|
|
elevated,
|
|
onSelect,
|
|
onToggleFavorite,
|
|
}: SelectableModelRowProps) {
|
|
const handlePress = useCallback(() => {
|
|
onSelect(row.provider, row.modelId);
|
|
}, [onSelect, row.provider, row.modelId]);
|
|
return (
|
|
<ModelRow
|
|
row={row}
|
|
isSelected={isSelected}
|
|
isFavorite={isFavorite}
|
|
elevated={elevated}
|
|
onPress={handlePress}
|
|
onToggleFavorite={onToggleFavorite}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function FavoritesSection({
|
|
favoriteRows,
|
|
selectedProvider,
|
|
selectedModel,
|
|
favoriteKeys,
|
|
onSelect,
|
|
onToggleFavorite,
|
|
}: {
|
|
favoriteRows: ProviderSelectionModelRow[];
|
|
selectedProvider: string;
|
|
selectedModel: string;
|
|
favoriteKeys: Set<string>;
|
|
onSelect: (provider: string, modelId: string) => void;
|
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (favoriteRows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View style={styles.favoritesContainer}>
|
|
<View style={styles.sectionHeading}>
|
|
<Text style={styles.sectionHeadingText}>{t("modelSelector.favorites")}</Text>
|
|
</View>
|
|
{favoriteRows.map((row) => (
|
|
<SelectableModelRow
|
|
key={row.favoriteKey}
|
|
row={row}
|
|
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
|
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
|
elevated
|
|
onSelect={onSelect}
|
|
onToggleFavorite={onToggleFavorite}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface GroupProviderButtonProps {
|
|
provider: ProviderSelectorProvider;
|
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
|
}
|
|
|
|
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
|
return [
|
|
styles.rowIconButton,
|
|
Boolean(hovered) && styles.rowIconButtonHovered,
|
|
pressed && styles.rowIconButtonPressed,
|
|
];
|
|
}
|
|
|
|
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
|
const { t } = useTranslation();
|
|
const selection = provider.modelSelection;
|
|
|
|
const handlePress = useCallback(() => {
|
|
onDrillDown(provider.id, provider.label);
|
|
}, [onDrillDown, provider.id, provider.label]);
|
|
|
|
let stateNode: React.ReactNode;
|
|
if (selection.kind === "models") {
|
|
const count = selection.rows.length;
|
|
stateNode = (
|
|
<Text style={styles.drillDownCount}>
|
|
{t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", {
|
|
count,
|
|
})}
|
|
</Text>
|
|
);
|
|
} else if (selection.kind === "loading") {
|
|
stateNode = (
|
|
<View style={styles.rowStateInline}>
|
|
<View style={styles.rowSpinner}>
|
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
|
</View>
|
|
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
|
</View>
|
|
);
|
|
} else {
|
|
stateNode = (
|
|
<View style={styles.rowStateInline}>
|
|
<ThemedAlertTriangle size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
|
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
|
<ProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />
|
|
<Text style={styles.drillDownText}>{provider.label}</Text>
|
|
<View style={styles.drillDownTrailing}>
|
|
{stateNode}
|
|
<ThemedChevronRight size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function GroupedProviderRows({
|
|
providers,
|
|
onDrillDown,
|
|
}: {
|
|
providers: ProviderSelectorProvider[];
|
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
|
}) {
|
|
return (
|
|
<View>
|
|
{providers.map((provider, index) => (
|
|
<View key={provider.id}>
|
|
{index > 0 ? <View style={styles.separator} /> : null}
|
|
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
|
|
</View>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ProviderModelRows({
|
|
rows,
|
|
selectedProvider,
|
|
selectedModel,
|
|
favoriteKeys,
|
|
onSelect,
|
|
onToggleFavorite,
|
|
normalizedQuery,
|
|
}: {
|
|
rows: ProviderSelectionModelRow[];
|
|
selectedProvider: string;
|
|
selectedModel: string;
|
|
favoriteKeys: Set<string>;
|
|
onSelect: (provider: string, modelId: string) => void;
|
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
normalizedQuery: string;
|
|
}) {
|
|
const isMobile = useIsCompactFormFactor();
|
|
const useVirtualizedList = isMobile && isNative;
|
|
const displayRows = useMemo(
|
|
() => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)),
|
|
[favoriteKeys, normalizedQuery, rows],
|
|
);
|
|
const renderItem = useCallback(
|
|
({ item }: { item: ProviderSelectionModelRow }) => (
|
|
<SelectableModelRow
|
|
row={item}
|
|
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
|
isFavorite={favoriteKeys.has(item.favoriteKey)}
|
|
onSelect={onSelect}
|
|
onToggleFavorite={onToggleFavorite}
|
|
/>
|
|
),
|
|
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
|
);
|
|
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
|
|
|
|
if (useVirtualizedList) {
|
|
return (
|
|
<BottomSheetFlatList
|
|
data={displayRows}
|
|
renderItem={renderItem}
|
|
keyExtractor={keyExtractor}
|
|
style={styles.virtualizedModelList}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={styles.virtualizedModelListContent}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View>
|
|
{displayRows.map((row) => (
|
|
<View key={row.favoriteKey}>{renderItem({ item: row })}</View>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ProviderErrorEmptyState({
|
|
providerId,
|
|
message,
|
|
onRetryProvider,
|
|
isRetryingProvider,
|
|
}: {
|
|
providerId: string;
|
|
message: string;
|
|
onRetryProvider?: (provider: AgentProvider) => void;
|
|
isRetryingProvider: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const handleRetry = useCallback(() => {
|
|
onRetryProvider?.(providerId);
|
|
}, [onRetryProvider, providerId]);
|
|
return (
|
|
<View style={styles.emptyState}>
|
|
<ThemedAlertTriangle size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
|
<Text style={styles.emptyStateText}>{message}</Text>
|
|
{onRetryProvider ? (
|
|
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
|
{isRetryingProvider ? t("modelSelector.retrying") : t("modelSelector.retry")}
|
|
</Button>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SelectorContent({
|
|
view,
|
|
providers,
|
|
selectedProvider,
|
|
selectedModel,
|
|
searchQuery,
|
|
favoriteKeys,
|
|
onSelect,
|
|
onToggleFavorite,
|
|
onDrillDown,
|
|
onRetryProvider,
|
|
isRetryingProvider,
|
|
}: SelectorContentProps) {
|
|
const { t } = useTranslation();
|
|
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
|
const selectedViewProvider = useMemo(
|
|
() =>
|
|
view.kind === "provider"
|
|
? providers.find((provider) => provider.id === view.providerId)
|
|
: null,
|
|
[providers, view],
|
|
);
|
|
const visibleRows = useMemo(
|
|
() =>
|
|
selectedViewProvider
|
|
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
|
|
: [],
|
|
[normalizedQuery, selectedViewProvider],
|
|
);
|
|
const favoriteRows = useMemo(
|
|
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
|
[favoriteKeys, providers],
|
|
);
|
|
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
|
const emptyState = (
|
|
<View style={styles.emptyState}>
|
|
<ThemedSearch size={ICON_SIZE.md} uniProps={foregroundMutedMapping} />
|
|
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
|
</View>
|
|
);
|
|
|
|
if (view.kind === "provider") {
|
|
if (!selectedViewProvider) {
|
|
return emptyState;
|
|
}
|
|
const drillSelection = selectedViewProvider.modelSelection;
|
|
if (drillSelection.kind === "loading") {
|
|
return (
|
|
<View style={styles.emptyState}>
|
|
<View style={styles.rowSpinner}>
|
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
|
</View>
|
|
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (drillSelection.kind === "error") {
|
|
return (
|
|
<ProviderErrorEmptyState
|
|
providerId={view.providerId}
|
|
message={drillSelection.message}
|
|
onRetryProvider={onRetryProvider}
|
|
isRetryingProvider={isRetryingProvider}
|
|
/>
|
|
);
|
|
}
|
|
if (visibleRows.length === 0) {
|
|
return emptyState;
|
|
}
|
|
|
|
return (
|
|
<ProviderModelRows
|
|
rows={visibleRows}
|
|
selectedProvider={selectedProvider}
|
|
selectedModel={selectedModel}
|
|
favoriteKeys={favoriteKeys}
|
|
onSelect={onSelect}
|
|
onToggleFavorite={onToggleFavorite}
|
|
normalizedQuery={normalizedQuery}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View>
|
|
<FavoritesSection
|
|
favoriteRows={favoriteRows}
|
|
selectedProvider={selectedProvider}
|
|
selectedModel={selectedModel}
|
|
favoriteKeys={favoriteKeys}
|
|
onSelect={onSelect}
|
|
onToggleFavorite={onToggleFavorite}
|
|
/>
|
|
|
|
{providers.length > 0 ? (
|
|
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
|
|
) : null}
|
|
|
|
{!hasResults ? emptyState : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function CombinedModelSelector({
|
|
providers,
|
|
selectedProvider,
|
|
selectedModel,
|
|
onSelect,
|
|
isLoading,
|
|
favoriteKeys = new Set<string>(),
|
|
onToggleFavorite,
|
|
renderTrigger,
|
|
onOpen,
|
|
onClose,
|
|
onRetryProvider,
|
|
isRetryingProvider = false,
|
|
disabled = false,
|
|
serverId = null,
|
|
desktopPlacement,
|
|
desktopMinWidth,
|
|
triggerFill = false,
|
|
}: CombinedModelSelectorProps) {
|
|
const { t } = useTranslation();
|
|
const anchorRef = useRef<View>(null);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
|
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
|
|
|
// Single-provider mode: only one provider → skip Level 1 entirely
|
|
const singleProviderView = useMemo<SelectorView | null>(() => {
|
|
if (providers.length !== 1) return null;
|
|
const provider = providers[0];
|
|
if (!provider) return null;
|
|
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
|
|
}, [providers]);
|
|
|
|
const computeInitialView = useCallback((): SelectorView => {
|
|
if (singleProviderView) return singleProviderView;
|
|
|
|
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
|
|
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
|
|
const provider = providers.find((entry) => entry.id === selectedProvider);
|
|
if (provider)
|
|
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
|
|
}
|
|
|
|
return { kind: "all" };
|
|
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providers]);
|
|
|
|
const handleOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
setIsOpen(open);
|
|
setView(computeInitialView());
|
|
if (open) {
|
|
onOpen?.();
|
|
} else {
|
|
setSearchQuery("");
|
|
bumpSearchResetKey();
|
|
onClose?.();
|
|
}
|
|
},
|
|
[onOpen, onClose, computeInitialView],
|
|
);
|
|
|
|
const handleSelect = useCallback(
|
|
(provider: string, modelId: string) => {
|
|
onSelect(provider, modelId);
|
|
setIsOpen(false);
|
|
setSearchQuery("");
|
|
bumpSearchResetKey();
|
|
},
|
|
[onSelect],
|
|
);
|
|
|
|
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
|
|
|
const selectedModelLabel = useMemo(() => {
|
|
return resolveSelectedModelLabel({
|
|
providers,
|
|
selectedProvider,
|
|
selectedModel,
|
|
isLoading,
|
|
});
|
|
}, [isLoading, providers, selectedModel, selectedProvider]);
|
|
|
|
const desktopFixedHeight = useMemo(() => {
|
|
if (view.kind !== "provider") {
|
|
return undefined;
|
|
}
|
|
const provider = providers.find((entry) => entry.id === view.providerId);
|
|
if (!provider || provider.modelSelection.kind !== "models") {
|
|
return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT;
|
|
}
|
|
const modelCount = getProviderModelRows(provider).length;
|
|
return Math.min(
|
|
Math.max(
|
|
DESKTOP_PROVIDER_VIEW_MIN_HEIGHT,
|
|
DESKTOP_PROVIDER_VIEW_BASE_HEIGHT + modelCount * DESKTOP_MODEL_ROW_HEIGHT,
|
|
),
|
|
DESKTOP_PROVIDER_VIEW_MAX_HEIGHT,
|
|
);
|
|
}, [providers, view]);
|
|
|
|
const triggerLabel = useMemo(() => {
|
|
if (
|
|
selectedModelLabel === t("modelSelector.loading") ||
|
|
selectedModelLabel === t("modelSelector.selectModel")
|
|
) {
|
|
return selectedModelLabel;
|
|
}
|
|
|
|
return buildSelectedTriggerLabel(selectedModelLabel);
|
|
}, [selectedModelLabel, t]);
|
|
|
|
useEffect(() => {
|
|
if (platformIsWeb) {
|
|
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 }) => {
|
|
// Fill mode: transparent full-width passthrough. The trigger paints its own
|
|
// hover/pressed state from the args, so the wrapper must not double-paint.
|
|
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 handleBackToAll = useCallback(() => {
|
|
setView({ kind: "all" });
|
|
setSearchQuery("");
|
|
bumpSearchResetKey();
|
|
}, []);
|
|
|
|
const handleDrillDown = useCallback((providerId: string, providerLabel: string) => {
|
|
setView({ kind: "provider", providerId, providerLabel });
|
|
}, []);
|
|
|
|
const handleSearchQueryChange = useCallback((value: string) => {
|
|
setSearchQuery(value);
|
|
}, []);
|
|
|
|
const openProviderSettings = useCallback(() => {
|
|
if (!serverId || view.kind !== "provider") return;
|
|
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
|
|
}, [serverId, view]);
|
|
|
|
const sheetHeader = useMemo<SheetHeader>(() => {
|
|
if (view.kind === "all") {
|
|
return { title: t("modelSelector.title") };
|
|
}
|
|
const headerActions = (
|
|
<Pressable
|
|
onPress={openProviderSettings}
|
|
disabled={!serverId}
|
|
hitSlop={8}
|
|
style={iconButtonStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("modelSelector.openProviderSettings", {
|
|
provider: view.providerLabel,
|
|
})}
|
|
testID={`selector-header-settings-${view.providerId}`}
|
|
>
|
|
<HeaderSettingsIcon disabled={!serverId} />
|
|
</Pressable>
|
|
);
|
|
return {
|
|
title: view.providerLabel,
|
|
leading: <ProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />,
|
|
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
|
actions: headerActions,
|
|
search: {
|
|
onChange: handleSearchQueryChange,
|
|
resetKey: `${view.providerId}:${searchResetKey}`,
|
|
placeholder: t("modelSelector.searchPlaceholder"),
|
|
autoFocus: platformIsWeb,
|
|
testID: "model-search-input",
|
|
},
|
|
};
|
|
}, [
|
|
view,
|
|
singleProviderView,
|
|
serverId,
|
|
openProviderSettings,
|
|
handleBackToAll,
|
|
handleSearchQueryChange,
|
|
searchResetKey,
|
|
t,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
{renderTrigger ? (
|
|
<Pressable
|
|
ref={anchorRef}
|
|
collapsable={false}
|
|
disabled={disabled}
|
|
onPress={handleTriggerPress}
|
|
style={triggerStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
|
testID="combined-model-selector"
|
|
>
|
|
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
|
|
renderTrigger({
|
|
selectedModelLabel: 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: selectedModelLabel })}
|
|
testID="combined-model-selector"
|
|
>
|
|
{hasSelectedProvider ? (
|
|
<ProviderGlyph provider={selectedProvider} size={ICON_SIZE.md} />
|
|
) : null}
|
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
|
{triggerLabel}
|
|
</Text>
|
|
</ComboboxTrigger>
|
|
)}
|
|
<Combobox
|
|
options={EMPTY_COMBOBOX_OPTIONS}
|
|
value=""
|
|
onSelect={noop}
|
|
open={isOpen}
|
|
onOpenChange={handleOpenChange}
|
|
anchorRef={anchorRef}
|
|
desktopPlacement={desktopPlacement}
|
|
desktopMinWidth={desktopMinWidth}
|
|
desktopFixedHeight={desktopFixedHeight}
|
|
header={sheetHeader}
|
|
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
|
>
|
|
{isContentReady ? (
|
|
<SelectorContent
|
|
view={view}
|
|
providers={providers}
|
|
selectedProvider={selectedProvider}
|
|
selectedModel={selectedModel}
|
|
searchQuery={searchQuery}
|
|
favoriteKeys={favoriteKeys}
|
|
onSelect={handleSelect}
|
|
onToggleFavorite={onToggleFavorite}
|
|
onDrillDown={handleDrillDown}
|
|
onRetryProvider={onRetryProvider}
|
|
isRetryingProvider={isRetryingProvider}
|
|
/>
|
|
) : (
|
|
<View style={styles.sheetLoadingState}>
|
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
|
|
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
|
</View>
|
|
)}
|
|
</Combobox>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
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,
|
|
},
|
|
triggerPressed: {
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
triggerDisabled: {
|
|
opacity: 0.5,
|
|
},
|
|
triggerText: {
|
|
minWidth: 0,
|
|
flexShrink: 1,
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
customTriggerWrapper: {
|
|
paddingHorizontal: 0,
|
|
paddingVertical: 0,
|
|
height: "auto",
|
|
},
|
|
// Stretch the wrapper (and, via column + stretch, its single child) to the
|
|
// full width of the field, with no background or rounding of its own.
|
|
triggerFill: {
|
|
alignSelf: "stretch",
|
|
flexShrink: 0,
|
|
flexDirection: "column",
|
|
alignItems: "stretch",
|
|
backgroundColor: "transparent",
|
|
borderRadius: 0,
|
|
},
|
|
favoritesContainer: {
|
|
backgroundColor: theme.colors.surface1,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
separator: {
|
|
height: 1,
|
|
backgroundColor: theme.colors.border,
|
|
},
|
|
sectionHeading: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingTop: theme.spacing[2],
|
|
paddingBottom: theme.spacing[1],
|
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
|
},
|
|
sectionHeadingText: {
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
drillDownRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
minHeight: 36,
|
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
|
},
|
|
drillDownRowHovered: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
drillDownRowPressed: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
drillDownText: {
|
|
flex: 1,
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foreground,
|
|
},
|
|
drillDownTrailing: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
},
|
|
drillDownCount: {
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
rowStateInline: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
},
|
|
rowErrorText: {
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foregroundMuted,
|
|
maxWidth: 140,
|
|
},
|
|
rowIconButton: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: theme.borderRadius.full,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
rowSpinner: {
|
|
transform: [{ scale: 0.7 }],
|
|
},
|
|
rowIconButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
rowIconButtonPressed: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
emptyState: {
|
|
paddingVertical: theme.spacing[4],
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
emptyStateText: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
virtualizedModelList: {
|
|
flex: 1,
|
|
},
|
|
virtualizedModelListContent: {
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingTop: theme.spacing[1],
|
|
paddingBottom: theme.spacing[8],
|
|
},
|
|
favoriteButton: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: theme.borderRadius.full,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
favoriteButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
favoriteButtonPressed: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
sheetLoadingState: {
|
|
minHeight: 160,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
sheetLoadingText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
providerIconMuted: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
providerIconForeground: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
}));
|