From e9373c026bb998600f6043975ef2350645d094a3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 19 Aug 2026 07:46:47 +0200 Subject: [PATCH] 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 " 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 " 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. --- .../agent-profiles/agent-profile-editor.tsx | 78 +++++ packages/app/src/agent-profiles/index.ts | 2 + .../internal/profile-form-model.test.ts | 47 +++ .../internal/profile-form-model.ts | 25 +- .../src/agent-profiles/internal/profile-id.ts | 3 + .../internal/use-agent-profile-picker.ts | 3 + .../settings/agent-profile-edit-modal.tsx | 15 +- .../settings/agent-profiles-section.tsx | 5 +- .../components/combined-model-selector.tsx | 24 +- .../src/components/model-browser-view.test.ts | 36 ++- .../app/src/components/model-browser-view.ts | 30 ++ packages/app/src/components/model-browser.tsx | 293 +++++++++++++++++- .../app/src/composer/agent-controls/index.tsx | 101 ++++-- .../composer/agent-controls/model-sheet.tsx | 39 ++- packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + 23 files changed, 674 insertions(+), 54 deletions(-) create mode 100644 packages/app/src/agent-profiles/agent-profile-editor.tsx create mode 100644 packages/app/src/agent-profiles/internal/profile-id.ts diff --git a/packages/app/src/agent-profiles/agent-profile-editor.tsx b/packages/app/src/agent-profiles/agent-profile-editor.tsx new file mode 100644 index 000000000..b30e05185 --- /dev/null +++ b/packages/app/src/agent-profiles/agent-profile-editor.tsx @@ -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(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( + () => ( + + ), + [close, handleSave, request, serverId], + ); + + return useMemo( + () => ({ openCreateFromModel, openEdit, element }), + [openCreateFromModel, openEdit, element], + ); +} diff --git a/packages/app/src/agent-profiles/index.ts b/packages/app/src/agent-profiles/index.ts index d2ffebe78..b819e40fe 100644 --- a/packages/app/src/agent-profiles/index.ts +++ b/packages/app/src/agent-profiles/index.ts @@ -15,6 +15,8 @@ */ export type { AgentProfile } from "@getpaseo/protocol/messages"; 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 { useAgentProfilePicker, diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts index 0b5fe6253..768962183 100644 --- a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts +++ b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts @@ -82,6 +82,53 @@ describe("openAgentProfileForm", () => { 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", () => { const model = openWithCatalog({ mode: "create" }); diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.ts b/packages/app/src/agent-profiles/internal/profile-form-model.ts index ba3a9e522..2ab618505 100644 --- a/packages/app/src/agent-profiles/internal/profile-form-model.ts +++ b/packages/app/src/agent-profiles/internal/profile-form-model.ts @@ -40,9 +40,20 @@ export interface AgentProfileFormOption { 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 { mode: "create" | "edit"; profile?: AgentProfile; + seed?: AgentProfileSeed; } /** @@ -367,14 +378,18 @@ function seedDisplay(value: string | undefined): AgentProfileFormDisplay | null */ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileFormState { 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 { mode: snapshot.mode, - name: profile.name, + name, icon: profile.icon ?? "", color: profile.color ?? "", notes: profile.notes ?? "", - provider: profile.provider, - modelId: profile.model ?? "", + provider, + modelId, modeId: profile.modeId ?? "", thinkingOptionId: profile.thinkingOptionId ?? "", featureValues: { ...profile.featureValues }, @@ -383,8 +398,8 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm modeOptions: [], thinkingOptions: [], features: [], - providerDisplay: seedDisplay(profile.provider), - modelDisplay: seedDisplay(profile.model), + providerDisplay: seedDisplay(provider), + modelDisplay: seedDisplay(modelId), modeDisplay: seedDisplay(profile.modeId), thinkingDisplay: seedDisplay(profile.thinkingOptionId), catalogResolution: "idle", diff --git a/packages/app/src/agent-profiles/internal/profile-id.ts b/packages/app/src/agent-profiles/internal/profile-id.ts new file mode 100644 index 000000000..c9d45ae13 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/profile-id.ts @@ -0,0 +1,3 @@ +export function generateAgentProfileId(): string { + return `agent_profile_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; +} diff --git a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts index e90b8e8bb..c4b97ec22 100644 --- a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts +++ b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts @@ -29,6 +29,8 @@ export type AgentProfileApplyTarget = export interface AgentProfilePickerRow { id: 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: string; color: string; @@ -95,6 +97,7 @@ export function useAgentProfilePicker( applicableProfiles.map((profile) => ({ id: profile.id, provider: profile.provider, + modelId: profile.model?.trim() ?? "", icon: profile.icon ?? "", color: profile.color ?? "", name: profile.name, diff --git a/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx index b4a9cd81e..cc06432d6 100644 --- a/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx +++ b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx @@ -20,6 +20,7 @@ import { AgentProfileAppearanceField } from "./agent-profile-appearance-field"; import type { AgentProfileFormModel, AgentProfileFormOption, + AgentProfileSeed, AgentProfileValue, } from "../internal/profile-form-model"; import { @@ -36,6 +37,7 @@ export interface AgentProfileEditModalProps { visible: boolean; mode: "create" | "edit"; profile?: AgentProfile; + seed?: AgentProfileSeed; onClose: () => void; onSave: (value: AgentProfileValue) => Promise; } @@ -47,7 +49,10 @@ export interface AgentProfileEditModalProps { const UNSET_VALUE = ""; 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, mode, profile, + seed, onClose, onDismiss, onSave, }: AgentProfileEditModalProps & { onDismiss: () => void }): ReactElement { const { t } = useTranslation(); 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 state = useAgentProfileFormState(model); useAgentProfileFormCatalog({ serverId, model }); @@ -234,7 +243,7 @@ function OpenAgentProfileEditModal({ testID="agent-profile-name-field" > ({ color: theme.colors.foregroundMuted }); const addIcon = ; -function generateAgentProfileId(): string { - return `agent_profile_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; -} - interface EditTarget { mode: "create" | "edit"; profile?: AgentProfile; diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index ff8905a49..5639c604a 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -3,7 +3,7 @@ import { Pressable, Text, View, type PressableStateCallbackType } from "react-na import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; 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 { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox"; @@ -30,6 +30,8 @@ interface CombinedModelSelectorProps { profiles?: AgentProfilePicker | null; onApplyProfile?: (profileId: string) => void; onEditProfiles?: () => void; + onCreateProfile?: (seed: AgentProfileSeed) => void; + onEditProfile?: (profileId: string) => void; renderTrigger?: (input: { selectedModelLabel: string; onPress: () => void; @@ -70,6 +72,8 @@ export function CombinedModelSelector({ profiles = null, onApplyProfile, onEditProfiles, + onCreateProfile, + onEditProfile, renderTrigger, onOpen, onClose, @@ -168,12 +172,30 @@ export function CombinedModelSelector({ 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 ? ( { }); }); +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", () => { const claude = provider("claude", "Claude Code", [ modelRow("claude", "Claude Code", "opus-5", "Opus 5"), diff --git a/packages/app/src/components/model-browser-view.ts b/packages/app/src/components/model-browser-view.ts index 707b1c49f..464aa69ef 100644 --- a/packages/app/src/components/model-browser-view.ts +++ b/packages/app/src/components/model-browser-view.ts @@ -9,6 +9,36 @@ export type ModelBrowserView = | { kind: "all" } | { 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( + refs: readonly T[], +): Map { + const lookup = new Map(); + 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. */ export type ModelBrowserAllView = | { kind: "browse" } diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 6c330d6ba..ec8b7513c 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -30,6 +30,7 @@ import { AgentProfileGlyph, type AgentProfilePicker, type AgentProfilePickerRow as AgentProfilePickerRowModel, + type AgentProfileSeed, } from "@/agent-profiles"; import type { SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -52,6 +53,7 @@ import { useProviderSettingsStore } from "@/stores/provider-settings-store"; import { useCurrentOverlayLayer } from "@/lib/overlay-root"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { + groupProfilesByProviderModel, resolveInitialModelBrowserView, resolveModelBrowserAllView, type ModelBrowserView, @@ -176,6 +178,8 @@ interface ModelBrowserProps { /** Applying a profile resolves the pick and dismisses, exactly like a model row. */ onApplyProfile?: (profileId: string) => void; onEditProfiles?: () => void; + onCreateProfile?: (seed: AgentProfileSeed) => void; + onEditProfile?: (profileId: string) => void; onRetryProvider?: (provider: AgentProvider) => void; isRetryingProvider?: boolean; scrolling?: "sheet" | "independent"; @@ -183,6 +187,8 @@ interface ModelBrowserProps { searchAllOnFocus?: boolean; /** Replaces provider rows only while the all-provider search is empty. */ rootBrowseContent?: React.ReactNode; + /** Hide the pinned Profiles section while still using rows for model matching. */ + showProfilesSection?: boolean; } interface ModelBrowserContentProps extends Omit { @@ -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 ( + + + + {children} + + + + {label} + + + ); +} + function ModelRow({ row, isSelected, showProviderLabel = false, onPress, + profiledRows, + onCreateProfile, + onEditProfile, + onEditProfiles, }: { row: ProviderSelectionModelRow; isSelected: boolean; showProviderLabel?: boolean; 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( () => , [row.provider], ); 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 ( + + + + ); + } + if (profiledRows.length === 1) { + if (!onEditProfile) { + return null; + } + return ( + + + + ); + } + if (!onEditProfiles) { + return null; + } + return ( + + + + ); + }, [ + 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 ( - + + + + {leadingSlot} + + + {row.modelLabel} + + {description ? ( + + {description} + + ) : null} + + + + {isSelected ? ( + + ) : null} + + {profileAction} + + + + ); } @@ -627,11 +813,19 @@ function SelectableModelRow({ isSelected, showProviderLabel, onSelect, + profiledRows, + onCreateProfile, + onEditProfile, + onEditProfiles, }: { row: ProviderSelectionModelRow; isSelected: boolean; showProviderLabel?: boolean; onSelect: (provider: string, modelId: string) => void; + profiledRows: AgentProfilePickerRowModel[]; + onCreateProfile?: (seed: AgentProfileSeed) => void; + onEditProfile?: (profileId: string) => void; + onEditProfiles?: () => void; }) { const handlePress = useCallback(() => { onSelect(row.provider, row.modelId); @@ -642,6 +836,10 @@ function SelectableModelRow({ isSelected={isSelected} showProviderLabel={showProviderLabel} onPress={handlePress} + profiledRows={profiledRows} + onCreateProfile={onCreateProfile} + onEditProfile={onEditProfile} + onEditProfiles={onEditProfiles} /> ); } @@ -919,6 +1117,10 @@ function ModelRowList({ showProviderLabel = false, header, scrolling, + profiledLookup, + onCreateProfile, + onEditProfile, + onEditProfiles, }: { rows: ProviderSelectionModelRow[]; selectedProvider: string; @@ -927,6 +1129,10 @@ function ModelRowList({ showProviderLabel?: boolean; header?: React.ReactElement; scrolling: "sheet" | "independent"; + profiledLookup: Map; + onCreateProfile?: (seed: AgentProfileSeed) => void; + onEditProfile?: (profileId: string) => void; + onEditProfiles?: () => void; }) { const isCompact = useIsCompactFormFactor(); const renderItem = useCallback( @@ -936,9 +1142,22 @@ function ModelRowList({ isSelected={item.provider === selectedProvider && item.modelId === selectedModel} showProviderLabel={showProviderLabel} 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, []); @@ -1019,6 +1238,10 @@ function ProviderModelBrowserContent({ onSelect, onApplyProfile, onEditProfiles, + onCreateProfile, + onEditProfile, + profiledLookup, + showProfilesSection = true, onRetryProvider, isRetryingProvider, scrolling, @@ -1032,6 +1255,10 @@ function ProviderModelBrowserContent({ onSelect: (provider: string, modelId: string) => void; onApplyProfile?: (profileId: string) => void; onEditProfiles?: () => void; + onCreateProfile?: (seed: AgentProfileSeed) => void; + onEditProfile?: (profileId: string) => void; + profiledLookup: Map; + showProfilesSection?: boolean; onRetryProvider?: (provider: AgentProvider) => void; isRetryingProvider: boolean; scrolling: "sheet" | "independent"; @@ -1047,14 +1274,21 @@ function ProviderModelBrowserContent({ ); const profileHeader = useMemo( () => - normalizedQuery.length === 0 && profiles ? ( + normalizedQuery.length === 0 && showProfilesSection && profiles ? ( ) : undefined, - [normalizedQuery, onApplyProfile, onEditProfiles, profiles, providerProfileRows], + [ + normalizedQuery, + onApplyProfile, + onEditProfiles, + profiles, + providerProfileRows, + showProfilesSection, + ], ); if (!provider) return ; @@ -1090,6 +1324,10 @@ function ProviderModelBrowserContent({ onSelect={onSelect} header={profileHeader} scrolling={scrolling} + profiledLookup={profiledLookup} + onCreateProfile={onCreateProfile} + onEditProfile={onEditProfile} + onEditProfiles={onEditProfiles} /> ); } @@ -1105,15 +1343,22 @@ function ModelBrowserContent({ onSelect, onApplyProfile, onEditProfiles, + onCreateProfile, + onEditProfile, onDrillDown, onRetryProvider, isRetryingProvider = false, scrolling, searchAllOnFocus, rootBrowseContent, + showProfilesSection = true, }: ModelBrowserContentProps) { const { t } = useTranslation(); const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); + const profiledLookup = useMemo( + () => groupProfilesByProviderModel(profiles?.rows ?? []), + [profiles], + ); const selectedViewProvider = useMemo( () => view.kind === "provider" @@ -1144,6 +1389,10 @@ function ModelBrowserContent({ onSelect={onSelect} onApplyProfile={onApplyProfile} onEditProfiles={onEditProfiles} + onCreateProfile={onCreateProfile} + onEditProfile={onEditProfile} + profiledLookup={profiledLookup} + showProfilesSection={showProfilesSection} onRetryProvider={onRetryProvider} isRetryingProvider={isRetryingProvider} scrolling={scrolling} @@ -1171,13 +1420,17 @@ function ModelBrowserContent({ onSelect={onSelect} showProviderLabel scrolling={scrolling} + profiledLookup={profiledLookup} + onCreateProfile={onCreateProfile} + onEditProfile={onEditProfile} + onEditProfiles={onEditProfiles} /> ); } const allProvidersContent = ( - {profiles ? ( + {showProfilesSection && profiles ? ( 0 ? ( - {profiles ? ( + {showProfilesSection && profiles ? ( {t("modelSelector.providers")} @@ -1211,11 +1464,14 @@ export function ModelBrowser({ onSelect, onApplyProfile, onEditProfiles, + onCreateProfile, + onEditProfile, onRetryProvider, isRetryingProvider = false, scrolling = "sheet", searchAllOnFocus = false, rootBrowseContent, + showProfilesSection, }: ModelBrowserProps) { return ( ); } @@ -1279,6 +1538,9 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], minHeight: 36, }, + modelRowHoverBoundary: { + position: "relative", + }, browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] }, browserRowHovered: { backgroundColor: theme.colors.surface1, @@ -1371,6 +1633,9 @@ const styles = StyleSheet.create((theme) => ({ rowIconButtonPressed: { backgroundColor: theme.colors.surface1, }, + profileActionHidden: { + opacity: 0, + }, emptyState: { paddingVertical: theme.spacing[4], alignItems: "center", diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 652564262..32941e46e 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -82,9 +82,12 @@ import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; import { + useAgentProfileEditor, useAgentProfilePicker, type AgentProfileApplyTarget, + type AgentProfileEditorControls, type AgentProfilePicker, + type AgentProfileSeed, type DraftAgentProfileControls, } from "@/agent-profiles"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; @@ -116,6 +119,8 @@ interface ControlledAgentControlsProps { agentProfiles?: AgentProfilePicker | null; onApplyAgentProfile?: (profileId: string) => void; onEditAgentProfiles?: () => void; + onCreateAgentProfile?: (seed: AgentProfileSeed) => void; + onEditAgentProfile?: (profileId: string) => void; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; onDropdownClose?: () => void; @@ -281,6 +286,22 @@ function useEditAgentProfilesNavigation( 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( provider: string, modelOptions: AgentControlOption[] | undefined, @@ -468,6 +489,8 @@ function ControlledAgentControls({ agentProfiles = null, onApplyAgentProfile, onEditAgentProfiles, + onCreateAgentProfile, + onEditAgentProfile, features, onSetFeature, onDropdownClose, @@ -722,6 +745,8 @@ function ControlledAgentControls({ onSetFeature={onSetFeature} onApplyAgentProfile={onApplyAgentProfile} onEditAgentProfiles={onEditAgentProfiles} + onCreateAgentProfile={onCreateAgentProfile} + onEditAgentProfile={onEditAgentProfile} onDropdownClose={onDropdownClose} onModelSelectorOpen={onModelSelectorOpen} onRetryModelProvider={onRetryModelProvider} @@ -769,6 +794,8 @@ function ControlledAgentControls({ onSetFeature={onSetFeature} onApplyAgentProfile={onApplyAgentProfile} onEditAgentProfiles={onEditAgentProfiles} + onCreateAgentProfile={onCreateAgentProfile} + onEditAgentProfile={onEditAgentProfile} onDropdownClose={onDropdownClose} onModelSelectorOpen={onModelSelectorOpen} onRetryModelProvider={onRetryModelProvider} @@ -813,6 +840,8 @@ interface DesktopAgentControlsContentProps { onSetFeature?: (featureId: string, value: unknown) => void; onApplyAgentProfile?: (profileId: string) => void; onEditAgentProfiles?: () => void; + onCreateAgentProfile?: (seed: AgentProfileSeed) => void; + onEditAgentProfile?: (profileId: string) => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; @@ -873,6 +902,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { onSetFeature, onApplyAgentProfile, onEditAgentProfiles, + onCreateAgentProfile, + onEditAgentProfile, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, @@ -961,6 +992,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { profiles={agentProfiles} onApplyProfile={onApplyAgentProfile} onEditProfiles={onEditAgentProfiles} + onCreateProfile={onCreateAgentProfile} + onEditProfile={onEditAgentProfile} isLoading={isModelLoading} disabled={modelDisabled} onOpen={onModelSelectorOpen} @@ -1079,6 +1112,8 @@ interface SheetAgentControlsContentProps { onSetFeature?: (featureId: string, value: unknown) => void; onApplyAgentProfile?: (profileId: string) => void; onEditAgentProfiles?: () => void; + onCreateAgentProfile?: (seed: AgentProfileSeed) => void; + onEditAgentProfile?: (profileId: string) => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; @@ -1121,6 +1156,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { onSetFeature, onApplyAgentProfile, onEditAgentProfiles, + onCreateAgentProfile, + onEditAgentProfile, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, @@ -1222,6 +1259,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { profiles={agentProfiles} onApplyProfile={onApplyAgentProfile} onEditProfiles={onEditAgentProfiles} + onCreateProfile={onCreateAgentProfile} + onEditProfile={onEditAgentProfile} isLoading={isModelLoading} disabled={modelDisabled} onOpen={onModelSelectorOpen} @@ -1610,6 +1649,8 @@ export const AgentControls = memo(function AgentControls({ target: profileTarget, }); const handleEditAgentProfiles = useEditAgentProfilesNavigation(serverId, agentProfiles !== null); + const profileEditor = useAgentProfileEditor(serverId); + const profileActions = resolveAgentProfileEditorActions(agentProfiles !== null, profileEditor); const handleSelectThinkingOption = useCallback( (thinkingOptionId: string) => { @@ -1735,6 +1776,7 @@ export const AgentControls = memo(function AgentControls({ return ( <> {commandCenterRegistration} + {profileEditor.element} 1 ? thinkingOptions : undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} onSelectThinkingOption={handleSelectThinkingOption} @@ -1830,6 +1874,8 @@ export function DraftAgentControls({ modelSelectorServerId, agentProfiles !== null, ); + const profileEditor = useAgentProfileEditor(modelSelectorServerId); + const profileActions = resolveAgentProfileEditorActions(agentProfiles !== null, profileEditor); const modeControl = useMemo( () => @@ -1847,31 +1893,36 @@ export function DraftAgentControls({ ); return ( - 0 ? mappedThinkingOptions : undefined} - selectedThinkingOptionId={effectiveSelectedThinkingOption} - onSelectThinkingOption={onSelectThinkingOption} - features={features} - onSetFeature={onSetFeature} - onDropdownClose={onDropdownClose} - onModelSelectorOpen={onModelSelectorOpen} - onRetryModelProvider={onRetryModelProvider} - isRetryingModelProvider={isRetryingModelProvider} - disabled={disabled} - modeControl={modeControl} - modelSelectorServerId={modelSelectorServerId} - isCompactLayout={isCompactLayout} - /> + <> + {profileEditor.element} + 0 ? mappedThinkingOptions : undefined} + selectedThinkingOptionId={effectiveSelectedThinkingOption} + onSelectThinkingOption={onSelectThinkingOption} + features={features} + onSetFeature={onSetFeature} + onDropdownClose={onDropdownClose} + onModelSelectorOpen={onModelSelectorOpen} + onRetryModelProvider={onRetryModelProvider} + isRetryingModelProvider={isRetryingModelProvider} + disabled={disabled} + modeControl={modeControl} + modelSelectorServerId={modelSelectorServerId} + isCompactLayout={isCompactLayout} + /> + ); } diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index 05164b5f6..3e11e9040 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -4,7 +4,7 @@ import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } fro import { StyleSheet } from "react-native-unistyles"; import { Bot } from "lucide-react-native"; 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 { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { getProviderIcon } from "@/components/provider-icons"; @@ -36,6 +36,8 @@ interface CompactModelSheetProps { profiles?: AgentProfilePicker | null; onApplyProfile?: (profileId: string) => void; onEditProfiles?: () => void; + onCreateProfile?: (seed: AgentProfileSeed) => void; + onEditProfile?: (profileId: string) => void; onOpen?: () => void; onClose?: () => void; onRetryProvider?: (provider: AgentProvider) => void; @@ -52,6 +54,30 @@ function shortModelLabel(label: string): string { return separatorIndex === -1 ? label : label.slice(separatorIndex + 1); } +function resolveModelSheetProfileActions( + onCreateProfile: ((seed: AgentProfileSeed) => void) | undefined, + onEditProfile: ((profileId: string) => void) | undefined, + close: () => void, +): { + create?: (seed: AgentProfileSeed) => void; + edit?: (profileId: string) => void; +} { + return { + create: onCreateProfile + ? (seed: AgentProfileSeed) => { + close(); + onCreateProfile(seed); + } + : undefined, + edit: onEditProfile + ? (profileId: string) => { + close(); + onEditProfile(profileId); + } + : undefined, + }; +} + export function CompactModelSheet({ providers, selectedProvider, @@ -62,6 +88,8 @@ export function CompactModelSheet({ profiles = null, onApplyProfile, onEditProfiles, + onCreateProfile, + onEditProfile, onOpen, onClose, onRetryProvider, @@ -98,6 +126,7 @@ export function CompactModelSheet({ selectedModel, isLoading, autoFocusSearch: isWeb && !usesBottomSheet, + profiles, serverId, }); const ProviderIcon = @@ -191,6 +220,8 @@ export function CompactModelSheet({ onEditProfiles?.(); }, [close, onEditProfiles]); + const profileActions = resolveModelSheetProfileActions(onCreateProfile, onEditProfile, close); + const toggle = useCallback(() => { if (isOpen) { close(); @@ -284,6 +315,8 @@ export function CompactModelSheet({ onSelect={usesBottomSheet ? handleSearchSelect : handleDesktopSelect} onApplyProfile={handleApplyProfile} onEditProfiles={onEditProfiles ? handleEditProfiles : undefined} + onCreateProfile={profileActions.create} + onEditProfile={profileActions.edit} onRetryProvider={onRetryProvider} isRetryingProvider={isRetryingProvider} scrolling={modelBrowserScrolling} @@ -326,10 +359,14 @@ export function CompactModelSheet({ diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9a3a59421..329e01670 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1463,6 +1463,9 @@ export const ar: TranslationResources = { editProfiles: "تحرير", editProfilesLabel: "تحرير ملفات الوكيل", createProfile: "إنشاء ملف شخصي", + createProfileFromModel: "إنشاء ملف شخصي من هذا النموذج", + editProfileLabel: "تحرير {{name}}", + editProfilesCount: "تحرير الملفات الشخصية ({{count}})", modelCount: "{{count}} نموذج", modelCountPlural: "{{count}} نماذج", retry: "أعد المحاولة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c227200b3..1ed69b42b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1473,6 +1473,9 @@ export const en = { editProfiles: "Edit", editProfilesLabel: "Edit agent profiles", createProfile: "Create profile", + createProfileFromModel: "Create profile from this model", + editProfileLabel: "Edit {{name}}", + editProfilesCount: "Edit profiles ({{count}})", modelCount: "{{count}} model", modelCountPlural: "{{count}} models", retry: "Retry", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index cae447fa0..9d081cb3a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1507,6 +1507,9 @@ export const es: TranslationResources = { editProfiles: "Editar", editProfilesLabel: "Editar perfiles de agente", createProfile: "Crear perfil", + createProfileFromModel: "Crear perfil desde este modelo", + editProfileLabel: "Editar {{name}}", + editProfilesCount: "Editar perfiles ({{count}})", modelCount: "{{count}} modelo", modelCountPlural: "{{count}} modelos", retry: "Rever", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 8938b6a84..7ad69348c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1511,6 +1511,9 @@ export const fr: TranslationResources = { editProfiles: "Modifier", editProfilesLabel: "Modifier les profils d'agent", 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", modelCountPlural: "{{count}} modèles", retry: "Réessayer", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 79542db15..8895fcf6e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1478,6 +1478,9 @@ export const ja: TranslationResources = { editProfiles: "編集", editProfilesLabel: "エージェントプロファイルを編集", createProfile: "プロファイルを作成", + createProfileFromModel: "このモデルからプロファイルを作成", + editProfileLabel: "{{name}}を編集", + editProfilesCount: "プロファイルを編集({{count}})", modelCount: "{{count}}つのモデル", modelCountPlural: "{{count}}つのモデル", retry: "再試行", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 0d8bd066f..fb4e5d8b6 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1473,6 +1473,9 @@ export const ko: TranslationResources = { editProfiles: "편집", editProfilesLabel: "에이전트 프로필 편집", createProfile: "프로필 만들기", + createProfileFromModel: "이 모델에서 프로필 만들기", + editProfileLabel: "{{name}} 편집", + editProfilesCount: "프로필 편집 ({{count}})", modelCount: "모델 {{count}}개", modelCountPlural: "모델 {{count}}개", retry: "다시 시도", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index dd1488af6..f42a28f0e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1493,6 +1493,9 @@ export const ptBR: TranslationResources = { editProfiles: "Editar", editProfilesLabel: "Editar perfis de agente", createProfile: "Criar perfil", + createProfileFromModel: "Criar perfil a partir deste modelo", + editProfileLabel: "Editar {{name}}", + editProfilesCount: "Editar perfis ({{count}})", modelCount: "{{count}} modelo", modelCountPlural: "{{count}} modelos", retry: "Tentar novamente", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index c2a7b4a85..b11d0cc70 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1496,6 +1496,9 @@ export const ru: TranslationResources = { editProfiles: "Изменить", editProfilesLabel: "Изменить профили агентов", createProfile: "Создать профиль", + createProfileFromModel: "Создать профиль из этой модели", + editProfileLabel: "Изменить {{name}}", + editProfilesCount: "Изменить профили ({{count}})", modelCount: "{{count}} модель", modelCountPlural: "{{count}} моделей", retry: "Повторить попытку", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index b1a70bcb5..45ff5248e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1446,6 +1446,9 @@ export const zhCN: TranslationResources = { editProfiles: "编辑", editProfilesLabel: "编辑智能体配置文件", createProfile: "创建配置文件", + createProfileFromModel: "从此模型创建配置文件", + editProfileLabel: "编辑 {{name}}", + editProfilesCount: "编辑配置文件 ({{count}})", modelCount: "{{count}} 个模型", modelCountPlural: "{{count}} 个模型", retry: "重试",