Add reusable agent profiles (#3208)

* Search models across all providers from the picker root

* Store agent profiles in daemon config and apply agent config in one RPC

An agent profile is a named bundle of provider, model, mode, thinking
option, feature values and notes, stored host-wide next to terminal
profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is
creation-only, so a profile holding one would apply when starting an agent
and silently do nothing when applied to a running one.

Applying a profile through the four single-field RPCs can fail halfway and
leave an agent matching no profile, so agent.config.apply takes the whole
bundle. Model runs first because a thinking option only means anything
against the model that offers it.

Old daemons parse their persisted config strictly and would drop an
unknown agentProfiles key, making a save appear to succeed and vanish, so
both capabilities are advertised on server_info for the client to gate on.

list_profiles exposes the profiles to orchestrating agents, which read the
notes to pick one and copy its values into create_agent.

* Add the agent profiles settings section

Profiles live behind one module boundary so removing the feature is a
directory plus one mount, which is what favourites never was. The picker
and composer will ask for rows and an apply function rather than learning
what a profile contains.

The edit form follows the form-model pattern instead of the terminal
profile modal's per-field state, because provider selection cascades into
which models, modes, thinking levels and features exist below it.

The section is gated on server_info.features.agentProfiles: an older daemon
drops the key on write, and a save that appears to work and then vanishes
is worse than an unavailable state.

* Document agent profiles and name the four senses of profile

The glossary reserved Profile for HostProfile, terminal profiles owned the
user-facing word, and custom-providers.md used it for provider aliases.
Agent profile is now the term, with all four cross-referenced.

data-model.md never documented terminalProfiles either, so both profile
lists are backfilled together along with the whole-list patch semantics and
why absent and empty differ.

* Pin agent profiles to the model picker and delete favourites

Applying a profile writes its values into the agent controls and is then
forgotten, so rows carry no checkmark and no selected state: after applying,
nothing is selected. Chevron still means navigate deeper, so the absence of
one is what marks a row as an action.

A running agent is its provider's process and agent.config.apply carries no
provider, so the live picker only offers profiles matching the agent's
provider rather than showing rows that cannot do what they say. The draft
path has no such limit and switches provider freely.

Draft feature values go through preferences rather than the feature
callback alone, because the draft feature hook clears local values whenever
the provider changes and a provider-switching profile would lose them.

Favourites are deleted rather than migrated: they were device-local
AsyncStorage keyed by provider and model, while profiles are host-wide
daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity
and moves to its real owner; renaming it is its own commit.

* Cover agent profiles and cross-provider search with E2E specs

Model rows expressed selection only as a checkmark glyph, which assistive
tech cannot see and a spec cannot assert. Rows that draw the indicator now
carry aria-selected; profile rows deliberately carry none, because a
profile is an action rather than a selection. That is what makes "nothing
is marked selected after applying a profile" a real assertion instead of a
tautology, with the materialized model one level down as the control.

The search fixture extends claude rather than mock: provider-config.ts
only allows the six shipped providers as extends targets, so a derived
provider cannot inherit the dev-only mock provider.

* State what applying a config bundle does on partial failure

applyBundle is not a transaction and the earlier commit message implied it
was. A step that rejects aborts the rest and the steps that already applied
stay applied, which a profile omitting modelId can reach: provider setters
validate against the agent's live model, so mode can land while a feature
that model does not support is rejected.

Nothing is hidden by that. Every setter emits agent state before returning,
so what applied is streamed to the client next to the rejected response.
Rolling back would re-invoke the setters that just failed, usually against
a session that is gone, and pre-flight validation would restate each
provider's model-dependent checks where they would drift from the setters
that own them.

What sending the bundle in one request removes is the multi-round-trip
failure class: interruption between hops, and interleaving with another
client. The test already pinned this behaviour; its name now says so.

* feat(agent-profiles): refine profile discovery and appearance

Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably.

* fix(agent-profiles): defer profile swatch styles

Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render.
This commit is contained in:
Mohamed Boudra
2026-08-11 17:42:35 +02:00
committed by GitHub
parent 07044136c8
commit 308e9af845
72 changed files with 6348 additions and 538 deletions
@@ -128,9 +128,13 @@ const styles = StyleSheet.create((theme) => ({
// Horizontal padding matches the model picker's row indent: the picker uses
// children mode (desktopChildrenScrollContent, no scroll padding), so the
// row content starts at item.paddingHorizontal = spacing[3].
// The search row below owns the gap under the title: the input already
// carries its own vertical padding, so a paddingBottom here would stack on
// top of two more and push the title far off the field.
inlineHeaderRow: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -3,6 +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 { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
@@ -12,7 +13,6 @@ import type { ProviderSelectorProvider } from "@/provider-selection/provider-sel
import { ICON_SIZE, type Theme } from "@/styles/theme";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const EMPTY_FAVORITE_KEYS = new Set<string>();
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedMapping = (theme: Theme) => ({
@@ -27,8 +27,9 @@ interface CombinedModelSelectorProps {
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
favoriteKeys?: Set<string>;
onToggleFavorite?: (provider: string, modelId: string) => void;
profiles?: AgentProfilePicker | null;
onApplyProfile?: (profileId: string) => void;
onEditProfiles?: () => void;
renderTrigger?: (input: {
selectedModelLabel: string;
onPress: () => void;
@@ -66,8 +67,9 @@ export function CombinedModelSelector({
selectedModel,
onSelect,
isLoading,
favoriteKeys = EMPTY_FAVORITE_KEYS,
onToggleFavorite,
profiles = null,
onApplyProfile,
onEditProfiles,
renderTrigger,
onOpen,
onClose,
@@ -89,7 +91,7 @@ export function CombinedModelSelector({
selectedProvider,
selectedModel,
isLoading,
favoriteKeys,
profiles,
serverId,
});
const { prepareToOpen, reset } = browser;
@@ -153,13 +155,28 @@ export function CombinedModelSelector({
[disabled, isOpen, renderTrigger, triggerFill],
);
const handleApplyProfile = useCallback(
(profileId: string) => {
onApplyProfile?.(profileId);
handleOpenChange(false);
},
[handleOpenChange, onApplyProfile],
);
const handleEditProfiles = useCallback(() => {
handleOpenChange(false);
onEditProfiles?.();
}, [handleOpenChange, onEditProfiles]);
const selectorBody = isContentReady ? (
<ModelBrowser
state={browser}
onSelect={handleSelect}
onToggleFavorite={onToggleFavorite}
onApplyProfile={handleApplyProfile}
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
scrolling={isWeb ? "independent" : "sheet"}
/>
) : (
<View style={styles.sheetLoadingState}>
@@ -230,7 +247,9 @@ export function CombinedModelSelector({
anchorRef={anchorRef}
desktopPlacement={desktopPlacement}
desktopMinWidth={desktopMinWidth}
desktopLockWidth
desktopFixedHeight={browser.desktopFixedHeight}
desktopChildrenScrollEnabled={false}
header={browser.header}
mobileChildrenScrollEnabled={!browser.isProviderView || !isNative}
mobileChildrenContentContainerStyle={styles.mobileBrowserContent}
@@ -1,12 +1,35 @@
import { describe, expect, it } from "vitest";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { resolveInitialModelBrowserView } from "./model-browser-view";
import type {
ProviderSelectionModelRow,
ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { resolveInitialModelBrowserView, resolveModelBrowserAllView } from "./model-browser-view";
function provider(id: string, label: string): ProviderSelectorProvider {
function provider(
id: string,
label: string,
rows: ProviderSelectionModelRow[] = [],
): ProviderSelectorProvider {
return {
id,
label,
modelSelection: { kind: "models", rows: [] },
modelSelection: { kind: "models", rows },
};
}
function modelRow(
providerId: string,
providerLabel: string,
modelId: string,
modelLabel: string,
): ProviderSelectionModelRow {
return {
favoriteKey: `${providerId}:${modelId}`,
provider: providerId,
providerLabel,
modelId,
modelLabel,
description: modelId,
};
}
@@ -20,30 +43,111 @@ describe("model browser initial view", () => {
providers: [pi],
selectedProvider: "",
selectedModel: "",
favoriteKeys: new Set(),
hasProfiles: false,
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the selected provider when its model is not a favorite", () => {
it("opens the selected provider when there is one", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(),
hasProfiles: false,
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
});
it("opens the provider overview when the selected model is a favorite", () => {
it("opens the root so pinned profiles are reachable", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
favoriteKeys: new Set(["pi:pi-pro"]),
hasProfiles: true,
}),
).toEqual({ kind: "all" });
});
it("keeps the root even when a single provider would otherwise be skipped", () => {
expect(
resolveInitialModelBrowserView({
providers: [pi],
selectedProvider: "pi",
selectedModel: "pi-pro",
hasProfiles: true,
}),
).toEqual({ kind: "all" });
});
it("falls back to the root when the selected provider is gone", () => {
expect(
resolveInitialModelBrowserView({
providers: [codex, pi],
selectedProvider: "gemini",
selectedModel: "gemini-3",
hasProfiles: false,
}),
).toEqual({ kind: "all" });
});
});
describe("model browser all view", () => {
const claude = provider("claude", "Claude Code", [
modelRow("claude", "Claude Code", "opus-5", "Opus 5"),
modelRow("claude", "Claude Code", "sonnet-4.6", "Sonnet 4.6"),
]);
const copilot = provider("copilot", "Copilot", [
modelRow("copilot", "Copilot", "claude-opus-5", "Opus 5"),
]);
const codex = provider("codex", "Codex", [modelRow("codex", "Codex", "gpt-5.4", "GPT-5.4")]);
const providers = [claude, copilot, codex];
it("browses providers while the query is empty", () => {
expect(resolveModelBrowserAllView({ providers, normalizedQuery: "" })).toEqual({
kind: "browse",
});
});
it("ranks the same model label across every provider that offers it", () => {
const view = resolveModelBrowserAllView({ providers, normalizedQuery: "opus" });
expect(view.kind).toBe("searchResults");
expect(view.kind === "searchResults" ? view.rows.map((row) => row.favoriteKey) : []).toEqual([
"claude:opus-5",
"copilot:claude-opus-5",
]);
});
it("matches models by their provider label", () => {
const view = resolveModelBrowserAllView({ providers, normalizedQuery: "codex" });
expect(view.kind === "searchResults" ? view.rows.map((row) => row.modelId) : []).toEqual([
"gpt-5.4",
]);
});
it("reports no matches instead of falling back to the provider list", () => {
expect(resolveModelBrowserAllView({ providers, normalizedQuery: "zzzz" })).toEqual({
kind: "noSearchMatches",
});
});
it("ignores providers that are still loading or errored", () => {
const loading: ProviderSelectorProvider = {
id: "opencode",
label: "OpenCode",
modelSelection: { kind: "loading" },
};
const failed: ProviderSelectorProvider = {
id: "pi",
label: "Pi",
modelSelection: { kind: "error", message: "unavailable" },
};
expect(
resolveModelBrowserAllView({ providers: [loading, failed], normalizedQuery: "opus" }),
).toEqual({ kind: "noSearchMatches" });
});
});
@@ -1,20 +1,57 @@
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import {
filterAndRankModelRows,
getAllProviderModelRows,
type ProviderSelectionModelRow,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
export type ModelBrowserView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
/** What the root view shows: the provider drill-down, or ranked cross-provider results. */
export type ModelBrowserAllView =
| { kind: "browse" }
| { kind: "searchResults"; rows: ProviderSelectionModelRow[] }
| { kind: "noSearchMatches" };
export function resolveModelBrowserAllView({
providers,
normalizedQuery,
}: {
providers: ProviderSelectorProvider[];
normalizedQuery: string;
}): ModelBrowserAllView {
if (!normalizedQuery) {
return { kind: "browse" };
}
const rows = filterAndRankModelRows(getAllProviderModelRows(providers), normalizedQuery);
if (rows.length === 0) {
return { kind: "noSearchMatches" };
}
return { kind: "searchResults", rows };
}
/**
* Where the picker lands when it opens. Agent profiles live on the root view, so
* a host that has any profile always opens there — including the single-provider
* case, which would otherwise skip the root entirely and hide them.
*/
export function resolveInitialModelBrowserView({
providers,
selectedProvider,
selectedModel,
favoriteKeys,
hasProfiles,
}: {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
hasProfiles: boolean;
}): ModelBrowserView {
if (hasProfiles) {
return { kind: "all" };
}
const singleProvider = providers.length === 1 ? providers[0] : undefined;
if (singleProvider) {
return {
@@ -24,12 +61,7 @@ export function resolveInitialModelBrowserView({
};
}
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
const shouldOpenSelectedProvider =
selectedProvider.length > 0 &&
selectedModel.length > 0 &&
!favoriteKeys.has(selectedFavoriteKey);
if (shouldOpenSelectedProvider) {
if (selectedProvider.length > 0 && selectedModel.length > 0) {
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider) {
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ function buildWidthStyle(input: {
}): Pick<ViewStyle, "width" | "minWidth" | "maxWidth"> {
const [frameStyle] = buildDesktopFrameStyle({
desktopMinWidth: input.desktopMinWidth,
desktopLockWidth: false,
referenceWidth: input.referenceWidth,
desktopFixedHeight: undefined,
desktopPositionStyle: { left: 0, top: 0 },
@@ -55,4 +56,18 @@ describe("buildDesktopFrameStyle", () => {
maxWidth: 400,
});
});
it("locks the frame to its opening floor when content must not resize it", () => {
const [frameStyle] = buildDesktopFrameStyle({
desktopMinWidth: 360,
desktopLockWidth: true,
referenceWidth: 120,
desktopFixedHeight: undefined,
desktopPositionStyle: { left: 0, top: 0 },
shouldHideDesktopContent: false,
availableHeight: undefined,
}) as ViewStyle[];
expect(frameStyle).toMatchObject({ width: 360, minWidth: 360, maxWidth: 360 });
});
});
@@ -2,6 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native";
export interface DesktopFrameStyleInput {
desktopMinWidth: number | undefined;
desktopLockWidth: boolean;
referenceWidth: number | null;
desktopFixedHeight: number | undefined;
desktopPositionStyle: StyleProp<ViewStyle>;
@@ -12,6 +13,7 @@ export interface DesktopFrameStyleInput {
export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp<ViewStyle> {
const {
desktopMinWidth,
desktopLockWidth,
referenceWidth,
desktopFixedHeight,
desktopPositionStyle,
@@ -28,11 +30,13 @@ export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200);
const widthStyle = desktopLockWidth
? { width: floor, minWidth: floor, maxWidth: floor }
: { minWidth: floor, maxWidth: Math.max(400, floor) };
return [
{
position: "absolute" as const,
minWidth: floor,
maxWidth: Math.max(400, floor),
...widthStyle,
},
fixedHeightStyle,
desktopPositionStyle,
+30 -2
View File
@@ -108,6 +108,8 @@ export interface ComboboxProps {
*/
header?: SheetHeader;
mobileChildrenScrollEnabled?: boolean;
/** Let custom desktop children own scrolling, for example a virtualized list. */
desktopChildrenScrollEnabled?: boolean;
/** Overrides the mobile scroll container spacing for custom child content. */
mobileChildrenContentContainerStyle?: StyleProp<ViewStyle>;
presentation?: "push" | "replace";
@@ -122,6 +124,8 @@ export interface ComboboxProps {
desktopPreventInitialFlash?: boolean;
/** Minimum width for the desktop popover (overrides trigger-based width). */
desktopMinWidth?: number;
/** Keep the desktop popover at its opening width as its content changes. */
desktopLockWidth?: boolean;
/** Fixed height for the desktop popover (overrides default 400px max). */
desktopFixedHeight?: number;
/** Content rendered above the scroll area on desktop (sticky header). */
@@ -1080,17 +1084,28 @@ interface DesktopBodyProps {
handleSelect: (id: string) => void;
renderOption: RenderOptionFn | undefined;
hasChildren: boolean;
childrenScrollEnabled: boolean;
children: ReactNode;
}
function DesktopComboboxChildrenBody(props: {
header: SheetHeader | undefined;
stickyHeader: ReactNode;
scrollEnabled: boolean;
children: ReactNode;
}): ReactElement {
const header = props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader;
if (!props.scrollEnabled) {
return (
<>
{header}
<View style={styles.desktopChildrenBody}>{props.children}</View>
</>
);
}
return (
<>
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
{header}
<ScrollView
contentContainerStyle={styles.desktopChildrenScrollContent}
keyboardShouldPersistTaps="handled"
@@ -1218,7 +1233,11 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
onLayout={props.handleDesktopContentLayout}
>
{props.hasChildren ? (
<DesktopComboboxChildrenBody header={props.header} stickyHeader={props.stickyHeader}>
<DesktopComboboxChildrenBody
header={props.header}
stickyHeader={props.stickyHeader}
scrollEnabled={props.childrenScrollEnabled}
>
{props.children}
</DesktopComboboxChildrenBody>
) : (
@@ -1284,6 +1303,7 @@ export function Combobox({
title,
header,
mobileChildrenScrollEnabled = true,
desktopChildrenScrollEnabled = true,
mobileChildrenContentContainerStyle,
presentation,
open,
@@ -1291,6 +1311,7 @@ export function Combobox({
desktopPlacement = "bottom-start",
desktopPreventInitialFlash = true,
desktopMinWidth,
desktopLockWidth,
desktopFixedHeight,
stickyHeader,
footer,
@@ -1538,6 +1559,7 @@ export function Combobox({
() =>
buildDesktopFrameStyle({
desktopMinWidth,
desktopLockWidth: Boolean(desktopLockWidth),
referenceWidth,
desktopFixedHeight,
desktopPositionStyle,
@@ -1546,6 +1568,7 @@ export function Combobox({
}),
[
desktopMinWidth,
desktopLockWidth,
referenceWidth,
desktopFixedHeight,
desktopPositionStyle,
@@ -1630,6 +1653,7 @@ export function Combobox({
handleSelect={handleSelect}
renderOption={renderOption}
hasChildren={hasChildren}
childrenScrollEnabled={desktopChildrenScrollEnabled}
>
{children}
</DesktopComboboxBody>
@@ -1786,6 +1810,10 @@ const styles = StyleSheet.create((theme) => ({
desktopChildrenScrollContent: {
// No padding — custom children (e.g. model selector) control their own spacing
},
desktopChildrenBody: {
flex: 1,
minHeight: 0,
},
desktopScrollContentAboveSearch: {
flexGrow: 1,
justifyContent: "flex-end",