feat(app): create agent profiles from the model chooser (#3533)

* 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.
This commit is contained in:
Mohamed Boudra
2026-08-19 07:46:47 +02:00
committed by GitHub
parent 9c9a85a0cf
commit e9373c026b
23 changed files with 674 additions and 54 deletions
@@ -0,0 +1,78 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AgentProfileEditModal } from "./settings/agent-profile-edit-modal";
import { generateAgentProfileId } from "./internal/profile-id";
import type { AgentProfileSeed, AgentProfileValue } from "./internal/profile-form-model";
import { useAgentProfiles } from "./internal/use-agent-profiles";
export interface AgentProfileEditorControls {
/** Open the create form pre-filled with provider, model, and suggested name. */
openCreateFromModel: (seed: AgentProfileSeed) => void;
/** Open the edit form for one profile. */
openEdit: (profileId: string) => void;
/** Render once where a sheet can portal; it self-hides until opened. */
element: ReactElement | null;
}
type EditorRequest =
| { mode: "create"; seed?: AgentProfileSeed }
| { mode: "edit"; profile: AgentProfile };
/**
* The model chooser's create/edit surface. Owns the write path so call sites
* never see `saveProfiles` or construct profile records themselves.
*/
export function useAgentProfileEditor(serverId: string | null): AgentProfileEditorControls {
const { profiles, saveProfiles } = useAgentProfiles(serverId);
const [request, setRequest] = useState<EditorRequest | null>(null);
const openCreateFromModel = useCallback((seed: AgentProfileSeed) => {
setRequest({ mode: "create", seed });
}, []);
const openEdit = useCallback(
(profileId: string) => {
const profile = profiles?.find((entry) => entry.id === profileId);
if (!profile) {
return;
}
setRequest({ mode: "edit", profile });
},
[profiles],
);
const close = useCallback(() => setRequest(null), []);
const handleSave = useCallback(
async (value: AgentProfileValue) => {
const current = profiles ?? [];
const editing = request?.mode === "edit" ? request.profile : undefined;
const next: AgentProfile[] = editing
? current.map((entry) => (entry.id === editing.id ? { id: entry.id, ...value } : entry))
: [...current, { id: generateAgentProfileId(), ...value }];
await saveProfiles(next);
setRequest(null);
},
[profiles, request, saveProfiles],
);
const element = useMemo(
() => (
<AgentProfileEditModal
serverId={serverId ?? ""}
visible={request !== null}
mode={request?.mode ?? "create"}
{...(request?.mode === "edit" && request.profile ? { profile: request.profile } : {})}
{...(request?.mode === "create" && request.seed ? { seed: request.seed } : {})}
onClose={close}
onSave={handleSave}
/>
),
[close, handleSave, request, serverId],
);
return useMemo(
() => ({ openCreateFromModel, openEdit, element }),
[openCreateFromModel, openEdit, element],
);
}
+2
View File
@@ -15,6 +15,8 @@
*/ */
export type { AgentProfile } from "@getpaseo/protocol/messages"; export type { AgentProfile } from "@getpaseo/protocol/messages";
export type { MaterializedAgentProfile } from "./internal/materialize-profile"; export type { MaterializedAgentProfile } from "./internal/materialize-profile";
export type { AgentProfileSeed } from "./internal/profile-form-model";
export { useAgentProfileEditor, type AgentProfileEditorControls } from "./agent-profile-editor";
export { useAgentProfiles } from "./internal/use-agent-profiles"; export { useAgentProfiles } from "./internal/use-agent-profiles";
export { export {
useAgentProfilePicker, useAgentProfilePicker,
@@ -82,6 +82,53 @@ describe("openAgentProfileForm", () => {
expect(state.disclosure.showModelField).toBe(false); expect(state.disclosure.showModelField).toBe(false);
}); });
it("seeds create mode from a model seed before the catalog lands", () => {
const model = openAgentProfileForm({
mode: "create",
seed: { provider: "claude", modelId: "claude-opus-5", name: "Opus 5" },
});
const state = model.getState();
expect(state.name).toBe("Opus 5");
expect(state.provider).toBe("claude");
expect(state.modelId).toBe("claude-opus-5");
expect(state.providerDisplay).toEqual({ label: "claude" });
expect(state.modelDisplay).toEqual({ label: "claude-opus-5" });
expect(state.catalogResolution).toBe("idle");
});
it("upgrades a seeded model to catalog labels without touching the selection", () => {
const model = openAgentProfileForm({
mode: "create",
seed: { provider: "claude", modelId: "claude-opus-5", name: "Opus 5" },
});
model.applyProviderCatalog(ENTRIES);
const state = model.getState();
expect(state.providerDisplay).toEqual({ label: "Claude Code" });
expect(state.modelDisplay).toEqual({ label: "Opus 5" });
expect(state.modelId).toBe("claude-opus-5");
expect(state.canSubmit).toBe(true);
expect(state.submitValue).toMatchObject({
name: "Opus 5",
provider: "claude",
model: "claude-opus-5",
});
});
it("ignores the seed in edit mode", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "UI work", provider: "claude", model: "claude-haiku-4-5" },
seed: { provider: "codex", modelId: "gpt-5.2-codex", name: "Nope" },
});
const state = model.getState();
expect(state.name).toBe("UI work");
expect(state.provider).toBe("claude");
expect(state.modelId).toBe("claude-haiku-4-5");
});
it("lists only enabled providers", () => { it("lists only enabled providers", () => {
const model = openWithCatalog({ mode: "create" }); const model = openWithCatalog({ mode: "create" });
@@ -40,9 +40,20 @@ export interface AgentProfileFormOption {
testID: string; testID: string;
} }
/**
* Pre-fill values for create mode. Only used when `mode === "create"`; edit
* mode always seeds from the stored `profile` and ignores this.
*/
export interface AgentProfileSeed {
provider: string;
modelId?: string;
name?: string;
}
export interface AgentProfileFormSnapshot { export interface AgentProfileFormSnapshot {
mode: "create" | "edit"; mode: "create" | "edit";
profile?: AgentProfile; profile?: AgentProfile;
seed?: AgentProfileSeed;
} }
/** /**
@@ -367,14 +378,18 @@ function seedDisplay(value: string | undefined): AgentProfileFormDisplay | null
*/ */
function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileFormState { function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileFormState {
const profile = snapshot.profile ?? BLANK_PROFILE; const profile = snapshot.profile ?? BLANK_PROFILE;
const seed = snapshot.mode === "create" ? snapshot.seed : undefined;
const name = seed?.name ?? profile.name;
const provider = seed?.provider ?? profile.provider;
const modelId = seed?.modelId ?? profile.model ?? "";
return { return {
mode: snapshot.mode, mode: snapshot.mode,
name: profile.name, name,
icon: profile.icon ?? "", icon: profile.icon ?? "",
color: profile.color ?? "", color: profile.color ?? "",
notes: profile.notes ?? "", notes: profile.notes ?? "",
provider: profile.provider, provider,
modelId: profile.model ?? "", modelId,
modeId: profile.modeId ?? "", modeId: profile.modeId ?? "",
thinkingOptionId: profile.thinkingOptionId ?? "", thinkingOptionId: profile.thinkingOptionId ?? "",
featureValues: { ...profile.featureValues }, featureValues: { ...profile.featureValues },
@@ -383,8 +398,8 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
modeOptions: [], modeOptions: [],
thinkingOptions: [], thinkingOptions: [],
features: [], features: [],
providerDisplay: seedDisplay(profile.provider), providerDisplay: seedDisplay(provider),
modelDisplay: seedDisplay(profile.model), modelDisplay: seedDisplay(modelId),
modeDisplay: seedDisplay(profile.modeId), modeDisplay: seedDisplay(profile.modeId),
thinkingDisplay: seedDisplay(profile.thinkingOptionId), thinkingDisplay: seedDisplay(profile.thinkingOptionId),
catalogResolution: "idle", catalogResolution: "idle",
@@ -0,0 +1,3 @@
export function generateAgentProfileId(): string {
return `agent_profile_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
@@ -29,6 +29,8 @@ export type AgentProfileApplyTarget =
export interface AgentProfilePickerRow { export interface AgentProfilePickerRow {
id: string; id: string;
provider: string; provider: string;
/** Empty when the profile names no model. */
modelId: string;
/** Icon registry key and identity colour; either may be empty for the default glyph. */ /** Icon registry key and identity colour; either may be empty for the default glyph. */
icon: string; icon: string;
color: string; color: string;
@@ -95,6 +97,7 @@ export function useAgentProfilePicker(
applicableProfiles.map((profile) => ({ applicableProfiles.map((profile) => ({
id: profile.id, id: profile.id,
provider: profile.provider, provider: profile.provider,
modelId: profile.model?.trim() ?? "",
icon: profile.icon ?? "", icon: profile.icon ?? "",
color: profile.color ?? "", color: profile.color ?? "",
name: profile.name, name: profile.name,
@@ -20,6 +20,7 @@ import { AgentProfileAppearanceField } from "./agent-profile-appearance-field";
import type { import type {
AgentProfileFormModel, AgentProfileFormModel,
AgentProfileFormOption, AgentProfileFormOption,
AgentProfileSeed,
AgentProfileValue, AgentProfileValue,
} from "../internal/profile-form-model"; } from "../internal/profile-form-model";
import { import {
@@ -36,6 +37,7 @@ export interface AgentProfileEditModalProps {
visible: boolean; visible: boolean;
mode: "create" | "edit"; mode: "create" | "edit";
profile?: AgentProfile; profile?: AgentProfile;
seed?: AgentProfileSeed;
onClose: () => void; onClose: () => void;
onSave: (value: AgentProfileValue) => Promise<void>; onSave: (value: AgentProfileValue) => Promise<void>;
} }
@@ -47,7 +49,10 @@ export interface AgentProfileEditModalProps {
const UNSET_VALUE = ""; const UNSET_VALUE = "";
function openKey(props: AgentProfileEditModalProps): string { function openKey(props: AgentProfileEditModalProps): string {
return props.mode === "edit" ? `edit:${props.profile?.id ?? ""}` : "create"; if (props.mode === "edit") {
return `edit:${props.profile?.id ?? ""}`;
}
return `create:${props.seed?.provider ?? ""}:${props.seed?.modelId ?? ""}`;
} }
/** /**
@@ -122,13 +127,17 @@ function OpenAgentProfileEditModal({
visible, visible,
mode, mode,
profile, profile,
seed,
onClose, onClose,
onDismiss, onDismiss,
onSave, onSave,
}: AgentProfileEditModalProps & { onDismiss: () => void }): ReactElement { }: AgentProfileEditModalProps & { onDismiss: () => void }): ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const snapshot = useMemo(() => ({ mode, ...(profile ? { profile } : {}) }), [mode, profile]); const snapshot = useMemo(
() => ({ mode, ...(profile ? { profile } : {}), ...(seed ? { seed } : {}) }),
[mode, profile, seed],
);
const model = useAgentProfileFormModel(snapshot); const model = useAgentProfileFormModel(snapshot);
const state = useAgentProfileFormState(model); const state = useAgentProfileFormState(model);
useAgentProfileFormCatalog({ serverId, model }); useAgentProfileFormCatalog({ serverId, model });
@@ -234,7 +243,7 @@ function OpenAgentProfileEditModal({
testID="agent-profile-name-field" testID="agent-profile-name-field"
> >
<FormTextInput <FormTextInput
initialValue={profile?.name ?? ""} initialValue={seed?.name ?? profile?.name ?? ""}
onChangeText={model.setName} onChangeText={model.setName}
placeholder={t("settings.host.agentProfiles.namePlaceholder")} placeholder={t("settings.host.agentProfiles.namePlaceholder")}
autoCapitalize="none" autoCapitalize="none"
@@ -12,6 +12,7 @@ import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useAgentProfiles } from "../internal/use-agent-profiles"; import { useAgentProfiles } from "../internal/use-agent-profiles";
import { generateAgentProfileId } from "../internal/profile-id";
import type { AgentProfileValue } from "../internal/profile-form-model"; import type { AgentProfileValue } from "../internal/profile-form-model";
import { AgentProfileEditModal } from "./agent-profile-edit-modal"; import { AgentProfileEditModal } from "./agent-profile-edit-modal";
import { AgentProfileRow } from "./agent-profile-row"; import { AgentProfileRow } from "./agent-profile-row";
@@ -20,10 +21,6 @@ const ThemedPlus = withUnistyles(Plus);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const addIcon = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedColorMapping} />; const addIcon = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
function generateAgentProfileId(): string {
return `agent_profile_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
interface EditTarget { interface EditTarget {
mode: "create" | "edit"; mode: "create" | "edit";
profile?: AgentProfile; profile?: AgentProfile;
@@ -3,7 +3,7 @@ import { Pressable, Text, View, type PressableStateCallbackType } from "react-na
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker } from "@/agent-profiles"; import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox"; import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
@@ -30,6 +30,8 @@ interface CombinedModelSelectorProps {
profiles?: AgentProfilePicker | null; profiles?: AgentProfilePicker | null;
onApplyProfile?: (profileId: string) => void; onApplyProfile?: (profileId: string) => void;
onEditProfiles?: () => void; onEditProfiles?: () => void;
onCreateProfile?: (seed: AgentProfileSeed) => void;
onEditProfile?: (profileId: string) => void;
renderTrigger?: (input: { renderTrigger?: (input: {
selectedModelLabel: string; selectedModelLabel: string;
onPress: () => void; onPress: () => void;
@@ -70,6 +72,8 @@ export function CombinedModelSelector({
profiles = null, profiles = null,
onApplyProfile, onApplyProfile,
onEditProfiles, onEditProfiles,
onCreateProfile,
onEditProfile,
renderTrigger, renderTrigger,
onOpen, onOpen,
onClose, onClose,
@@ -168,12 +172,30 @@ export function CombinedModelSelector({
onEditProfiles?.(); onEditProfiles?.();
}, [handleOpenChange, 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 ? ( const selectorBody = isContentReady ? (
<ModelBrowser <ModelBrowser
state={browser} state={browser}
onSelect={handleSelect} onSelect={handleSelect}
onApplyProfile={handleApplyProfile} onApplyProfile={handleApplyProfile}
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined} onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
onCreateProfile={onCreateProfile ? handleCreateProfile : undefined}
onEditProfile={onEditProfile ? handleEditProfile : undefined}
onRetryProvider={onRetryProvider} onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider} isRetryingProvider={isRetryingProvider}
scrolling={isWeb ? "independent" : "sheet"} scrolling={isWeb ? "independent" : "sheet"}
@@ -3,7 +3,11 @@ import type {
ProviderSelectionModelRow, ProviderSelectionModelRow,
ProviderSelectorProvider, ProviderSelectorProvider,
} from "@/provider-selection/provider-selection"; } from "@/provider-selection/provider-selection";
import { resolveInitialModelBrowserView, resolveModelBrowserAllView } from "./model-browser-view"; import {
resolveInitialModelBrowserView,
resolveModelBrowserAllView,
groupProfilesByProviderModel,
} from "./model-browser-view";
function provider( function provider(
id: string, id: string,
@@ -93,6 +97,36 @@ describe("model browser initial view", () => {
}); });
}); });
describe("groupProfilesByProviderModel", () => {
it("groups profiles by provider and model, skipping profiles without a model", () => {
const lookup = groupProfilesByProviderModel([
{ provider: "claude", modelId: "opus-5" },
{ provider: "claude", modelId: "opus-5" },
{ provider: "claude", modelId: "sonnet-4.6" },
{ provider: "claude", modelId: "" },
{ provider: "codex", modelId: "gpt-5.4" },
]);
expect(lookup.get("claude:opus-5")).toHaveLength(2);
expect(lookup.get("claude:sonnet-4.6")).toHaveLength(1);
expect(lookup.get("codex:gpt-5.4")).toHaveLength(1);
expect(lookup.has("claude:")).toBe(false);
});
it("trims model ids so whitespace cannot create a separate key", () => {
const lookup = groupProfilesByProviderModel([
{ provider: "claude", modelId: "opus-5" },
{ provider: "claude", modelId: " opus-5 " },
]);
expect(lookup.get("claude:opus-5")).toHaveLength(2);
});
it("returns an empty map for no refs", () => {
expect(groupProfilesByProviderModel([]).size).toBe(0);
});
});
describe("model browser all view", () => { describe("model browser all view", () => {
const claude = provider("claude", "Claude Code", [ const claude = provider("claude", "Claude Code", [
modelRow("claude", "Claude Code", "opus-5", "Opus 5"), modelRow("claude", "Claude Code", "opus-5", "Opus 5"),
@@ -9,6 +9,36 @@ export type ModelBrowserView =
| { kind: "all" } | { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string }; | { kind: "provider"; providerId: string; providerLabel: string };
/** A profile's model reference; used to match profiles back to model rows. */
export interface ModelProfileRef {
provider: string;
modelId: string;
}
/**
* Groups profiles by `provider:modelId`, skipping profiles that name no model.
* Pure so the model browser can test it apart from the component tree.
*/
export function groupProfilesByProviderModel<T extends ModelProfileRef>(
refs: readonly T[],
): Map<string, T[]> {
const lookup = new Map<string, T[]>();
for (const ref of refs) {
const modelId = ref.modelId.trim();
if (!modelId) {
continue;
}
const key = `${ref.provider}:${modelId}`;
const existing = lookup.get(key);
if (existing) {
existing.push(ref);
} else {
lookup.set(key, [ref]);
}
}
return lookup;
}
/** What the root view shows: the provider drill-down, or ranked cross-provider results. */ /** What the root view shows: the provider drill-down, or ranked cross-provider results. */
export type ModelBrowserAllView = export type ModelBrowserAllView =
| { kind: "browse" } | { kind: "browse" }
+279 -14
View File
@@ -30,6 +30,7 @@ import {
AgentProfileGlyph, AgentProfileGlyph,
type AgentProfilePicker, type AgentProfilePicker,
type AgentProfilePickerRow as AgentProfilePickerRowModel, type AgentProfilePickerRow as AgentProfilePickerRowModel,
type AgentProfileSeed,
} from "@/agent-profiles"; } from "@/agent-profiles";
import type { SheetHeader } from "@/components/adaptive-modal-sheet"; import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -52,6 +53,7 @@ import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { useCurrentOverlayLayer } from "@/lib/overlay-root"; import { useCurrentOverlayLayer } from "@/lib/overlay-root";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import { import {
groupProfilesByProviderModel,
resolveInitialModelBrowserView, resolveInitialModelBrowserView,
resolveModelBrowserAllView, resolveModelBrowserAllView,
type ModelBrowserView, type ModelBrowserView,
@@ -176,6 +178,8 @@ interface ModelBrowserProps {
/** Applying a profile resolves the pick and dismisses, exactly like a model row. */ /** Applying a profile resolves the pick and dismisses, exactly like a model row. */
onApplyProfile?: (profileId: string) => void; onApplyProfile?: (profileId: string) => void;
onEditProfiles?: () => void; onEditProfiles?: () => void;
onCreateProfile?: (seed: AgentProfileSeed) => void;
onEditProfile?: (profileId: string) => void;
onRetryProvider?: (provider: AgentProvider) => void; onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider?: boolean; isRetryingProvider?: boolean;
scrolling?: "sheet" | "independent"; scrolling?: "sheet" | "independent";
@@ -183,6 +187,8 @@ interface ModelBrowserProps {
searchAllOnFocus?: boolean; searchAllOnFocus?: boolean;
/** Replaces provider rows only while the all-provider search is empty. */ /** Replaces provider rows only while the all-provider search is empty. */
rootBrowseContent?: React.ReactNode; rootBrowseContent?: React.ReactNode;
/** Hide the pinned Profiles section while still using rows for model matching. */
showProfilesSection?: boolean;
} }
interface ModelBrowserContentProps extends Omit<ModelBrowserProps, "state" | "scrolling"> { interface ModelBrowserContentProps extends Omit<ModelBrowserProps, "state" | "scrolling"> {
@@ -591,34 +597,214 @@ function ModelBrowserRow({
); );
} }
function ModelRowProfileAction({
hovered,
onPress,
label,
testID,
children,
}: {
hovered: boolean;
onPress: () => void;
label: string;
testID: string;
children: React.ReactNode;
}) {
const isCompact = useIsCompactFormFactor();
const visible = hovered || isNative || isCompact;
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onPress();
},
[onPress],
);
const pressableStyle = useCallback(
({ hovered: buttonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.rowIconButton,
Boolean(buttonHovered) && styles.rowIconButtonHovered,
pressed && styles.rowIconButtonPressed,
!visible && styles.profileActionHidden,
],
[visible],
);
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={handlePress}
hitSlop={8}
style={pressableStyle}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="button"
accessibilityLabel={label}
testID={testID}
>
{children}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
function ModelRow({ function ModelRow({
row, row,
isSelected, isSelected,
showProviderLabel = false, showProviderLabel = false,
onPress, onPress,
profiledRows,
onCreateProfile,
onEditProfile,
onEditProfiles,
}: { }: {
row: ProviderSelectionModelRow; row: ProviderSelectionModelRow;
isSelected: boolean; isSelected: boolean;
showProviderLabel?: boolean; showProviderLabel?: boolean;
onPress: () => void; onPress: () => void;
profiledRows: AgentProfilePickerRowModel[];
onCreateProfile?: (seed: AgentProfileSeed) => void;
onEditProfile?: (profileId: string) => void;
onEditProfiles?: () => void;
}) { }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const leadingSlot = useMemo( const leadingSlot = useMemo(
() => <ModelProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />, () => <ModelProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
[row.provider], [row.provider],
); );
const description = showProviderLabel ? buildProviderQualifiedDescription(row) : row.description; const description = showProviderLabel ? buildProviderQualifiedDescription(row) : row.description;
const primary = profiledRows[profiledRows.length - 1];
const handleCreateProfile = useCallback(() => {
onCreateProfile?.({
provider: row.provider,
modelId: row.modelId,
name: row.modelLabel,
});
}, [onCreateProfile, row.modelId, row.modelLabel, row.provider]);
const handleEditProfile = useCallback(() => {
onEditProfile?.(primary.id);
}, [onEditProfile, primary]);
const handleEditProfiles = useCallback(() => {
onEditProfiles?.();
}, [onEditProfiles]);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const profileAction = useMemo(() => {
if (row.modelId.length === 0) {
return null;
}
if (profiledRows.length === 0) {
if (!onCreateProfile) {
return null;
}
return (
<ModelRowProfileAction
hovered={isHovered}
onPress={handleCreateProfile}
label={t("modelSelector.createProfileFromModel")}
testID={`model-create-profile-${row.provider}-${row.modelId}`}
>
<ThemedPlus size={ICON_SIZE.xs} uniProps={foregroundMutedMapping} />
</ModelRowProfileAction>
);
}
if (profiledRows.length === 1) {
if (!onEditProfile) {
return null;
}
return (
<ModelRowProfileAction
hovered={isHovered}
onPress={handleEditProfile}
label={t("modelSelector.editProfileLabel", { name: primary.name })}
testID={`model-edit-profile-${row.provider}-${row.modelId}`}
>
<AgentProfileGlyph icon={primary.icon} color={primary.color} size={ICON_SIZE.xs} />
</ModelRowProfileAction>
);
}
if (!onEditProfiles) {
return null;
}
return (
<ModelRowProfileAction
hovered={isHovered}
onPress={handleEditProfiles}
label={t("modelSelector.editProfilesCount", { count: profiledRows.length })}
testID={`model-edit-profiles-${row.provider}-${row.modelId}`}
>
<AgentProfileGlyph icon={primary.icon} color={primary.color} size={ICON_SIZE.xs} />
</ModelRowProfileAction>
);
}, [
handleCreateProfile,
handleEditProfile,
handleEditProfiles,
isHovered,
onCreateProfile,
onEditProfile,
onEditProfiles,
primary,
profiledRows,
row.modelId,
row.provider,
t,
]);
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.browserRow,
styles.browserModelRow,
Boolean(hovered) && styles.browserRowHovered,
pressed && styles.browserRowPressed,
],
[],
);
return ( return (
<ModelBrowserRow <View
label={row.modelLabel} style={styles.modelRowHoverBoundary}
description={description} onPointerEnter={handlePointerEnter}
selected={isSelected} onPointerLeave={handlePointerLeave}
selectionIndicator >
onPress={onPress} <ModelBrowserPressable
leadingSlot={leadingSlot} onPress={onPress}
testID={`model-row-${row.provider}-${row.modelId}`} style={pressableStyle}
/> accessibilitySelected={isSelected}
testID={`model-row-${row.provider}-${row.modelId}`}
>
<View style={styles.browserRowContent}>
<View style={styles.browserRowLeading}>{leadingSlot}</View>
<View style={[styles.browserRowText, description && styles.browserRowTextInline]}>
<Text numberOfLines={1} style={styles.browserRowLabel}>
{row.modelLabel}
</Text>
{description ? (
<Text numberOfLines={1} style={styles.browserRowDescription}>
{description}
</Text>
) : null}
</View>
<View style={styles.browserRowTrailing}>
<View style={styles.browserRowSelection}>
{isSelected ? (
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
) : null}
</View>
{profileAction}
</View>
</View>
</ModelBrowserPressable>
</View>
); );
} }
@@ -627,11 +813,19 @@ function SelectableModelRow({
isSelected, isSelected,
showProviderLabel, showProviderLabel,
onSelect, onSelect,
profiledRows,
onCreateProfile,
onEditProfile,
onEditProfiles,
}: { }: {
row: ProviderSelectionModelRow; row: ProviderSelectionModelRow;
isSelected: boolean; isSelected: boolean;
showProviderLabel?: boolean; showProviderLabel?: boolean;
onSelect: (provider: string, modelId: string) => void; onSelect: (provider: string, modelId: string) => void;
profiledRows: AgentProfilePickerRowModel[];
onCreateProfile?: (seed: AgentProfileSeed) => void;
onEditProfile?: (profileId: string) => void;
onEditProfiles?: () => void;
}) { }) {
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
onSelect(row.provider, row.modelId); onSelect(row.provider, row.modelId);
@@ -642,6 +836,10 @@ function SelectableModelRow({
isSelected={isSelected} isSelected={isSelected}
showProviderLabel={showProviderLabel} showProviderLabel={showProviderLabel}
onPress={handlePress} onPress={handlePress}
profiledRows={profiledRows}
onCreateProfile={onCreateProfile}
onEditProfile={onEditProfile}
onEditProfiles={onEditProfiles}
/> />
); );
} }
@@ -919,6 +1117,10 @@ function ModelRowList({
showProviderLabel = false, showProviderLabel = false,
header, header,
scrolling, scrolling,
profiledLookup,
onCreateProfile,
onEditProfile,
onEditProfiles,
}: { }: {
rows: ProviderSelectionModelRow[]; rows: ProviderSelectionModelRow[];
selectedProvider: string; selectedProvider: string;
@@ -927,6 +1129,10 @@ function ModelRowList({
showProviderLabel?: boolean; showProviderLabel?: boolean;
header?: React.ReactElement; header?: React.ReactElement;
scrolling: "sheet" | "independent"; scrolling: "sheet" | "independent";
profiledLookup: Map<string, AgentProfilePickerRowModel[]>;
onCreateProfile?: (seed: AgentProfileSeed) => void;
onEditProfile?: (profileId: string) => void;
onEditProfiles?: () => void;
}) { }) {
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const renderItem = useCallback( const renderItem = useCallback(
@@ -936,9 +1142,22 @@ function ModelRowList({
isSelected={item.provider === selectedProvider && item.modelId === selectedModel} isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
showProviderLabel={showProviderLabel} showProviderLabel={showProviderLabel}
onSelect={onSelect} onSelect={onSelect}
profiledRows={profiledLookup.get(`${item.provider}:${item.modelId}`) ?? []}
onCreateProfile={onCreateProfile}
onEditProfile={onEditProfile}
onEditProfiles={onEditProfiles}
/> />
), ),
[onSelect, selectedModel, selectedProvider, showProviderLabel], [
onEditProfile,
onEditProfiles,
onCreateProfile,
onSelect,
profiledLookup,
selectedModel,
selectedProvider,
showProviderLabel,
],
); );
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []); const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
@@ -1019,6 +1238,10 @@ function ProviderModelBrowserContent({
onSelect, onSelect,
onApplyProfile, onApplyProfile,
onEditProfiles, onEditProfiles,
onCreateProfile,
onEditProfile,
profiledLookup,
showProfilesSection = true,
onRetryProvider, onRetryProvider,
isRetryingProvider, isRetryingProvider,
scrolling, scrolling,
@@ -1032,6 +1255,10 @@ function ProviderModelBrowserContent({
onSelect: (provider: string, modelId: string) => void; onSelect: (provider: string, modelId: string) => void;
onApplyProfile?: (profileId: string) => void; onApplyProfile?: (profileId: string) => void;
onEditProfiles?: () => void; onEditProfiles?: () => void;
onCreateProfile?: (seed: AgentProfileSeed) => void;
onEditProfile?: (profileId: string) => void;
profiledLookup: Map<string, AgentProfilePickerRowModel[]>;
showProfilesSection?: boolean;
onRetryProvider?: (provider: AgentProvider) => void; onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider: boolean; isRetryingProvider: boolean;
scrolling: "sheet" | "independent"; scrolling: "sheet" | "independent";
@@ -1047,14 +1274,21 @@ function ProviderModelBrowserContent({
); );
const profileHeader = useMemo( const profileHeader = useMemo(
() => () =>
normalizedQuery.length === 0 && profiles ? ( normalizedQuery.length === 0 && showProfilesSection && profiles ? (
<AgentProfilesPickerContent <AgentProfilesPickerContent
rows={providerProfileRows} rows={providerProfileRows}
onApplyProfile={onApplyProfile} onApplyProfile={onApplyProfile}
onEditProfiles={onEditProfiles} onEditProfiles={onEditProfiles}
/> />
) : undefined, ) : undefined,
[normalizedQuery, onApplyProfile, onEditProfiles, profiles, providerProfileRows], [
normalizedQuery,
onApplyProfile,
onEditProfiles,
profiles,
providerProfileRows,
showProfilesSection,
],
); );
if (!provider) return <ModelSearchEmptyState />; if (!provider) return <ModelSearchEmptyState />;
@@ -1090,6 +1324,10 @@ function ProviderModelBrowserContent({
onSelect={onSelect} onSelect={onSelect}
header={profileHeader} header={profileHeader}
scrolling={scrolling} scrolling={scrolling}
profiledLookup={profiledLookup}
onCreateProfile={onCreateProfile}
onEditProfile={onEditProfile}
onEditProfiles={onEditProfiles}
/> />
); );
} }
@@ -1105,15 +1343,22 @@ function ModelBrowserContent({
onSelect, onSelect,
onApplyProfile, onApplyProfile,
onEditProfiles, onEditProfiles,
onCreateProfile,
onEditProfile,
onDrillDown, onDrillDown,
onRetryProvider, onRetryProvider,
isRetryingProvider = false, isRetryingProvider = false,
scrolling, scrolling,
searchAllOnFocus, searchAllOnFocus,
rootBrowseContent, rootBrowseContent,
showProfilesSection = true,
}: ModelBrowserContentProps) { }: ModelBrowserContentProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const profiledLookup = useMemo(
() => groupProfilesByProviderModel(profiles?.rows ?? []),
[profiles],
);
const selectedViewProvider = useMemo( const selectedViewProvider = useMemo(
() => () =>
view.kind === "provider" view.kind === "provider"
@@ -1144,6 +1389,10 @@ function ModelBrowserContent({
onSelect={onSelect} onSelect={onSelect}
onApplyProfile={onApplyProfile} onApplyProfile={onApplyProfile}
onEditProfiles={onEditProfiles} onEditProfiles={onEditProfiles}
onCreateProfile={onCreateProfile}
onEditProfile={onEditProfile}
profiledLookup={profiledLookup}
showProfilesSection={showProfilesSection}
onRetryProvider={onRetryProvider} onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider} isRetryingProvider={isRetryingProvider}
scrolling={scrolling} scrolling={scrolling}
@@ -1171,13 +1420,17 @@ function ModelBrowserContent({
onSelect={onSelect} onSelect={onSelect}
showProviderLabel showProviderLabel
scrolling={scrolling} scrolling={scrolling}
profiledLookup={profiledLookup}
onCreateProfile={onCreateProfile}
onEditProfile={onEditProfile}
onEditProfiles={onEditProfiles}
/> />
); );
} }
const allProvidersContent = ( const allProvidersContent = (
<View> <View>
{profiles ? ( {showProfilesSection && profiles ? (
<AgentProfilesPickerContent <AgentProfilesPickerContent
rows={profiles.rows} rows={profiles.rows}
onApplyProfile={onApplyProfile} onApplyProfile={onApplyProfile}
@@ -1187,7 +1440,7 @@ function ModelBrowserContent({
{rootBrowseContent ?? {rootBrowseContent ??
(providers.length > 0 ? ( (providers.length > 0 ? (
<View> <View>
{profiles ? ( {showProfilesSection && profiles ? (
<View style={styles.sectionHeading}> <View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>{t("modelSelector.providers")}</Text> <Text style={styles.sectionHeadingText}>{t("modelSelector.providers")}</Text>
</View> </View>
@@ -1211,11 +1464,14 @@ export function ModelBrowser({
onSelect, onSelect,
onApplyProfile, onApplyProfile,
onEditProfiles, onEditProfiles,
onCreateProfile,
onEditProfile,
onRetryProvider, onRetryProvider,
isRetryingProvider = false, isRetryingProvider = false,
scrolling = "sheet", scrolling = "sheet",
searchAllOnFocus = false, searchAllOnFocus = false,
rootBrowseContent, rootBrowseContent,
showProfilesSection,
}: ModelBrowserProps) { }: ModelBrowserProps) {
return ( return (
<ModelBrowserContent <ModelBrowserContent
@@ -1229,12 +1485,15 @@ export function ModelBrowser({
onSelect={onSelect} onSelect={onSelect}
onApplyProfile={onApplyProfile} onApplyProfile={onApplyProfile}
onEditProfiles={onEditProfiles} onEditProfiles={onEditProfiles}
onCreateProfile={onCreateProfile}
onEditProfile={onEditProfile}
onDrillDown={state.drillDown} onDrillDown={state.drillDown}
onRetryProvider={onRetryProvider} onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider} isRetryingProvider={isRetryingProvider}
scrolling={scrolling} scrolling={scrolling}
searchAllOnFocus={searchAllOnFocus} searchAllOnFocus={searchAllOnFocus}
rootBrowseContent={rootBrowseContent} rootBrowseContent={rootBrowseContent}
showProfilesSection={showProfilesSection}
/> />
); );
} }
@@ -1279,6 +1538,9 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
minHeight: 36, minHeight: 36,
}, },
modelRowHoverBoundary: {
position: "relative",
},
browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] }, browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] },
browserRowHovered: { browserRowHovered: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
@@ -1371,6 +1633,9 @@ const styles = StyleSheet.create((theme) => ({
rowIconButtonPressed: { rowIconButtonPressed: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
profileActionHidden: {
opacity: 0,
},
emptyState: { emptyState: {
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
alignItems: "center", alignItems: "center",
@@ -82,9 +82,12 @@ import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import { AgentControlTrigger } from "@/composer/agent-controls/control"; import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; import { CompactModelSheet } from "@/composer/agent-controls/model-sheet";
import { import {
useAgentProfileEditor,
useAgentProfilePicker, useAgentProfilePicker,
type AgentProfileApplyTarget, type AgentProfileApplyTarget,
type AgentProfileEditorControls,
type AgentProfilePicker, type AgentProfilePicker,
type AgentProfileSeed,
type DraftAgentProfileControls, type DraftAgentProfileControls,
} from "@/agent-profiles"; } from "@/agent-profiles";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
@@ -116,6 +119,8 @@ interface ControlledAgentControlsProps {
agentProfiles?: AgentProfilePicker | null; agentProfiles?: AgentProfilePicker | null;
onApplyAgentProfile?: (profileId: string) => void; onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void; onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
onEditAgentProfile?: (profileId: string) => void;
features?: AgentFeature[]; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
@@ -281,6 +286,22 @@ function useEditAgentProfilesNavigation(
return serverId && isSupported ? handleEdit : undefined; return serverId && isSupported ? handleEdit : undefined;
} }
function resolveAgentProfileEditorActions(
isSupported: boolean,
editor: AgentProfileEditorControls,
): {
create?: (seed: AgentProfileSeed) => void;
edit?: (profileId: string) => void;
} {
if (!isSupported) {
return {};
}
return {
create: editor.openCreateFromModel,
edit: editor.openEdit,
};
}
function buildFallbackModelSelectorProviders( function buildFallbackModelSelectorProviders(
provider: string, provider: string,
modelOptions: AgentControlOption[] | undefined, modelOptions: AgentControlOption[] | undefined,
@@ -468,6 +489,8 @@ function ControlledAgentControls({
agentProfiles = null, agentProfiles = null,
onApplyAgentProfile, onApplyAgentProfile,
onEditAgentProfiles, onEditAgentProfiles,
onCreateAgentProfile,
onEditAgentProfile,
features, features,
onSetFeature, onSetFeature,
onDropdownClose, onDropdownClose,
@@ -722,6 +745,8 @@ function ControlledAgentControls({
onSetFeature={onSetFeature} onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile} onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles} onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile}
onEditAgentProfile={onEditAgentProfile}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen} onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider} onRetryModelProvider={onRetryModelProvider}
@@ -769,6 +794,8 @@ function ControlledAgentControls({
onSetFeature={onSetFeature} onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile} onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles} onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile}
onEditAgentProfile={onEditAgentProfile}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen} onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider} onRetryModelProvider={onRetryModelProvider}
@@ -813,6 +840,8 @@ interface DesktopAgentControlsContentProps {
onSetFeature?: (featureId: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void; onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void; onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
onEditAgentProfile?: (profileId: string) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void; onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void; onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -873,6 +902,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onSetFeature, onSetFeature,
onApplyAgentProfile, onApplyAgentProfile,
onEditAgentProfiles, onEditAgentProfiles,
onCreateAgentProfile,
onEditAgentProfile,
onDropdownClose, onDropdownClose,
onModelSelectorOpen, onModelSelectorOpen,
onRetryModelProvider, onRetryModelProvider,
@@ -961,6 +992,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
profiles={agentProfiles} profiles={agentProfiles}
onApplyProfile={onApplyAgentProfile} onApplyProfile={onApplyAgentProfile}
onEditProfiles={onEditAgentProfiles} onEditProfiles={onEditAgentProfiles}
onCreateProfile={onCreateAgentProfile}
onEditProfile={onEditAgentProfile}
isLoading={isModelLoading} isLoading={isModelLoading}
disabled={modelDisabled} disabled={modelDisabled}
onOpen={onModelSelectorOpen} onOpen={onModelSelectorOpen}
@@ -1079,6 +1112,8 @@ interface SheetAgentControlsContentProps {
onSetFeature?: (featureId: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void; onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void; onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
onEditAgentProfile?: (profileId: string) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void; onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void; onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -1121,6 +1156,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
onSetFeature, onSetFeature,
onApplyAgentProfile, onApplyAgentProfile,
onEditAgentProfiles, onEditAgentProfiles,
onCreateAgentProfile,
onEditAgentProfile,
onDropdownClose, onDropdownClose,
onModelSelectorOpen, onModelSelectorOpen,
onRetryModelProvider, onRetryModelProvider,
@@ -1222,6 +1259,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
profiles={agentProfiles} profiles={agentProfiles}
onApplyProfile={onApplyAgentProfile} onApplyProfile={onApplyAgentProfile}
onEditProfiles={onEditAgentProfiles} onEditProfiles={onEditAgentProfiles}
onCreateProfile={onCreateAgentProfile}
onEditProfile={onEditAgentProfile}
isLoading={isModelLoading} isLoading={isModelLoading}
disabled={modelDisabled} disabled={modelDisabled}
onOpen={onModelSelectorOpen} onOpen={onModelSelectorOpen}
@@ -1610,6 +1649,8 @@ export const AgentControls = memo(function AgentControls({
target: profileTarget, target: profileTarget,
}); });
const handleEditAgentProfiles = useEditAgentProfilesNavigation(serverId, agentProfiles !== null); const handleEditAgentProfiles = useEditAgentProfilesNavigation(serverId, agentProfiles !== null);
const profileEditor = useAgentProfileEditor(serverId);
const profileActions = resolveAgentProfileEditorActions(agentProfiles !== null, profileEditor);
const handleSelectThinkingOption = useCallback( const handleSelectThinkingOption = useCallback(
(thinkingOptionId: string) => { (thinkingOptionId: string) => {
@@ -1735,6 +1776,7 @@ export const AgentControls = memo(function AgentControls({
return ( return (
<> <>
{commandCenterRegistration} {commandCenterRegistration}
{profileEditor.element}
<ControlledAgentControls <ControlledAgentControls
provider={agent.provider} provider={agent.provider}
modelSelectorProviders={agentModelSelectorProviders} modelSelectorProviders={agentModelSelectorProviders}
@@ -1744,6 +1786,8 @@ export const AgentControls = memo(function AgentControls({
agentProfiles={agentProfiles} agentProfiles={agentProfiles}
onApplyAgentProfile={agentProfiles?.applyProfile} onApplyAgentProfile={agentProfiles?.applyProfile}
onEditAgentProfiles={handleEditAgentProfiles} onEditAgentProfiles={handleEditAgentProfiles}
onCreateAgentProfile={profileActions.create}
onEditAgentProfile={profileActions.edit}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined} thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={handleSelectThinkingOption} onSelectThinkingOption={handleSelectThinkingOption}
@@ -1830,6 +1874,8 @@ export function DraftAgentControls({
modelSelectorServerId, modelSelectorServerId,
agentProfiles !== null, agentProfiles !== null,
); );
const profileEditor = useAgentProfileEditor(modelSelectorServerId);
const profileActions = resolveAgentProfileEditorActions(agentProfiles !== null, profileEditor);
const modeControl = useMemo<AgentModeControlValue | null>( const modeControl = useMemo<AgentModeControlValue | null>(
() => () =>
@@ -1847,31 +1893,36 @@ export function DraftAgentControls({
); );
return ( return (
<ControlledAgentControls <>
provider={selectedProvider ?? ""} {profileEditor.element}
modelSelectorProviders={modelSelectorProviders} <ControlledAgentControls
modelOptions={modelOptions} provider={selectedProvider ?? ""}
selectedModelId={selectedModel} modelSelectorProviders={modelSelectorProviders}
onSelectModel={onSelectModel} modelOptions={modelOptions}
onSelectProviderAndModel={onSelectProviderAndModel} selectedModelId={selectedModel}
isModelLoading={isAllModelsLoading} onSelectModel={onSelectModel}
agentProfiles={agentProfiles} onSelectProviderAndModel={onSelectProviderAndModel}
onApplyAgentProfile={agentProfiles?.applyProfile} isModelLoading={isAllModelsLoading}
onEditAgentProfiles={handleEditAgentProfiles} agentProfiles={agentProfiles}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} onApplyAgentProfile={agentProfiles?.applyProfile}
selectedThinkingOptionId={effectiveSelectedThinkingOption} onEditAgentProfiles={handleEditAgentProfiles}
onSelectThinkingOption={onSelectThinkingOption} onCreateAgentProfile={profileActions.create}
features={features} onEditAgentProfile={profileActions.edit}
onSetFeature={onSetFeature} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
onDropdownClose={onDropdownClose} selectedThinkingOptionId={effectiveSelectedThinkingOption}
onModelSelectorOpen={onModelSelectorOpen} onSelectThinkingOption={onSelectThinkingOption}
onRetryModelProvider={onRetryModelProvider} features={features}
isRetryingModelProvider={isRetryingModelProvider} onSetFeature={onSetFeature}
disabled={disabled} onDropdownClose={onDropdownClose}
modeControl={modeControl} onModelSelectorOpen={onModelSelectorOpen}
modelSelectorServerId={modelSelectorServerId} onRetryModelProvider={onRetryModelProvider}
isCompactLayout={isCompactLayout} isRetryingModelProvider={isRetryingModelProvider}
/> disabled={disabled}
modeControl={modeControl}
modelSelectorServerId={modelSelectorServerId}
isCompactLayout={isCompactLayout}
/>
</>
); );
} }
@@ -4,7 +4,7 @@ import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } fro
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Bot } from "lucide-react-native"; import { Bot } from "lucide-react-native";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker } from "@/agent-profiles"; import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
@@ -36,6 +36,8 @@ interface CompactModelSheetProps {
profiles?: AgentProfilePicker | null; profiles?: AgentProfilePicker | null;
onApplyProfile?: (profileId: string) => void; onApplyProfile?: (profileId: string) => void;
onEditProfiles?: () => void; onEditProfiles?: () => void;
onCreateProfile?: (seed: AgentProfileSeed) => void;
onEditProfile?: (profileId: string) => void;
onOpen?: () => void; onOpen?: () => void;
onClose?: () => void; onClose?: () => void;
onRetryProvider?: (provider: AgentProvider) => void; onRetryProvider?: (provider: AgentProvider) => void;
@@ -52,6 +54,30 @@ function shortModelLabel(label: string): string {
return separatorIndex === -1 ? label : label.slice(separatorIndex + 1); 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({ export function CompactModelSheet({
providers, providers,
selectedProvider, selectedProvider,
@@ -62,6 +88,8 @@ export function CompactModelSheet({
profiles = null, profiles = null,
onApplyProfile, onApplyProfile,
onEditProfiles, onEditProfiles,
onCreateProfile,
onEditProfile,
onOpen, onOpen,
onClose, onClose,
onRetryProvider, onRetryProvider,
@@ -98,6 +126,7 @@ export function CompactModelSheet({
selectedModel, selectedModel,
isLoading, isLoading,
autoFocusSearch: isWeb && !usesBottomSheet, autoFocusSearch: isWeb && !usesBottomSheet,
profiles,
serverId, serverId,
}); });
const ProviderIcon = const ProviderIcon =
@@ -191,6 +220,8 @@ export function CompactModelSheet({
onEditProfiles?.(); onEditProfiles?.();
}, [close, onEditProfiles]); }, [close, onEditProfiles]);
const profileActions = resolveModelSheetProfileActions(onCreateProfile, onEditProfile, close);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isOpen) { if (isOpen) {
close(); close();
@@ -284,6 +315,8 @@ export function CompactModelSheet({
onSelect={usesBottomSheet ? handleSearchSelect : handleDesktopSelect} onSelect={usesBottomSheet ? handleSearchSelect : handleDesktopSelect}
onApplyProfile={handleApplyProfile} onApplyProfile={handleApplyProfile}
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined} onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
onCreateProfile={profileActions.create}
onEditProfile={profileActions.edit}
onRetryProvider={onRetryProvider} onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider} isRetryingProvider={isRetryingProvider}
scrolling={modelBrowserScrolling} scrolling={modelBrowserScrolling}
@@ -326,10 +359,14 @@ export function CompactModelSheet({
<ModelBrowser <ModelBrowser
state={modelBrowser} state={modelBrowser}
onSelect={handleBrowserSelect} onSelect={handleBrowserSelect}
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
onCreateProfile={profileActions.create}
onEditProfile={profileActions.edit}
onRetryProvider={onRetryProvider} onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider} isRetryingProvider={isRetryingProvider}
scrolling={modelBrowserScrolling} scrolling={modelBrowserScrolling}
searchAllOnFocus searchAllOnFocus
showProfilesSection={false}
/> />
</View> </View>
</AdaptiveModalSheet> </AdaptiveModalSheet>
+3
View File
@@ -1463,6 +1463,9 @@ export const ar: TranslationResources = {
editProfiles: "تحرير", editProfiles: "تحرير",
editProfilesLabel: "تحرير ملفات الوكيل", editProfilesLabel: "تحرير ملفات الوكيل",
createProfile: "إنشاء ملف شخصي", createProfile: "إنشاء ملف شخصي",
createProfileFromModel: "إنشاء ملف شخصي من هذا النموذج",
editProfileLabel: "تحرير {{name}}",
editProfilesCount: "تحرير الملفات الشخصية ({{count}})",
modelCount: "{{count}} نموذج", modelCount: "{{count}} نموذج",
modelCountPlural: "{{count}} نماذج", modelCountPlural: "{{count}} نماذج",
retry: "أعد المحاولة", retry: "أعد المحاولة",
+3
View File
@@ -1473,6 +1473,9 @@ export const en = {
editProfiles: "Edit", editProfiles: "Edit",
editProfilesLabel: "Edit agent profiles", editProfilesLabel: "Edit agent profiles",
createProfile: "Create profile", createProfile: "Create profile",
createProfileFromModel: "Create profile from this model",
editProfileLabel: "Edit {{name}}",
editProfilesCount: "Edit profiles ({{count}})",
modelCount: "{{count}} model", modelCount: "{{count}} model",
modelCountPlural: "{{count}} models", modelCountPlural: "{{count}} models",
retry: "Retry", retry: "Retry",
+3
View File
@@ -1507,6 +1507,9 @@ export const es: TranslationResources = {
editProfiles: "Editar", editProfiles: "Editar",
editProfilesLabel: "Editar perfiles de agente", editProfilesLabel: "Editar perfiles de agente",
createProfile: "Crear perfil", createProfile: "Crear perfil",
createProfileFromModel: "Crear perfil desde este modelo",
editProfileLabel: "Editar {{name}}",
editProfilesCount: "Editar perfiles ({{count}})",
modelCount: "{{count}} modelo", modelCount: "{{count}} modelo",
modelCountPlural: "{{count}} modelos", modelCountPlural: "{{count}} modelos",
retry: "Rever", retry: "Rever",
+3
View File
@@ -1511,6 +1511,9 @@ export const fr: TranslationResources = {
editProfiles: "Modifier", editProfiles: "Modifier",
editProfilesLabel: "Modifier les profils d'agent", editProfilesLabel: "Modifier les profils d'agent",
createProfile: "Créer un profil", createProfile: "Créer un profil",
createProfileFromModel: "Créer un profil à partir de ce modèle",
editProfileLabel: "Modifier {{name}}",
editProfilesCount: "Modifier les profils ({{count}})",
modelCount: "{{count}} modèle", modelCount: "{{count}} modèle",
modelCountPlural: "{{count}} modèles", modelCountPlural: "{{count}} modèles",
retry: "Réessayer", retry: "Réessayer",
+3
View File
@@ -1478,6 +1478,9 @@ export const ja: TranslationResources = {
editProfiles: "編集", editProfiles: "編集",
editProfilesLabel: "エージェントプロファイルを編集", editProfilesLabel: "エージェントプロファイルを編集",
createProfile: "プロファイルを作成", createProfile: "プロファイルを作成",
createProfileFromModel: "このモデルからプロファイルを作成",
editProfileLabel: "{{name}}を編集",
editProfilesCount: "プロファイルを編集({{count}})",
modelCount: "{{count}}つのモデル", modelCount: "{{count}}つのモデル",
modelCountPlural: "{{count}}つのモデル", modelCountPlural: "{{count}}つのモデル",
retry: "再試行", retry: "再試行",
+3
View File
@@ -1473,6 +1473,9 @@ export const ko: TranslationResources = {
editProfiles: "편집", editProfiles: "편집",
editProfilesLabel: "에이전트 프로필 편집", editProfilesLabel: "에이전트 프로필 편집",
createProfile: "프로필 만들기", createProfile: "프로필 만들기",
createProfileFromModel: "이 모델에서 프로필 만들기",
editProfileLabel: "{{name}} 편집",
editProfilesCount: "프로필 편집 ({{count}})",
modelCount: "모델 {{count}}개", modelCount: "모델 {{count}}개",
modelCountPlural: "모델 {{count}}개", modelCountPlural: "모델 {{count}}개",
retry: "다시 시도", retry: "다시 시도",
+3
View File
@@ -1493,6 +1493,9 @@ export const ptBR: TranslationResources = {
editProfiles: "Editar", editProfiles: "Editar",
editProfilesLabel: "Editar perfis de agente", editProfilesLabel: "Editar perfis de agente",
createProfile: "Criar perfil", createProfile: "Criar perfil",
createProfileFromModel: "Criar perfil a partir deste modelo",
editProfileLabel: "Editar {{name}}",
editProfilesCount: "Editar perfis ({{count}})",
modelCount: "{{count}} modelo", modelCount: "{{count}} modelo",
modelCountPlural: "{{count}} modelos", modelCountPlural: "{{count}} modelos",
retry: "Tentar novamente", retry: "Tentar novamente",
+3
View File
@@ -1496,6 +1496,9 @@ export const ru: TranslationResources = {
editProfiles: "Изменить", editProfiles: "Изменить",
editProfilesLabel: "Изменить профили агентов", editProfilesLabel: "Изменить профили агентов",
createProfile: "Создать профиль", createProfile: "Создать профиль",
createProfileFromModel: "Создать профиль из этой модели",
editProfileLabel: "Изменить {{name}}",
editProfilesCount: "Изменить профили ({{count}})",
modelCount: "{{count}} модель", modelCount: "{{count}} модель",
modelCountPlural: "{{count}} моделей", modelCountPlural: "{{count}} моделей",
retry: "Повторить попытку", retry: "Повторить попытку",
+3
View File
@@ -1446,6 +1446,9 @@ export const zhCN: TranslationResources = {
editProfiles: "编辑", editProfiles: "编辑",
editProfilesLabel: "编辑智能体配置文件", editProfilesLabel: "编辑智能体配置文件",
createProfile: "创建配置文件", createProfile: "创建配置文件",
createProfileFromModel: "从此模型创建配置文件",
editProfileLabel: "编辑 {{name}}",
editProfilesCount: "编辑配置文件 ({{count}})",
modelCount: "{{count}} 个模型", modelCount: "{{count}} 个模型",
modelCountPlural: "{{count}} 个模型", modelCountPlural: "{{count}} 个模型",
retry: "重试", retry: "重试",