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
+26
View File
@@ -0,0 +1,26 @@
/**
* Agent profiles: named bundles of composer settings (provider, model, mode,
* thinking option, feature values, notes) stored host-wide in daemon config.
*
* Four capabilities leave this module — managing the list in settings, reading
* it, pinning it to the model picker, and drawing a profile's glyph. Everything
* else (the form model, the catalog and feature probes, the materialization
* rules, the icon registry, the row and modal chrome) is internal; import from
* `@/agent-profiles`, never a path inside it.
*
* `useAgentProfilePicker` deliberately hands the picker a flat row view model
* and one `applyProfile(id)` callback rather than the profiles themselves: what
* a profile contains, and how it reaches a live agent versus a draft, stays in
* here.
*/
export type { AgentProfile } from "@getpaseo/protocol/messages";
export { useAgentProfiles } from "./internal/use-agent-profiles";
export {
useAgentProfilePicker,
type AgentProfileApplyTarget,
type AgentProfilePicker,
type AgentProfilePickerRow,
type DraftAgentProfileControls,
} from "./internal/use-agent-profile-picker";
export { AgentProfileGlyph } from "./internal/agent-profile-glyph";
export { AgentProfilesSection } from "./settings/agent-profiles-section";
@@ -0,0 +1,128 @@
import { withUnistyles } from "react-native-unistyles";
import {
BookOpen,
Bot,
Boxes,
Brain,
Bug,
Cloud,
Code,
Compass,
Cpu,
Database,
Eye,
Feather,
FileText,
FlaskConical,
GitBranch,
Globe,
Hammer,
Layers,
Microscope,
Package,
Palette,
Pencil,
Rocket,
Search,
Server,
Shield,
Sparkles,
Terminal,
TestTube,
Wrench,
} from "lucide-react-native";
import { identityForeground } from "@/styles/identity-colors";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
AGENT_PROFILE_COLORS,
resolveAgentProfileColor,
resolveAgentProfileIconKey,
type AgentProfileColor,
type AgentProfileIconKey,
} from "./profile-appearance";
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
const ThemedDefaultIcon = withUnistyles(Bot);
/**
* `withUnistyles` has to wrap each icon once at module scope, so the registry
* stores the themed component rather than the raw lucide one.
*/
const THEMED_ICONS: Record<AgentProfileIconKey, typeof ThemedDefaultIcon> = {
code: withUnistyles(Code),
terminal: withUnistyles(Terminal),
bug: withUnistyles(Bug),
wrench: withUnistyles(Wrench),
hammer: withUnistyles(Hammer),
flask: withUnistyles(FlaskConical),
testTube: withUnistyles(TestTube),
microscope: withUnistyles(Microscope),
search: withUnistyles(Search),
eye: withUnistyles(Eye),
palette: withUnistyles(Palette),
feather: withUnistyles(Feather),
pencil: withUnistyles(Pencil),
fileText: withUnistyles(FileText),
book: withUnistyles(BookOpen),
rocket: withUnistyles(Rocket),
package: withUnistyles(Package),
boxes: withUnistyles(Boxes),
server: withUnistyles(Server),
database: withUnistyles(Database),
cpu: withUnistyles(Cpu),
cloud: withUnistyles(Cloud),
globe: withUnistyles(Globe),
gitBranch: withUnistyles(GitBranch),
layers: withUnistyles(Layers),
compass: withUnistyles(Compass),
brain: withUnistyles(Brain),
sparkles: withUnistyles(Sparkles),
shield: withUnistyles(Shield),
};
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* One `uniProps` mapping per colour, built once. Building these inline would
* hand `withUnistyles` a new function every render and defeat its tracking.
*/
const COLOR_MAPPINGS: Record<AgentProfileColor, (theme: Theme) => { color: string }> = (() => {
const byColor = {} as Record<AgentProfileColor, (theme: Theme) => { color: string }>;
for (const color of AGENT_PROFILE_COLORS) {
byColor[color] =
color === "none"
? mutedMapping
: (theme: Theme) => ({ color: identityForeground(color, theme.colorScheme) });
}
return byColor;
})();
export function agentProfileColorMapping(
color: AgentProfileColor,
): (theme: Theme) => { color: string } {
return COLOR_MAPPINGS[color];
}
/**
* How a profile is drawn everywhere it appears — settings list, model picker,
* and the picker grid's own cells. One component so the three can't drift.
*/
export function AgentProfileGlyph({
icon,
color,
size = ICON_SIZE.md,
}: {
icon?: string | undefined;
color?: string | undefined;
size?: number;
}) {
const iconKey = resolveAgentProfileIconKey(icon);
const mapping = COLOR_MAPPINGS[resolveAgentProfileColor(color)];
const Icon = iconKey ? THEMED_ICONS[iconKey] : ThemedDefaultIcon;
return <Icon size={size} uniProps={mapping} />;
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { supportsAgentProfiles } from "./capabilities";
describe("agent profile capabilities", () => {
it("requires storage and live config application support", () => {
expect(supportsAgentProfiles(undefined)).toBe(false);
expect(supportsAgentProfiles({ agentProfiles: true })).toBe(false);
expect(supportsAgentProfiles({ agentConfigApply: true })).toBe(false);
expect(supportsAgentProfiles({ agentProfiles: true, agentConfigApply: true })).toBe(true);
});
});
@@ -0,0 +1,8 @@
interface AgentProfileCapabilities {
agentProfiles?: boolean;
agentConfigApply?: boolean;
}
export function supportsAgentProfiles(features: AgentProfileCapabilities | undefined): boolean {
return features?.agentProfiles === true && features.agentConfigApply === true;
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile";
function profile(overrides: Partial<AgentProfile> = {}): AgentProfile {
return {
id: "ui-work",
name: "UI work",
provider: "claude",
...overrides,
};
}
describe("materializeAgentProfile", () => {
it("resolves every field a full profile names", () => {
expect(
materializeAgentProfile(
profile({
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
}),
),
).toEqual({
provider: "claude",
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
});
});
it("treats omitted and blank fields the same", () => {
expect(materializeAgentProfile(profile({ model: " ", modeId: "" }))).toEqual({
provider: "claude",
modelId: "",
modeId: "",
thinkingOptionId: "",
featureValues: {},
});
});
it("trims stored ids", () => {
expect(materializeAgentProfile(profile({ model: " claude-opus-5 " })).modelId).toBe(
"claude-opus-5",
);
});
});
describe("toAgentConfigApply", () => {
it("sends every value the profile names", () => {
expect(
toAgentConfigApply(
materializeAgentProfile(
profile({
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
}),
),
),
).toEqual({
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
});
});
it("omits what the profile leaves alone rather than clearing it", () => {
expect(toAgentConfigApply(materializeAgentProfile(profile({ modeId: "plan" })))).toEqual({
modeId: "plan",
});
});
it("omits an empty feature map so the agent keeps its own feature values", () => {
expect(
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
).not.toHaveProperty("featureValues");
});
it("never carries a provider, which a running agent cannot change", () => {
expect(
toAgentConfigApply(materializeAgentProfile(profile({ model: "claude-opus-5" }))),
).not.toHaveProperty("provider");
});
});
@@ -0,0 +1,46 @@
import type { AgentConfigApply, AgentProfile } from "@getpaseo/protocol/messages";
/**
* A profile with its blank fields resolved away. Storage keeps every field
* optional and lets the user clear one to an empty string; applying has to know
* the difference between "set this" and "leave whatever is there alone", and
* every consumer would otherwise re-derive the same trim-and-drop rules.
*/
export interface MaterializedAgentProfile {
provider: string;
/** Empty when the profile names no model, meaning "leave the model alone". */
modelId: string;
modeId: string;
thinkingOptionId: string;
featureValues: Record<string, unknown>;
}
function trimmed(value: string | undefined): string {
return value?.trim() ?? "";
}
export function materializeAgentProfile(profile: AgentProfile): MaterializedAgentProfile {
return {
provider: trimmed(profile.provider),
modelId: trimmed(profile.model),
modeId: trimmed(profile.modeId),
thinkingOptionId: trimmed(profile.thinkingOptionId),
featureValues: profile.featureValues ?? {},
};
}
/**
* The payload for a running agent. Omitted fields are left alone by the daemon,
* so a profile that names no mode does not reset the agent's mode. Provider is
* absent because a running agent cannot change the process it is.
*/
export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConfigApply {
return {
...(profile.modelId ? { modelId: profile.modelId } : {}),
...(profile.modeId ? { modeId: profile.modeId } : {}),
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
...(Object.keys(profile.featureValues).length > 0
? { featureValues: profile.featureValues }
: {}),
};
}
@@ -0,0 +1,71 @@
import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors";
/**
* A profile's icon is a key into a fixed registry, not a glyph. The value is
* stored in daemon config and drawn by every client, so it has to survive being
* rendered by an app version that ships a different icon set — an unknown key
* falls back to the default glyph rather than showing the raw string.
*
* Order is what the grid draws, five to a row: related icons sit together so
* scanning the grid is scanning categories, not thirty unrelated shapes.
*/
export const AGENT_PROFILE_ICON_KEYS = [
"code",
"terminal",
"bug",
"wrench",
"hammer",
"flask",
"testTube",
"microscope",
"search",
"eye",
"palette",
"feather",
"pencil",
"fileText",
"book",
"rocket",
"package",
"boxes",
"server",
"database",
"cpu",
"cloud",
"globe",
"gitBranch",
"layers",
"compass",
"brain",
"sparkles",
"shield",
] as const;
export type AgentProfileIconKey = (typeof AGENT_PROFILE_ICON_KEYS)[number];
/** Reuses the host identity palette so a profile and a host name read as one family. */
export type AgentProfileColor = "none" | IdentityColorName;
export const AGENT_PROFILE_COLORS: readonly AgentProfileColor[] = ["none", ...IDENTITY_COLOR_NAMES];
export function isAgentProfileIconKey(value: string | undefined): value is AgentProfileIconKey {
return AGENT_PROFILE_ICON_KEYS.some((key) => key === value);
}
export function isAgentProfileColor(value: string | undefined): value is AgentProfileColor {
return AGENT_PROFILE_COLORS.some((color) => color === value);
}
/** Anything unrecognised means "not chosen": the glyph and colour both default. */
export function resolveAgentProfileIconKey(value: string | undefined): AgentProfileIconKey | null {
return isAgentProfileIconKey(value) ? value : null;
}
export function resolveAgentProfileColor(value: string | undefined): AgentProfileColor {
return isAgentProfileColor(value) ? value : "none";
}
@@ -0,0 +1,431 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import {
buildFeatureRequestKey,
openAgentProfileForm,
type AgentProfileFormModel,
} from "./profile-form-model";
const CLAUDE: ProviderSnapshotEntry = {
provider: "claude",
status: "ready",
enabled: true,
label: "Claude Code",
defaultModeId: "plan",
modes: [
{ id: "plan", label: "Plan" },
{ id: "accept-edits", label: "Accept edits" },
],
models: [
{
provider: "claude",
id: "claude-opus-5",
label: "Opus 5",
isDefault: true,
thinkingOptions: [
{ id: "think", label: "Think" },
{ id: "think-hard", label: "Think hard" },
],
},
{
provider: "claude",
id: "claude-haiku-4-5",
label: "Haiku 4.5",
thinkingOptions: [{ id: "think", label: "Think" }],
},
],
};
const CODEX: ProviderSnapshotEntry = {
provider: "codex",
status: "ready",
enabled: true,
label: "Codex",
modes: [{ id: "read-only", label: "Read only" }],
models: [{ provider: "codex", id: "gpt-5.2-codex", label: "GPT-5.2 Codex", isDefault: true }],
};
const DISABLED: ProviderSnapshotEntry = {
provider: "pi",
status: "ready",
enabled: false,
label: "Pi",
};
const ENTRIES = [CLAUDE, CODEX, DISABLED];
function openWithCatalog(
snapshot: Parameters<typeof openAgentProfileForm>[0],
): AgentProfileFormModel {
const model = openAgentProfileForm(snapshot);
model.applyProviderCatalog(ENTRIES);
return model;
}
function selectClaude(model: AgentProfileFormModel): void {
model.setProvider("claude", { label: "Claude Code" });
}
function optionValues(options: readonly { value: string }[]): string[] {
return options.map((option) => option.value);
}
describe("openAgentProfileForm", () => {
it("starts a create form empty and cannot submit", () => {
const model = openWithCatalog({ mode: "create" });
const state = model.getState();
expect(state.name).toBe("");
expect(state.provider).toBe("");
expect(state.canSubmit).toBe(false);
expect(state.submitValue).toBeNull();
expect(state.disclosure.showModelField).toBe(false);
});
it("lists only enabled providers", () => {
const model = openWithCatalog({ mode: "create" });
expect(optionValues(model.getState().providerOptions)).toEqual(["claude", "codex"]);
});
it("requires a name and a provider before it can submit", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("UI work");
expect(model.getState().canSubmit).toBe(false);
selectClaude(model);
expect(model.getState().canSubmit).toBe(true);
expect(model.getState().submitValue).toMatchObject({ name: "UI work", provider: "claude" });
});
it("omits blank text fields from the submitted value but never a selection", () => {
const model = openWithCatalog({ mode: "create" });
model.setName(" Cheap grunt ");
model.setProvider("codex", { label: "Codex" });
model.setNotes(" ");
// Codex declares no thinking options, so that key is genuinely absent —
// model and mode are seeded, never left for the host to decide.
expect(model.getState().submitValue).toEqual({
name: "Cheap grunt",
provider: "codex",
model: "gpt-5.2-codex",
modeId: "read-only",
});
});
it("seeds edit mode from the stored profile before the catalog lands", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
icon: "palette",
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
notes: "Visual work only.",
},
});
const state = model.getState();
expect(state.name).toBe("UI work");
expect(state.icon).toBe("palette");
// Ids double as labels until the catalog arrives.
expect(state.modelDisplay).toEqual({ label: "claude-opus-5" });
expect(state.thinkingDisplay).toEqual({ label: "think-hard" });
expect(state.catalogResolution).toBe("idle");
});
it("upgrades seeded displays to catalog labels without touching selections", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
},
});
model.applyProviderCatalog(ENTRIES);
const state = model.getState();
expect(state.providerDisplay).toEqual({ label: "Claude Code" });
expect(state.modelDisplay).toEqual({ label: "Opus 5" });
expect(state.modeDisplay).toEqual({ label: "Plan" });
expect(state.thinkingDisplay).toEqual({ label: "Think hard" });
expect(state.modelId).toBe("claude-opus-5");
expect(state.catalogResolution).toBe("complete");
});
it("keeps a stored value the catalog does not know", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "Legacy", provider: "claude", model: "claude-retired-3" },
});
model.applyProviderCatalog(ENTRIES);
expect(model.getState().modelId).toBe("claude-retired-3");
expect(model.getState().modelDisplay).toEqual({ label: "claude-retired-3" });
expect(model.getState().disclosure.showModelField).toBe(true);
});
describe("provider cascade", () => {
it("reseeds every provider-scoped selection when the provider changes", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("Test writer");
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" });
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setFeatureValue("webSearch", true);
model.setProvider("codex", { label: "Codex" });
const state = model.getState();
// Claude's ids mean nothing under Codex, so each field lands on Codex's own default.
expect(state.modelId).toBe("gpt-5.2-codex");
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
expect(state.thinkingOptionId).toBe("");
expect(state.thinkingDisplay).toBeNull();
expect(state.featureValues).toEqual({});
expect(state.features).toEqual([]);
expect(state.name).toBe("Test writer");
});
it("seeds each provider's default mode", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().modeId).toBe("plan");
// Codex declares no defaultModeId, so the first mode it offers stands in.
model.setProvider("codex", { label: "Codex" });
expect(model.getState().modeId).toBe("read-only");
});
it("re-populates model, mode and thinking options from the new provider", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(optionValues(model.getState().modelOptions)).toEqual([
"claude-opus-5",
"claude-haiku-4-5",
]);
expect(optionValues(model.getState().thinkingOptions)).toEqual(["think", "think-hard"]);
model.setProvider("codex", { label: "Codex" });
expect(optionValues(model.getState().modelOptions)).toEqual(["gpt-5.2-codex"]);
expect(optionValues(model.getState().modeOptions)).toEqual(["read-only"]);
// GPT-5.2 Codex declares no thinking options, so the field disappears.
expect(model.getState().thinkingOptions).toEqual([]);
expect(model.getState().disclosure.showThinkingField).toBe(false);
});
it("ignores a no-op provider selection", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
selectClaude(model);
expect(model.getState().modelId).toBe("claude-opus-5");
});
});
describe("model cascade", () => {
it("reseeds a thinking level the new model does not offer", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" });
// Haiku offers only "think", so "think-hard" cannot survive the switch.
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().thinkingOptionId).toBe("think");
expect(model.getState().thinkingDisplay).toEqual({ label: "Think" });
});
it("keeps a thinking level the new model still offers", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think", { label: "Think" });
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().thinkingOptionId).toBe("think");
});
it("seeds the provider's default model and reads thinking options from it", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().modelId).toBe("claude-opus-5");
expect(optionValues(model.getState().thinkingOptions)).toEqual(["think", "think-hard"]);
expect(model.getState().thinkingOptionId).toBe("think");
});
it("offers no way to unset a selection", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
const state = model.getState();
const everyOption = [...state.modelOptions, ...state.modeOptions, ...state.thinkingOptions];
expect(everyOption.length).toBeGreaterThan(0);
expect(optionValues(everyOption)).not.toContain("");
});
});
describe("features", () => {
it("asks for features per provider/model/mode/thinking combination", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think",
});
const firstKey = model.getState().featureRequestKey;
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().featureRequestKey).not.toBe(firstKey);
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-haiku-4-5",
modeId: "plan",
thinkingOptionId: "think",
});
});
it("moves from pending to complete only for the current request", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
const key = model.getState().featureRequestKey ?? "";
expect(model.getState().featureResolution).toBe("pending");
model.applyFeatures("stale-key", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().features).toEqual([]);
model.applyFeatures(key, [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(true);
});
it("reflects a set feature value back into the rendered feature", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setFeatureValue("webSearch", true);
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
expect(model.getState().featureValues).toEqual({ webSearch: true });
});
it("keeps stored feature values while the feature list is still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
featureValues: { webSearch: true },
},
});
model.applyProviderCatalog(ENTRIES);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
});
it("prunes stored values the provider no longer reports", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
featureValues: { webSearch: true, retiredFlag: true },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureValues).toEqual({ webSearch: true });
});
it("resolves an unavailable feature listing without showing the field", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(false);
});
});
it("blocks submission while a save is in flight", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("UI work");
selectClaude(model);
model.setSubmitting(true);
expect(model.getState().canSubmit).toBe(false);
model.setSubmitting(false);
expect(model.getState().canSubmit).toBe(true);
});
it("stops publishing after close", () => {
const model = openWithCatalog({ mode: "create" });
let notifications = 0;
model.subscribe(() => {
notifications += 1;
});
model.setName("a");
expect(notifications).toBe(1);
model.close();
model.setName("b");
expect(notifications).toBe(1);
expect(model.getState().name).toBe("a");
});
});
describe("buildFeatureRequestKey", () => {
it("is null without a provider", () => {
expect(buildFeatureRequestKey(null)).toBeNull();
});
it("separates the fields so a value cannot bleed across positions", () => {
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildFeatureRequestKey({ provider: "claude", model: "a" }),
);
});
});
@@ -0,0 +1,594 @@
import type {
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentSelectOption,
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
/**
* The persisted profile minus the id; the list owns identity.
*
* `Omit` does not work here. `AgentProfileSchema` is `.passthrough()`, so
* `AgentProfile` carries a `[key: string]: unknown` index signature, and
* `Exclude<keyof AgentProfile, "id">` stays `string | number` — the named keys
* come back as `unknown` and `name`/`provider` stop being required. Mapping
* `keyof` with an `as` filter drops the index signature and the id together.
*/
export type AgentProfileValue = {
[K in keyof AgentProfile as string extends K
? never
: K extends "id"
? never
: K]: AgentProfile[K];
};
export interface AgentProfileFormDisplay {
label: string;
description?: string;
}
export interface AgentProfileFormOption {
id: string;
value: string;
label: string;
description?: string;
testID: string;
}
export interface AgentProfileFormSnapshot {
mode: "create" | "edit";
profile?: AgentProfile;
}
/**
* The inputs a feature listing needs. Features are provider-scoped and change
* with model/mode/thinking, so every one of those is part of the request.
*/
export interface AgentProfileFeatureRequest {
provider: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
}
export type AgentProfileResolutionStatus = "idle" | "pending" | "complete";
export interface AgentProfileFormDisclosure {
showModelField: boolean;
showModeField: boolean;
showThinkingField: boolean;
showFeaturesField: boolean;
}
export interface AgentProfileFormState {
mode: "create" | "edit";
name: string;
icon: string;
color: string;
notes: string;
provider: string;
modelId: string;
modeId: string;
thinkingOptionId: string;
featureValues: Record<string, unknown>;
providerOptions: AgentProfileFormOption[];
modelOptions: AgentProfileFormOption[];
modeOptions: AgentProfileFormOption[];
thinkingOptions: AgentProfileFormOption[];
features: AgentFeature[];
providerDisplay: AgentProfileFormDisplay | null;
modelDisplay: AgentProfileFormDisplay | null;
modeDisplay: AgentProfileFormDisplay | null;
thinkingDisplay: AgentProfileFormDisplay | null;
catalogResolution: AgentProfileResolutionStatus;
featureResolution: AgentProfileResolutionStatus;
featureRequest: AgentProfileFeatureRequest | null;
featureRequestKey: string | null;
disclosure: AgentProfileFormDisclosure;
isSubmitting: boolean;
submitError: string | null;
canSubmit: boolean;
submitValue: AgentProfileValue | null;
}
export interface AgentProfileFormModel {
getState: () => AgentProfileFormState;
subscribe: (listener: () => void) => () => void;
close: () => void;
/** Late input: the host-scoped provider catalog. Never touches selections. */
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
/** Late input: the feature list for one request. Stale keys are ignored. */
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
/** Resolve a request that produced no usable features (provider error). */
applyFeaturesUnavailable: (requestKey: string) => void;
setName: (value: string) => void;
setAppearance: (value: { icon: string; color: string }) => void;
setNotes: (value: string) => void;
setProvider: (providerId: string, display: AgentProfileFormDisplay) => void;
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
setFeatureValue: (featureId: string, value: unknown) => void;
setSubmitting: (value: boolean) => void;
setSubmitError: (value: string | null) => void;
}
function findEntry(
entries: readonly ProviderSnapshotEntry[],
provider: string,
): ProviderSnapshotEntry | null {
if (!provider) {
return null;
}
return entries.find((entry) => entry.provider === provider) ?? null;
}
function resolveModels(
entries: readonly ProviderSnapshotEntry[],
provider: string,
): AgentModelDefinition[] {
return filterSelectableModels(findEntry(entries, provider)?.models ?? null) ?? [];
}
function resolveModes(entries: readonly ProviderSnapshotEntry[], provider: string): AgentMode[] {
return findEntry(entries, provider)?.modes ?? [];
}
/**
* Thinking options hang off a model, so an unset model still has to resolve to
* the model the daemon would pick — otherwise the field disappears whenever the
* user leaves the model on "provider default".
*/
function resolveEffectiveModel(
models: readonly AgentModelDefinition[],
modelId: string,
): AgentModelDefinition | null {
const trimmed = modelId.trim();
if (trimmed) {
return models.find((model) => model.id === trimmed) ?? null;
}
return models.find((model) => model.isDefault) ?? models[0] ?? null;
}
function resolveThinkingOptions(
entries: readonly ProviderSnapshotEntry[],
provider: string,
modelId: string,
): AgentSelectOption[] {
return resolveEffectiveModel(resolveModels(entries, provider), modelId)?.thinkingOptions ?? [];
}
/** Every real option is selected by its own id; only the unset row differs. */
function formOption(input: {
id: string;
label: string;
description: string | undefined;
testID: string;
}): AgentProfileFormOption {
return {
id: input.id,
value: input.id,
label: input.label,
...(input.description ? { description: input.description } : {}),
testID: input.testID,
};
}
function buildProviderOptions(entries: readonly ProviderSnapshotEntry[]): AgentProfileFormOption[] {
return entries
.filter((entry) => entry.enabled)
.map((entry) =>
formOption({
id: entry.provider,
label: entry.label ?? entry.provider,
description: entry.description,
testID: `agent-profile-provider-option-${entry.provider}`,
}),
);
}
function buildModelOptions(models: readonly AgentModelDefinition[]): AgentProfileFormOption[] {
return models.map((model) =>
formOption({
id: model.id,
label: model.label,
// Models are labelled by family, so the id is the only thing that
// distinguishes two entries with the same label.
description: model.description ?? model.id,
testID: `agent-profile-model-option-${model.id}`,
}),
);
}
function buildModeOptions(modes: readonly AgentMode[]): AgentProfileFormOption[] {
return modes.map((mode) =>
formOption({
id: mode.id,
label: formatAgentModeLabel(mode),
description: mode.description,
testID: `agent-profile-mode-option-${mode.id}`,
}),
);
}
function buildThinkingOptions(options: readonly AgentSelectOption[]): AgentProfileFormOption[] {
return options.map((option) =>
formOption({
id: option.id,
label: formatThinkingOptionLabel(option),
description: option.description,
testID: `agent-profile-thinking-option-${option.id}`,
}),
);
}
/**
* Every selection resolves to a concrete id.
*
* Each list used to carry a synthetic "Provider default" row. It read as a value
* you could choose but stored an absent one, so a profile that looked fully
* specified would apply whatever the host preferred at the time — and two hosts
* would materialize the same profile differently. The schedule form never
* offered that row; this form now matches it and seeds the catalog's own default
* instead, so what the form shows is what the profile stores.
*/
function defaultModelId(models: readonly AgentModelDefinition[]): string {
return models.find((model) => model.isDefault)?.id ?? models[0]?.id ?? "";
}
function defaultModeId(entry: ProviderSnapshotEntry | null, modes: readonly AgentMode[]): string {
return entry?.defaultModeId ?? modes[0]?.id ?? "";
}
function defaultThinkingOptionId(model: AgentModelDefinition | null): string {
const options = model?.thinkingOptions ?? [];
return (
model?.defaultThinkingOptionId ??
options.find((option) => option.isDefault)?.id ??
options[0]?.id ??
""
);
}
function seedSelections(
next: AgentProfileFormState,
input: {
entry: ProviderSnapshotEntry | null;
models: readonly AgentModelDefinition[];
modes: readonly AgentMode[];
},
): AgentProfileFormState {
const modelId = next.modelId || defaultModelId(input.models);
const modeId = next.modeId || defaultModeId(input.entry, input.modes);
const thinkingOptionId =
next.thinkingOptionId || defaultThinkingOptionId(resolveEffectiveModel(input.models, modelId));
if (
modelId === next.modelId &&
modeId === next.modeId &&
thinkingOptionId === next.thinkingOptionId
) {
return next;
}
return { ...next, modelId, modeId, thinkingOptionId };
}
/**
* Selected labels upgrade from the stored id to the catalog label once the
* catalog lands, and fall back to the id when the catalog does not know the
* value. They never blank out — a profile pointing at a retired model still
* reads as that model.
*/
function refineDisplay(
current: AgentProfileFormDisplay | null,
selectedId: string,
resolvedLabel: string | null,
): AgentProfileFormDisplay | null {
if (!selectedId) {
return null;
}
if (resolvedLabel) {
return { label: resolvedLabel };
}
return current ?? { label: selectedId };
}
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
if (!state.provider) {
return null;
}
return {
provider: state.provider,
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
};
}
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
if (!request) {
return null;
}
return [
request.provider,
request.model ?? "",
request.modeId ?? "",
request.thinkingOptionId ?? "",
].join("|");
}
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
const name = state.name.trim();
const notes = state.notes.trim();
if (!name || !state.provider) {
return null;
}
return {
name,
...(state.icon ? { icon: state.icon } : {}),
...(state.color ? { color: state.color } : {}),
provider: state.provider,
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
...(notes ? { notes } : {}),
};
}
function resolveFeatureStatus(
featureRequestKey: string | null,
featuresAreCurrent: boolean,
): AgentProfileResolutionStatus {
if (featureRequestKey === null) {
return "idle";
}
return featuresAreCurrent ? "complete" : "pending";
}
const BLANK_PROFILE: AgentProfileValue = { name: "", provider: "" };
/** A stored id doubles as its own label until the catalog can upgrade it. */
function seedDisplay(value: string | undefined): AgentProfileFormDisplay | null {
return value ? { label: value } : null;
}
/**
* Edit mode seeds every value AND display from the stored profile alone: the
* catalog is not loaded yet, so `applyProviderCatalog` does the upgrading later.
*/
function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileFormState {
const profile = snapshot.profile ?? BLANK_PROFILE;
return {
mode: snapshot.mode,
name: profile.name,
icon: profile.icon ?? "",
color: profile.color ?? "",
notes: profile.notes ?? "",
provider: profile.provider,
modelId: profile.model ?? "",
modeId: profile.modeId ?? "",
thinkingOptionId: profile.thinkingOptionId ?? "",
featureValues: { ...profile.featureValues },
providerOptions: [],
modelOptions: [],
modeOptions: [],
thinkingOptions: [],
features: [],
providerDisplay: seedDisplay(profile.provider),
modelDisplay: seedDisplay(profile.model),
modeDisplay: seedDisplay(profile.modeId),
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
catalogResolution: "idle",
featureResolution: "idle",
featureRequest: null,
featureRequestKey: null,
disclosure: {
showModelField: false,
showModeField: false,
showThinkingField: false,
showFeaturesField: false,
},
isSubmitting: false,
submitError: null,
canSubmit: false,
submitValue: null,
};
}
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
let entries: readonly ProviderSnapshotEntry[] = [];
let catalogResolution: AgentProfileResolutionStatus = "idle";
let resolvedFeatureKey: string | null = null;
let resolvedFeatures: AgentFeature[] = [];
let listeners = new Set<() => void>();
let closed = false;
function derive(incoming: AgentProfileFormState): AgentProfileFormState {
const models = resolveModels(entries, incoming.provider);
const modes = resolveModes(entries, incoming.provider);
const next = seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
});
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
const featureRequest = buildFeatureRequest(next);
const featureRequestKey = buildFeatureRequestKey(featureRequest);
const featuresAreCurrent =
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
const features = featuresAreCurrent
? applyFeatureValues(resolvedFeatures, next.featureValues)
: [];
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
const withOptions: AgentProfileFormState = {
...next,
providerOptions: buildProviderOptions(entries),
modelOptions: buildModelOptions(models),
modeOptions: buildModeOptions(modes),
thinkingOptions: buildThinkingOptions(thinking),
features,
providerDisplay: refineDisplay(
next.providerDisplay,
next.provider,
findEntry(entries, next.provider)?.label ?? null,
),
modelDisplay: refineDisplay(
next.modelDisplay,
next.modelId,
models.find((model) => model.id === next.modelId)?.label ?? null,
),
modeDisplay: refineDisplay(
next.modeDisplay,
next.modeId,
(() => {
const mode = modes.find((entry) => entry.id === next.modeId);
return mode ? formatAgentModeLabel(mode) : null;
})(),
),
thinkingDisplay: refineDisplay(
next.thinkingDisplay,
next.thinkingOptionId,
(() => {
const option = thinking.find((entry) => entry.id === next.thinkingOptionId);
return option ? formatThinkingOptionLabel(option) : null;
})(),
),
catalogResolution,
featureResolution,
featureRequest,
featureRequestKey,
};
// A field appears once the catalog can populate it, and stays visible while
// a stored value needs a home even if this host cannot resolve it.
const hasProvider = Boolean(withOptions.provider);
const disclosure: AgentProfileFormDisclosure = {
showModelField: hasProvider && (models.length > 0 || Boolean(withOptions.modelId)),
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
showThinkingField:
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
showFeaturesField: hasProvider && features.length > 0,
};
const canSubmit =
withOptions.name.trim().length > 0 &&
withOptions.provider.length > 0 &&
!withOptions.isSubmitting;
const resolved: AgentProfileFormState = { ...withOptions, disclosure, canSubmit };
return { ...resolved, submitValue: canSubmit ? buildSubmitValue(resolved) : null };
}
let state: AgentProfileFormState = derive(buildInitialState(snapshot));
function publish(mutate: (current: AgentProfileFormState) => AgentProfileFormState): void {
if (closed) {
return;
}
state = derive(mutate(state));
for (const listener of listeners) {
listener();
}
}
return {
getState: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
close: () => {
closed = true;
listeners = new Set();
},
applyProviderCatalog: (nextEntries) => {
entries = [...nextEntries];
catalogResolution = "complete";
publish((current) => current);
},
applyFeatures: (requestKey, features) => {
if (requestKey !== state.featureRequestKey) {
return;
}
resolvedFeatureKey = requestKey;
resolvedFeatures = [...features];
publish((current) => ({
...current,
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
}));
},
applyFeaturesUnavailable: (requestKey) => {
if (requestKey !== state.featureRequestKey) {
return;
}
resolvedFeatureKey = requestKey;
resolvedFeatures = [];
publish((current) => current);
},
setName: (value) => publish((current) => ({ ...current, name: value })),
setAppearance: (value) =>
publish((current) => ({ ...current, icon: value.icon, color: value.color })),
setNotes: (value) => publish((current) => ({ ...current, notes: value })),
setProvider: (providerId, display) =>
publish((current) => {
if (current.provider === providerId) {
return current;
}
// Everything below the provider is provider-scoped: a model id, mode id,
// thinking id or feature id from the old provider means nothing here.
// Clearing them is enough — `derive` seeds the new provider's defaults.
return {
...current,
provider: providerId,
providerDisplay: display,
modelId: "",
modelDisplay: null,
modeId: "",
modeDisplay: null,
thinkingOptionId: "",
thinkingDisplay: null,
featureValues: {},
};
}),
setModel: (modelId, display) =>
publish((current) => {
if (current.modelId === modelId) {
return current;
}
// Thinking options are a property of the model, so a level the new model
// does not offer has to go.
const nextThinking = resolveThinkingOptions(entries, current.provider, modelId);
const keepsThinking =
current.thinkingOptionId.length > 0 &&
nextThinking.some((option) => option.id === current.thinkingOptionId);
return {
...current,
modelId,
modelDisplay: display,
thinkingOptionId: keepsThinking ? current.thinkingOptionId : "",
thinkingDisplay: keepsThinking ? current.thinkingDisplay : null,
};
}),
setMode: (modeId, display) =>
publish((current) => ({ ...current, modeId, modeDisplay: display })),
setThinking: (thinkingOptionId, display) =>
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
setFeatureValue: (featureId, value) =>
publish((current) => ({
...current,
featureValues: { ...current.featureValues, [featureId]: value },
})),
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
};
}
@@ -0,0 +1,56 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
export interface AgentProfileTag {
id: string;
label: string;
}
function findEntry(
entries: readonly ProviderSnapshotEntry[],
provider: string,
): ProviderSnapshotEntry | null {
return entries.find((entry) => entry.provider === provider) ?? null;
}
/**
* The one-line résumé of a profile in the settings list. Every label falls back
* to the stored id: a row must stay readable on a host whose provider catalog
* is offline, or that no longer ships the model the profile names.
*/
export function buildAgentProfileTags(input: {
profile: AgentProfile;
entries: readonly ProviderSnapshotEntry[] | undefined;
formatFeatureCount: (count: number) => string;
}): AgentProfileTag[] {
const entries = input.entries ?? [];
const entry = findEntry(entries, input.profile.provider);
const tags: AgentProfileTag[] = [
{ id: "provider", label: entry?.label ?? input.profile.provider },
];
const modelId = input.profile.model?.trim();
if (modelId) {
const model = entry?.models?.find((candidate) => candidate.id === modelId);
tags.push({ id: "model", label: model?.label ?? modelId });
}
const modeId = input.profile.modeId?.trim();
if (modeId) {
const mode = entry?.modes?.find((candidate) => candidate.id === modeId);
tags.push({ id: "mode", label: mode ? formatAgentModeLabel(mode) : modeId });
}
const thinkingOptionId = input.profile.thinkingOptionId?.trim();
if (thinkingOptionId) {
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
}
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
if (featureCount > 0) {
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
}
return tags;
}
@@ -0,0 +1,180 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useSessionStore } from "@/stores/session-store";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile";
import { buildAgentProfileTags } from "./profile-summary";
import { useAgentProfiles } from "./use-agent-profiles";
/** The draft composer's own setters. Applying a profile drives them like a user would. */
export interface DraftAgentProfileControls {
selectProvider: (provider: AgentProvider) => void;
selectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
selectMode: (modeId: string) => void;
selectThinkingOption: (thinkingOptionId: string) => void;
setFeature?: (featureId: string, value: unknown) => void;
}
export type AgentProfileApplyTarget =
| { kind: "agent"; agentId: string }
| { kind: "draft"; controls: DraftAgentProfileControls };
/** Everything the model picker renders for one profile. It never sees the profile itself. */
export interface AgentProfilePickerRow {
id: string;
provider: string;
/** Icon registry key and identity colour; either may be empty for the default glyph. */
icon: string;
color: string;
name: string;
/** "Claude Code · Opus 5 · Plan · Think hard" */
summary: string;
}
export interface AgentProfilePicker {
rows: AgentProfilePickerRow[];
applyProfile: (profileId: string) => void;
}
export interface UseAgentProfilePickerInput {
serverId: string | null;
/**
* Providers this composer can actually run; pass a stable reference. A profile
* naming anything else is hidden rather than shown as a row that cannot do
* what it says — a live agent is one provider's process and cannot switch, and
* the draft form ignores a provider the host does not offer.
*/
availableProviders: readonly string[];
target: AgentProfileApplyTarget;
}
/**
* The agent-profiles section of the model picker: the rows to draw, and what
* pressing one does. A supported host returns an empty row list when there is
* nothing applicable so the picker can retain its settings shortcut without
* pinning an empty section. Unsupported or still-loading hosts return `null`.
*/
export function useAgentProfilePicker(
input: UseAgentProfilePickerInput,
): AgentProfilePicker | null {
const { serverId, availableProviders, target } = input;
const { t } = useTranslation();
const { profiles, isSupported } = useAgentProfiles(serverId);
// Profiles are host config, so their labels read from the host-wide catalog
// rather than a workspace's. That is also the key the settings section uses,
// so every composer on a host shares one query instead of adding its own.
const { entries } = useProvidersSnapshot(serverId, { cwd: null });
const { updatePreferences } = useFormPreferences();
const client = useSessionStore((state) => state.sessions[serverId ?? ""]?.client ?? null);
const toast = useToast();
const applicableProfiles = useMemo(() => {
if (!isSupported || !profiles) {
return [];
}
const available = new Set(availableProviders);
return profiles.filter((profile) => available.has(profile.provider));
}, [availableProviders, isSupported, profiles]);
const formatFeatureCount = useCallback(
(count: number) =>
count === 1
? t("settings.host.agentProfiles.featureCountOne", { count })
: t("settings.host.agentProfiles.featureCount", { count }),
[t],
);
const rows = useMemo<AgentProfilePickerRow[]>(
() =>
applicableProfiles.map((profile) => ({
id: profile.id,
provider: profile.provider,
icon: profile.icon ?? "",
color: profile.color ?? "",
name: profile.name,
summary: buildAgentProfileTags({ profile, entries, formatFeatureCount })
.map((tag) => tag.label)
.join(" · "),
})),
[applicableProfiles, entries, formatFeatureCount],
);
const persistSelection = useCallback(
(profile: AgentProfile) => {
const resolved = materializeAgentProfile(profile);
void updatePreferences((current) =>
mergeCreateAgentSelectionPreferences({
preferences: current,
provider: resolved.provider,
modelId: resolved.modelId,
modeId: resolved.modeId,
thinkingOptionId: resolved.thinkingOptionId,
...(Object.keys(resolved.featureValues).length > 0
? { featureValues: resolved.featureValues }
: {}),
}),
).catch((error) => {
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
});
},
[updatePreferences],
);
const applyProfile = useCallback(
(profileId: string) => {
const profile = applicableProfiles.find((entry) => entry.id === profileId);
if (!profile) {
return;
}
const resolved = materializeAgentProfile(profile);
// Persist on both paths, before driving the controls. The draft form drops
// its local feature values when the provider changes, so preferences are
// the only channel that survives a profile that switches provider.
persistSelection(profile);
if (target.kind === "draft") {
const { controls } = target;
if (resolved.modelId) {
controls.selectProviderAndModel(resolved.provider, resolved.modelId);
} else {
controls.selectProvider(resolved.provider);
}
if (resolved.modeId) {
controls.selectMode(resolved.modeId);
}
if (resolved.thinkingOptionId) {
controls.selectThinkingOption(resolved.thinkingOptionId);
}
for (const [featureId, value] of Object.entries(resolved.featureValues)) {
controls.setFeature?.(featureId, value);
}
return;
}
if (!client) {
return;
}
void client
.applyAgentConfig(target.agentId, toAgentConfigApply(resolved))
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.warn("[useAgentProfilePicker] applyAgentConfig failed", error);
toast.error(toErrorMessage(error));
});
},
[applicableProfiles, client, persistSelection, target, toast],
);
return useMemo(
() => (isSupported && profiles !== null ? { rows, applyProfile } : null),
[applyProfile, isSupported, profiles, rows],
);
}
@@ -0,0 +1,34 @@
import { useCallback } from "react";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useSessionStore } from "@/stores/session-store";
import { supportsAgentProfiles } from "./capabilities";
export interface UseAgentProfilesResult {
/** `null` until the daemon config has arrived. */
profiles: AgentProfile[] | null;
/** False on daemons that predate agent profiles, or while disconnected. */
isSupported: boolean;
/** Writes the whole list; there is no per-profile RPC. */
saveProfiles: (next: AgentProfile[]) => Promise<void>;
}
export function useAgentProfiles(serverId: string | null): UseAgentProfilesResult {
const { config, patchConfig } = useDaemonConfig(serverId);
const isSupported = useSessionStore((state) => {
return supportsAgentProfiles(state.sessions[serverId ?? ""]?.serverInfo?.features);
});
const saveProfiles = useCallback(
async (next: AgentProfile[]) => {
await patchConfig({ agentProfiles: next });
},
[patchConfig],
);
return {
profiles: config ? (config.agentProfiles ?? []) : null,
isSupported,
saveProfiles,
};
}
@@ -0,0 +1,78 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFetchQuery } from "@/data/query";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { AgentProfileFormModel, AgentProfileFormState } from "./profile-form-model";
/**
* Agent profiles are host-wide, so the catalog is probed in the daemon's home
* directory (`cwd: null` for the snapshot, `~` for the feature listing) rather
* than in a workspace. A profile that resolved against one project's directory
* would name models the next project cannot launch.
*/
const HOME_SCOPE_CWD = "~";
export function useAgentProfileFormCatalog(input: {
serverId: string;
model: AgentProfileFormModel;
}): void {
const { entries } = useProvidersSnapshot(input.serverId, { cwd: null });
useEffect(() => {
if (!entries) {
return;
}
input.model.applyProviderCatalog(entries);
}, [entries, input.model]);
}
export function useAgentProfileFormFeatures(input: {
serverId: string;
model: AgentProfileFormModel;
state: AgentProfileFormState;
}): void {
const { t } = useTranslation();
const client = useHostRuntimeClient(input.serverId);
const isConnected = useHostRuntimeIsConnected(input.serverId);
const request = input.state.featureRequest;
const requestKey = input.state.featureRequestKey;
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
// page's data while the next request is in flight, which would hand the
// features of one provider/model combination to the key of another.
const featuresQuery = useFetchQuery({
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(client && isConnected && request),
retry: false,
queryFn: async () => {
if (!client || !request) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
},
});
const { data, isError } = featuresQuery;
const { model } = input;
useEffect(() => {
if (!requestKey || !data) {
return;
}
model.applyFeatures(requestKey, data);
}, [data, model, requestKey]);
useEffect(() => {
if (!requestKey || !isError) {
return;
}
model.applyFeaturesUnavailable(requestKey);
}, [isError, model, requestKey]);
}
@@ -0,0 +1,29 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import {
openAgentProfileForm,
type AgentProfileFormModel,
type AgentProfileFormSnapshot,
type AgentProfileFormState,
} from "./profile-form-model";
/**
* One model per mount. The modal keys its body on mode + profile id, so create
* and edit never share an instance and reopening always starts from the record.
*/
export function useAgentProfileFormModel(
snapshot: AgentProfileFormSnapshot,
): AgentProfileFormModel {
const [model] = useState(() => openAgentProfileForm(snapshot));
useEffect(() => {
return () => {
model.close();
};
}, [model]);
return model;
}
export function useAgentProfileFormState(model: AgentProfileFormModel): AgentProfileFormState {
return useSyncExternalStore(model.subscribe, model.getState, model.getState);
}
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { LegacyFavoriteProfileMigration } from ".";
class MemoryStorage {
private readonly values = new Map<string, string>();
constructor(initial: Record<string, unknown> = {}) {
for (const [key, value] of Object.entries(initial)) {
this.values.set(key, JSON.stringify(value));
}
}
async getItem(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async setItem(key: string, value: string): Promise<void> {
this.values.set(key, value);
}
}
class FakeProfileHost {
readonly patches: AgentProfile[][] = [];
config: { agentProfiles?: AgentProfile[] };
shouldFailPatch = false;
shouldFailCatalog = false;
catalogLoadingReads = 0;
constructor(
profiles: AgentProfile[] = [],
readonly supported = true,
) {
this.config = { agentProfiles: profiles };
}
getLastServerInfoMessage() {
return {
features: this.supported
? { agentProfiles: true, agentConfigApply: true }
: { agentProfiles: false, agentConfigApply: false },
};
}
async getDaemonConfig() {
return { requestId: "config", config: this.config };
}
async patchDaemonConfig(patch: { agentProfiles?: AgentProfile[] }) {
if (this.shouldFailPatch) {
throw new Error("host write failed");
}
const profiles = patch.agentProfiles ?? [];
this.patches.push(profiles);
this.config = { ...this.config, agentProfiles: profiles };
return { requestId: "patch", config: this.config };
}
async getProvidersSnapshot() {
if (this.shouldFailCatalog) {
throw new Error("catalog unavailable");
}
if (this.catalogLoadingReads > 0) {
this.catalogLoadingReads -= 1;
return {
entries: [
{
provider: "claude" as const,
status: "loading" as const,
enabled: true,
},
],
generatedAt: new Date(0).toISOString(),
requestId: "providers-loading",
};
}
return {
entries: [
{
provider: "claude" as const,
status: "ready" as const,
enabled: true,
label: "Claude Code",
models: [
{ provider: "claude" as const, id: "opus", label: "Opus" },
{ provider: "claude" as const, id: "sonnet", label: "Sonnet" },
],
},
],
generatedAt: new Date(0).toISOString(),
requestId: "providers",
};
}
}
const PREFERENCES_KEY = "@paseo:create-agent-preferences";
function migrationStorage() {
return new MemoryStorage({
[PREFERENCES_KEY]: {
favoriteModels: [
{ provider: "claude", modelId: "opus" },
{ provider: "claude", modelId: "sonnet" },
{ provider: "claude", modelId: "opus" },
],
},
});
}
describe("legacy favourite profile migration", () => {
it("appends provider-and-model-only profiles with useful catalog names", async () => {
const existing: AgentProfile = {
id: "hand-written",
name: "My Opus",
provider: "claude",
model: "opus",
modeId: "plan",
};
const host = new FakeProfileHost([existing]);
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
await migration.migrateHost("host-a", host);
expect(host.patches).toEqual([
[
existing,
{
id: "legacy_favorite:claude:sonnet",
name: "Sonnet",
provider: "claude",
model: "sonnet",
},
],
]);
});
it("runs once per host, even if the imported profiles are later deleted", async () => {
const storage = migrationStorage();
const migration = new LegacyFavoriteProfileMigration(storage);
const firstHost = new FakeProfileHost();
await migration.migrateHost("host-a", firstHost);
firstHost.config = { agentProfiles: [] };
await migration.migrateHost("host-a", firstHost);
const secondHost = new FakeProfileHost();
await migration.migrateHost("host-b", secondHost);
expect(firstHost.patches).toHaveLength(1);
expect(secondHost.patches).toHaveLength(1);
});
it("waits for profile capabilities without consuming the host migration", async () => {
const storage = migrationStorage();
const migration = new LegacyFavoriteProfileMigration(storage);
const oldHost = new FakeProfileHost([], false);
await migration.migrateHost("host-a", oldHost);
const upgradedHost = new FakeProfileHost();
await migration.migrateHost("host-a", upgradedHost);
expect(oldHost.patches).toHaveLength(0);
expect(upgradedHost.patches).toHaveLength(1);
});
it("retries after a failed host write", async () => {
const host = new FakeProfileHost();
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
host.shouldFailPatch = true;
await expect(migration.migrateHost("host-a", host)).rejects.toThrow("host write failed");
host.shouldFailPatch = false;
await migration.migrateHost("host-a", host);
expect(host.patches).toHaveLength(1);
});
it("retries catalog resolution instead of permanently storing raw ids", async () => {
const host = new FakeProfileHost();
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
host.shouldFailCatalog = true;
await expect(migration.migrateHost("host-a", host)).rejects.toThrow("catalog unavailable");
host.shouldFailCatalog = false;
await migration.migrateHost("host-a", host);
expect(host.patches[0]?.map((profile) => profile.name)).toEqual(["Opus", "Sonnet"]);
});
it("waits for relevant provider models to finish loading", async () => {
const host = new FakeProfileHost();
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
host.catalogLoadingReads = 1;
await migration.migrateHost("host-a", host);
expect(host.patches[0]?.map((profile) => profile.name)).toEqual(["Opus", "Sonnet"]);
});
});
@@ -0,0 +1,231 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { z } from "zod";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
const PREFERENCES_KEY = "@paseo:create-agent-preferences";
const COMPLETION_KEY_PREFIX = "@paseo:legacy-favorites-to-agent-profiles:v1:";
const CATALOG_LOADING_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000, 4_000] as const;
const legacyPreferencesSchema = z.object({
favoriteModels: z
.array(
z.object({
provider: z.string().trim().min(1),
modelId: z.string().trim().min(1),
}),
)
.optional(),
});
type LegacyFavorite = NonNullable<
z.infer<typeof legacyPreferencesSchema>["favoriteModels"]
>[number];
export interface LegacyFavoriteMigrationStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
export interface LegacyFavoriteMigrationHost {
getLastServerInfoMessage(): {
features?: { agentProfiles?: boolean; agentConfigApply?: boolean };
} | null;
getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
patchDaemonConfig(patch: {
agentProfiles: AgentProfile[];
}): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
getProvidersSnapshot(): Promise<{ entries: ProviderSnapshotEntry[] }>;
}
function supportsMigration(host: LegacyFavoriteMigrationHost): boolean {
const features = host.getLastServerInfoMessage()?.features;
return features?.agentProfiles === true && features.agentConfigApply === true;
}
function completionKey(serverId: string): string {
return `${COMPLETION_KEY_PREFIX}${serverId}`;
}
function parseStoredPreferences(raw: string | null): LegacyFavorite[] {
if (!raw) {
return [];
}
try {
const result = legacyPreferencesSchema.safeParse(JSON.parse(raw));
return result.success ? (result.data.favoriteModels ?? []) : [];
} catch {
return [];
}
}
function favoriteKey(favorite: Pick<LegacyFavorite, "provider" | "modelId">): string {
return `${favorite.provider}\u0000${favorite.modelId}`;
}
function migratedProfileId(favorite: LegacyFavorite): string {
return `legacy_favorite:${encodeURIComponent(favorite.provider)}:${encodeURIComponent(favorite.modelId)}`;
}
function deduplicateFavorites(favorites: readonly LegacyFavorite[]): LegacyFavorite[] {
const seen = new Set<string>();
return favorites.filter((favorite) => {
const key = favoriteKey(favorite);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function findCatalogLabels(
favorite: LegacyFavorite,
entries: readonly ProviderSnapshotEntry[],
): { model: string; provider: string } {
const provider = entries.find((entry) => entry.provider === favorite.provider);
const model = provider?.models?.find((entry) => entry.id === favorite.modelId);
return {
model: model?.label?.trim() || favorite.modelId,
provider: provider?.label?.trim() || favorite.provider,
};
}
function uniqueProfileName(input: {
favorite: LegacyFavorite;
entries: readonly ProviderSnapshotEntry[];
usedNames: Set<string>;
}): string {
const labels = findCatalogLabels(input.favorite, input.entries);
const candidates = [
labels.model,
`${labels.provider} · ${labels.model}`,
`${labels.provider} · ${labels.model} (${input.favorite.modelId})`,
];
for (const candidate of candidates) {
if (!input.usedNames.has(candidate)) {
input.usedNames.add(candidate);
return candidate;
}
}
let suffix = 2;
while (input.usedNames.has(`${candidates[2]} ${suffix}`)) {
suffix += 1;
}
const name = `${candidates[2]} ${suffix}`;
input.usedNames.add(name);
return name;
}
function buildMigratedProfiles(input: {
favorites: readonly LegacyFavorite[];
existing: readonly AgentProfile[];
entries: readonly ProviderSnapshotEntry[];
}): AgentProfile[] {
const existingSelections = new Set(
input.existing.map((profile) =>
favoriteKey({ provider: profile.provider, modelId: profile.model?.trim() ?? "" }),
),
);
const existingIds = new Set(input.existing.map((profile) => profile.id));
const usedNames = new Set(input.existing.map((profile) => profile.name));
return deduplicateFavorites(input.favorites).flatMap((favorite) => {
const id = migratedProfileId(favorite);
if (existingSelections.has(favoriteKey(favorite)) || existingIds.has(id)) {
return [];
}
return [
{
id,
name: uniqueProfileName({ favorite, entries: input.entries, usedNames }),
provider: favorite.provider,
model: favorite.modelId,
},
];
});
}
function catalogIsLoading(
entries: readonly ProviderSnapshotEntry[],
favorites: readonly LegacyFavorite[],
): boolean {
const providers = new Set(favorites.map((favorite) => favorite.provider));
return entries.some((entry) => providers.has(entry.provider) && entry.status === "loading");
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function readProviderCatalog(
host: LegacyFavoriteMigrationHost,
favorites: readonly LegacyFavorite[],
): Promise<ProviderSnapshotEntry[]> {
for (const retryDelay of CATALOG_LOADING_RETRY_DELAYS_MS) {
const entries = (await host.getProvidersSnapshot()).entries;
if (!catalogIsLoading(entries, favorites)) {
return entries;
}
await delay(retryDelay);
}
const entries = (await host.getProvidersSnapshot()).entries;
if (catalogIsLoading(entries, favorites)) {
throw new Error("Provider catalog remained loading during legacy favourite migration");
}
return entries;
}
/**
* One-time bridge from device-local model favourites to host-wide profiles.
* The module owns every migration invariant; callers only announce a connected
* host and may safely call again after reconnects or React remounts.
*/
export class LegacyFavoriteProfileMigration {
private readonly inFlight = new Map<string, Promise<void>>();
constructor(private readonly storage: LegacyFavoriteMigrationStorage) {}
migrateHost(serverId: string, host: LegacyFavoriteMigrationHost): Promise<void> {
const active = this.inFlight.get(serverId);
if (active) {
return active;
}
const migration = this.run(serverId, host).finally(() => {
if (this.inFlight.get(serverId) === migration) {
this.inFlight.delete(serverId);
}
});
this.inFlight.set(serverId, migration);
return migration;
}
private async run(serverId: string, host: LegacyFavoriteMigrationHost): Promise<void> {
if (!supportsMigration(host)) {
return;
}
const markerKey = completionKey(serverId);
if ((await this.storage.getItem(markerKey)) === "1") {
return;
}
const favorites = parseStoredPreferences(await this.storage.getItem(PREFERENCES_KEY));
if (favorites.length === 0) {
await this.storage.setItem(markerKey, "1");
return;
}
const [{ config }, entries] = await Promise.all([
host.getDaemonConfig(),
readProviderCatalog(host, favorites),
]);
const existing = config.agentProfiles ?? [];
const migrated = buildMigratedProfiles({ favorites, existing, entries });
if (migrated.length > 0) {
await host.patchDaemonConfig({ agentProfiles: [...existing, ...migrated] });
}
await this.storage.setItem(markerKey, "1");
}
}
export const legacyFavoriteProfileMigration = new LegacyFavoriteProfileMigration(AsyncStorage);
@@ -0,0 +1,384 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check, ChevronDown } from "lucide-react-native";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
} from "@/components/ui/control-geometry";
import { Field } from "@/components/ui/form-field";
import { identityForeground } from "@/styles/identity-colors";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { AgentProfileGlyph } from "../internal/agent-profile-glyph";
import {
AGENT_PROFILE_COLORS,
AGENT_PROFILE_ICON_KEYS,
resolveAgentProfileColor,
resolveAgentProfileIconKey,
type AgentProfileColor,
type AgentProfileIconKey,
} from "../internal/profile-appearance";
const CELL_SIZE = 40;
const CELL_GAP = 4;
const GRID_COLUMNS = 5;
const CELL_ROW_WIDTH = GRID_COLUMNS * CELL_SIZE + (GRID_COLUMNS - 1) * CELL_GAP;
/** Matches `styles.body.padding`; both derive from `theme.spacing[3]`. */
const CELL_GRID_PADDING = 12;
/** Swatches are dots, not targets the eye has to land on precisely, so they pack tighter. */
const COLOR_CELL_SIZE = 32;
const SWATCH_SIZE = 20;
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCheck = withUnistyles(Check);
const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const onIdentityMapping = (theme: Theme) => ({ color: theme.colors.background });
/**
* Locked, not a floor: an unlocked popover stretches to its 400px maximum and
* the wrapping grid follows it to eight columns. Two spare pixels so a frame
* border can never push the fifth cell onto its own row.
*/
const DESKTOP_WIDTH = CELL_ROW_WIDTH + CELL_GRID_PADDING * 2 + 2;
/** Colours are named in host appearance; profiles reuse both the palette and its labels. */
function colorLabel(t: TFunction, color: AgentProfileColor): string {
return t(`settings.host.appearance.color.options.${color}`);
}
function IconCell({
iconKey,
selected,
color,
onSelect,
}: {
iconKey: AgentProfileIconKey | null;
selected: boolean;
color: AgentProfileColor;
onSelect: (iconKey: AgentProfileIconKey | null) => void;
}) {
const handlePress = useCallback(() => onSelect(iconKey), [iconKey, onSelect]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const cellStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.cell,
Boolean(hovered) && styles.cellHovered,
pressed && styles.cellPressed,
selected && styles.cellSelected,
],
[selected],
);
return (
<Pressable
onPress={handlePress}
style={cellStyle}
accessibilityRole="button"
accessibilityState={accessibilityState}
accessibilityLabel={iconKey ?? "default"}
testID={`agent-profile-icon-cell-${iconKey ?? "default"}`}
>
<AgentProfileGlyph
{...(iconKey ? { icon: iconKey } : {})}
color={color}
size={ICON_SIZE.lg}
/>
</Pressable>
);
}
function ColorCell({
color,
selected,
onSelect,
}: {
color: AgentProfileColor;
selected: boolean;
onSelect: (color: AgentProfileColor) => void;
}) {
const { t } = useTranslation();
const handlePress = useCallback(() => onSelect(color), [color, onSelect]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const swatchStyle = useMemo(
() => [styles.swatch, color === "none" ? styles.swatchNone : identitySwatch(color)],
[color],
);
const cellStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [styles.colorCell, pressed && styles.cellPressed],
[],
);
return (
<Pressable
onPress={handlePress}
style={cellStyle}
accessibilityRole="button"
accessibilityState={accessibilityState}
accessibilityLabel={colorLabel(t, color)}
testID={`agent-profile-color-cell-${color}`}
>
<View style={swatchStyle}>
{selected ? (
<ThemedCheck
size={ICON_SIZE.sm}
uniProps={color === "none" ? foregroundMutedMapping : onIdentityMapping}
/>
) : null}
</View>
</Pressable>
);
}
/**
* Icon and colour in one popover rather than two fields: the pair is a single
* visual decision, and picking them apart means never seeing the result until
* both are closed. The grid redraws in the chosen colour as you switch colours,
* so the swatch row sits above it.
*/
export function AgentProfileAppearanceField({
label,
icon,
color,
onChange,
size = "md",
disabled = false,
testID,
triggerTestID,
}: {
label: string;
icon: string;
color: string;
onChange: (next: { icon: string; color: string }) => void;
size?: FieldControlSize;
disabled?: boolean;
testID?: string;
triggerTestID?: string;
}): ReactElement {
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const selectedIcon = resolveAgentProfileIconKey(icon);
const selectedColor = resolveAgentProfileColor(color);
const handleTriggerPress = useCallback(() => {
if (disabled) {
return;
}
setOpen((current) => !current);
}, [disabled]);
// Colour keeps the popover open — you are still choosing. An icon is the last
// decision, so it commits and closes.
const handleColorSelect = useCallback(
(next: AgentProfileColor) => onChange({ icon, color: next === "none" ? "" : next }),
[icon, onChange],
);
const handleIconSelect = useCallback(
(next: AgentProfileIconKey | null) => {
onChange({ icon: next ?? "", color });
setOpen(false);
},
[color, onChange],
);
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
size === "sm" ? styles.triggerSm : styles.triggerMd,
styles.triggerIconOnly,
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
controlDisabled: styles.controlDisabled,
},
{ hovered: Boolean(hovered), active: pressed || open, disabled },
),
],
[disabled, open, size],
);
const header = useMemo(() => ({ title: label }), [label]);
return (
<Field label={label} testID={testID}>
<View ref={anchorRef} collapsable={false}>
<Pressable
onPress={handleTriggerPress}
disabled={disabled}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`${label} (${colorLabel(t, selectedColor)})`}
testID={triggerTestID}
>
<AgentProfileGlyph
{...(selectedIcon ? { icon: selectedIcon } : {})}
color={selectedColor}
size={ICON_SIZE.md}
/>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
</Pressable>
</View>
<Combobox
options={EMPTY_COMBOBOX_OPTIONS}
value=""
onSelect={noop}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
header={header}
desktopMinWidth={DESKTOP_WIDTH}
desktopLockWidth
>
<View style={styles.body} testID="agent-profile-appearance-picker">
<View style={styles.colorRow}>
{AGENT_PROFILE_COLORS.map((option) => (
<ColorCell
key={option}
color={option}
selected={option === selectedColor}
onSelect={handleColorSelect}
/>
))}
</View>
<View style={styles.grid}>
<IconCell
iconKey={null}
selected={selectedIcon === null}
color={selectedColor}
onSelect={handleIconSelect}
/>
{AGENT_PROFILE_ICON_KEYS.map((key) => (
<IconCell
key={key}
iconKey={key}
selected={key === selectedIcon}
color={selectedColor}
onSelect={handleIconSelect}
/>
))}
</View>
</View>
</Combobox>
</Field>
);
}
function noop() {}
/**
* One named rule per hue. Unistyles cannot resolve a colour computed at render,
* so the palette has to be spelled out as static styles.
*/
function identitySwatch(color: Exclude<AgentProfileColor, "none">) {
return styles[IDENTITY_SWATCH_STYLE_KEYS[color]];
}
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
backgroundColor: theme.colors.surface2,
},
triggerSm: { ...geometry.fieldControlSm },
triggerMd: { ...geometry.fieldControlMd },
// The field geometry pads for a line of text. This trigger holds a glyph and
// a chevron, so it keeps the shared height and drops the text-sized inset.
triggerIconOnly: {
paddingHorizontal: theme.spacing[2],
},
controlRest: { ...geometry.controlRest },
controlHover: { ...geometry.controlHover },
controlActive: { ...geometry.controlActive },
controlDisabled: { ...geometry.controlDisabled },
body: {
padding: CELL_GRID_PADDING,
gap: theme.spacing[3],
},
colorRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: CELL_GAP,
paddingBottom: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
colorCell: {
width: COLOR_CELL_SIZE,
height: COLOR_CELL_SIZE,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: CELL_GAP,
},
cell: {
width: CELL_SIZE,
height: CELL_SIZE,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
},
cellHovered: {
backgroundColor: theme.colors.surface2,
},
cellPressed: {
opacity: 0.7,
},
cellSelected: {
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.border,
},
swatch: {
width: SWATCH_SIZE,
height: SWATCH_SIZE,
borderRadius: SWATCH_SIZE / 2,
alignItems: "center",
justifyContent: "center",
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
swatchNone: {
backgroundColor: theme.colors.surface2,
},
swatchViolet: { backgroundColor: identityForeground("violet", theme.colorScheme) },
swatchSky: { backgroundColor: identityForeground("sky", theme.colorScheme) },
swatchEmerald: { backgroundColor: identityForeground("emerald", theme.colorScheme) },
swatchOrange: { backgroundColor: identityForeground("orange", theme.colorScheme) },
swatchPink: { backgroundColor: identityForeground("pink", theme.colorScheme) },
swatchIndigo: { backgroundColor: identityForeground("indigo", theme.colorScheme) },
swatchTeal: { backgroundColor: identityForeground("teal", theme.colorScheme) },
swatchRed: { backgroundColor: identityForeground("red", theme.colorScheme) },
swatchAmber: { backgroundColor: identityForeground("amber", theme.colorScheme) },
swatchBlue: { backgroundColor: identityForeground("blue", theme.colorScheme) },
};
});
const IDENTITY_SWATCH_STYLE_KEYS = {
violet: "swatchViolet",
sky: "swatchSky",
emerald: "swatchEmerald",
orange: "swatchOrange",
pink: "swatchPink",
indigo: "swatchIndigo",
teal: "swatchTeal",
red: "swatchRed",
amber: "swatchAmber",
blue: "swatchBlue",
} as const;
@@ -0,0 +1,549 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { type FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import {
SelectField,
type SelectFieldDisplay,
type SelectFieldOption,
} from "@/components/ui/select-field";
import { Switch } from "@/components/ui/switch";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { AgentProfileAppearanceField } from "./agent-profile-appearance-field";
import type {
AgentProfileFormModel,
AgentProfileFormOption,
AgentProfileValue,
} from "../internal/profile-form-model";
import {
useAgentProfileFormCatalog,
useAgentProfileFormFeatures,
} from "../internal/use-profile-form-inputs";
import {
useAgentProfileFormModel,
useAgentProfileFormState,
} from "../internal/use-profile-form-model";
export interface AgentProfileEditModalProps {
serverId: string;
visible: boolean;
mode: "create" | "edit";
profile?: AgentProfile;
onClose: () => void;
onSave: (value: AgentProfileValue) => Promise<void>;
}
/**
* Only reachable before the catalog lands. Every select is seeded to a concrete
* id as soon as one exists, and none of them offer an "unset" row.
*/
const UNSET_VALUE = "";
function openKey(props: AgentProfileEditModalProps): string {
return props.mode === "edit" ? `edit:${props.profile?.id ?? ""}` : "create";
}
/**
* Create and edit never share a mounted instance: the body is keyed on mode plus
* profile id, so every open constructs a form model seeded from that record.
*/
export function AgentProfileEditModal(props: AgentProfileEditModalProps): ReactElement | null {
const [renderedProps, setRenderedProps] = useState<AgentProfileEditModalProps | null>(() =>
props.visible ? props : null,
);
const [sheetVisible, setSheetVisible] = useState(props.visible);
const livePropsRef = useRef(props);
const closeRequestedRef = useRef(false);
livePropsRef.current = props;
useEffect(() => {
if (props.visible) {
if (closeRequestedRef.current) {
return;
}
setRenderedProps(props);
setSheetVisible(true);
return;
}
if (renderedProps) {
setSheetVisible(false);
}
}, [props, renderedProps]);
const requestClose = useCallback(() => {
closeRequestedRef.current = true;
setSheetVisible(false);
}, []);
const handleDismiss = useCallback(() => {
const dismissedProps = livePropsRef.current;
closeRequestedRef.current = false;
setRenderedProps(null);
setSheetVisible(false);
if (dismissedProps.visible) {
dismissedProps.onClose();
}
}, []);
if (!renderedProps) {
return null;
}
return (
<OpenAgentProfileEditModal
key={openKey(renderedProps)}
{...renderedProps}
visible={sheetVisible}
onClose={requestClose}
onDismiss={handleDismiss}
/>
);
}
function toSelectOptions(options: AgentProfileFormOption[]): SelectFieldOption<string>[] {
return options.map((option) => ({
id: option.id,
value: option.value,
label: option.label,
...(option.description ? { description: option.description } : {}),
testID: option.testID,
}));
}
function OpenAgentProfileEditModal({
serverId,
visible,
mode,
profile,
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 model = useAgentProfileFormModel(snapshot);
const state = useAgentProfileFormState(model);
useAgentProfileFormCatalog({ serverId, model });
useAgentProfileFormFeatures({ serverId, model, state });
const sheetHeader = useMemo<SheetHeader>(
() => ({
title:
mode === "edit"
? t("settings.host.agentProfiles.editProfileTitle")
: t("settings.host.agentProfiles.addProfileTitle"),
}),
[mode, t],
);
const providerOptions = useMemo(
() => toSelectOptions(state.providerOptions),
[state.providerOptions],
);
const modelOptions = useMemo(() => toSelectOptions(state.modelOptions), [state.modelOptions]);
const modeOptions = useMemo(() => toSelectOptions(state.modeOptions), [state.modeOptions]);
const thinkingOptions = useMemo(
() => toSelectOptions(state.thinkingOptions),
[state.thinkingOptions],
);
const handleAppearanceChange = useCallback(
(value: { icon: string; color: string }) => model.setAppearance(value),
[model],
);
const handleProviderChange = useCallback(
(value: string, display: SelectFieldDisplay) => model.setProvider(value, display),
[model],
);
const handleModelChange = useCallback(
(value: string, display: SelectFieldDisplay) => model.setModel(value, value ? display : null),
[model],
);
const handleModeChange = useCallback(
(value: string, display: SelectFieldDisplay) => model.setMode(value, value ? display : null),
[model],
);
const handleThinkingChange = useCallback(
(value: string, display: SelectFieldDisplay) =>
model.setThinking(value, value ? display : null),
[model],
);
const handleSave = useCallback(async () => {
const value = model.getState().submitValue;
if (!value) {
return;
}
model.setSubmitError(null);
model.setSubmitting(true);
try {
await onSave(value);
onClose();
} catch (error) {
model.setSubmitError(toErrorMessage(error));
} finally {
model.setSubmitting(false);
}
}, [model, onClose, onSave]);
const handleSavePress = useCallback(() => {
void handleSave();
}, [handleSave]);
const handleCancel = useCallback(() => {
if (state.isSubmitting) {
return;
}
onClose();
}, [onClose, state.isSubmitting]);
return (
<AdaptiveModalSheet
visible={visible}
header={sheetHeader}
onClose={handleCancel}
onDismiss={onDismiss}
desktopMaxWidth={520}
testID="agent-profile-edit-modal"
>
<View style={styles.body}>
<View style={styles.nameRow}>
<View style={styles.iconField}>
<AgentProfileAppearanceField
label={t("settings.host.agentProfiles.iconLabel")}
icon={state.icon}
color={state.color}
onChange={handleAppearanceChange}
disabled={state.isSubmitting}
size={controlSize}
testID="agent-profile-icon-field"
triggerTestID="agent-profile-icon-trigger"
/>
</View>
<View style={styles.nameField}>
<Field
label={t("settings.host.agentProfiles.nameLabel")}
testID="agent-profile-name-field"
>
<FormTextInput
initialValue={profile?.name ?? ""}
onChangeText={model.setName}
placeholder={t("settings.host.agentProfiles.namePlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!state.isSubmitting}
size={controlSize}
accessibilityLabel={t("settings.host.agentProfiles.nameLabel")}
testID="agent-profile-name-input"
/>
</Field>
</View>
</View>
<SelectField
label={t("settings.host.agentProfiles.providerLabel")}
value={state.provider || null}
selectedDisplay={state.providerDisplay}
options={providerOptions}
onChange={handleProviderChange}
placeholder={t("settings.host.agentProfiles.providerPlaceholder")}
emptyText={t("settings.host.agentProfiles.noProviders")}
loading={state.catalogResolution !== "complete"}
disabled={state.isSubmitting}
searchable={providerOptions.length > 6}
title={t("settings.host.agentProfiles.providerLabel")}
size={controlSize}
testID="agent-profile-provider-field"
triggerTestID="agent-profile-provider-trigger"
/>
{state.disclosure.showModelField ? (
<SelectField
label={t("settings.host.agentProfiles.modelLabel")}
value={state.modelId || UNSET_VALUE}
selectedDisplay={state.modelDisplay}
options={modelOptions}
onChange={handleModelChange}
placeholder={t("settings.host.agentProfiles.modelLabel")}
emptyText={t("settings.host.agentProfiles.noModels")}
disabled={state.isSubmitting}
searchable={modelOptions.length > 6}
title={t("settings.host.agentProfiles.modelLabel")}
size={controlSize}
testID="agent-profile-model-field"
triggerTestID="agent-profile-model-trigger"
/>
) : null}
{state.disclosure.showModeField ? (
<SelectField
label={t("settings.host.agentProfiles.modeLabel")}
value={state.modeId || UNSET_VALUE}
selectedDisplay={state.modeDisplay}
options={modeOptions}
onChange={handleModeChange}
placeholder={t("settings.host.agentProfiles.modeLabel")}
emptyText={t("settings.host.agentProfiles.noModes")}
disabled={state.isSubmitting}
searchable={modeOptions.length > 6}
title={t("settings.host.agentProfiles.modeLabel")}
size={controlSize}
testID="agent-profile-mode-field"
triggerTestID="agent-profile-mode-trigger"
/>
) : null}
{state.disclosure.showThinkingField ? (
<SelectField
label={t("settings.host.agentProfiles.thinkingLabel")}
value={state.thinkingOptionId || UNSET_VALUE}
selectedDisplay={state.thinkingDisplay}
options={thinkingOptions}
onChange={handleThinkingChange}
placeholder={t("settings.host.agentProfiles.thinkingLabel")}
emptyText={t("settings.host.agentProfiles.noThinkingOptions")}
disabled={state.isSubmitting}
searchable={thinkingOptions.length > 6}
title={t("settings.host.agentProfiles.thinkingLabel")}
size={controlSize}
testID="agent-profile-thinking-field"
triggerTestID="agent-profile-thinking-trigger"
/>
) : null}
{state.disclosure.showFeaturesField ? (
<Field
label={t("settings.host.agentProfiles.featuresLabel")}
testID="agent-profile-features-field"
>
<View style={styles.featureCard} testID="agent-profile-features-card">
{state.features.map((feature, index) => (
<AgentProfileFeatureRow
key={feature.id}
feature={feature}
isFirst={index === 0}
disabled={state.isSubmitting}
size={controlSize}
model={model}
/>
))}
</View>
</Field>
) : null}
<Field
label={t("settings.host.agentProfiles.notesLabel")}
hint={t("settings.host.agentProfiles.notesHint")}
testID="agent-profile-notes-field"
>
<FormTextInput
initialValue={profile?.notes ?? ""}
onChangeText={model.setNotes}
placeholder={t("settings.host.agentProfiles.notesPlaceholder")}
multiline
numberOfLines={4}
style={styles.notesInput}
editable={!state.isSubmitting}
size={controlSize}
accessibilityLabel={t("settings.host.agentProfiles.notesLabel")}
testID="agent-profile-notes-input"
/>
</Field>
{state.submitError ? (
<Text style={styles.submitError} testID="agent-profile-submit-error">
{state.submitError}
</Text>
) : null}
<View style={styles.actions}>
<Button
variant="secondary"
style={styles.actionButton}
onPress={handleCancel}
disabled={state.isSubmitting}
testID="agent-profile-cancel-button"
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
style={styles.actionButton}
onPress={handleSavePress}
disabled={!state.canSubmit}
testID="agent-profile-save-button"
>
{state.isSubmitting
? t("settings.host.agentProfiles.saving")
: t("settings.host.agentProfiles.save")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
function AgentProfileFeatureRow({
feature,
isFirst,
disabled,
size,
model,
}: {
feature: AgentFeature;
isFirst: boolean;
disabled: boolean;
size: FieldControlSize;
model: AgentProfileFormModel;
}): ReactElement {
const rowStyle = useMemo(
() => [styles.featureRow, isFirst ? null : styles.featureRowBorder],
[isFirst],
);
const handleToggle = useCallback(
(value: boolean) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const handleSelect = useCallback(
(value: string) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const selectOptions = useMemo<SelectFieldOption<string>[]>(
() =>
feature.type === "select"
? feature.options.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
}))
: [],
[feature],
);
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
if (feature.type !== "select" || !feature.value) {
return null;
}
const option = feature.options.find((candidate) => candidate.id === feature.value);
return { label: option?.label ?? feature.value };
}, [feature]);
if (feature.type === "select") {
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<SelectField
label={feature.label}
value={feature.value}
selectedDisplay={selectedDisplay}
options={selectOptions}
onChange={handleSelect}
placeholder={feature.label}
emptyText={feature.label}
disabled={disabled}
{...(feature.description ? { hint: feature.description } : {})}
title={feature.label}
size={size}
testID={`agent-profile-feature-field-${feature.id}`}
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
/>
</View>
);
}
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<View style={styles.featureMeta}>
<Text style={styles.featureLabel} numberOfLines={1}>
{feature.label}
</Text>
{feature.description ? (
<Text style={styles.featureDescription} numberOfLines={2}>
{feature.description}
</Text>
) : null}
</View>
<Switch
value={feature.value === true}
onValueChange={handleToggle}
disabled={disabled}
accessibilityLabel={feature.label}
testID={`agent-profile-feature-switch-${feature.id}`}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
body: {
gap: theme.spacing[4],
paddingBottom: theme.spacing[2],
},
nameRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
},
// No width: the trigger is a glyph and a chevron, so it sizes to them.
iconField: {
flexShrink: 0,
},
nameField: {
flex: 1,
},
featureCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[3],
},
featureRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
featureRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
featureMeta: {
flex: 1,
gap: theme.spacing[1],
},
featureLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
featureDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
notesInput: {
minHeight: 88,
textAlignVertical: "top",
},
submitError: {
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
actionButton: {
flex: 1,
},
}));
@@ -0,0 +1,181 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, FileText, Pencil, Trash2 } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { AgentProfileGlyph } from "../internal/agent-profile-glyph";
import { buildAgentProfileTags } from "../internal/profile-summary";
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedArrowDown = withUnistyles(ArrowDown);
const ThemedPencil = withUnistyles(Pencil);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedFileText = withUnistyles(FileText);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const moveUpIcon = <ThemedArrowUp size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const moveDownIcon = <ThemedArrowDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const editIcon = <ThemedPencil size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const removeIcon = <ThemedTrash2 size={ICON_SIZE.sm} uniProps={destructiveColorMapping} />;
export interface AgentProfileRowProps {
profile: AgentProfile;
entries: readonly ProviderSnapshotEntry[] | undefined;
isFirst: boolean;
isLast: boolean;
onEdit: (id: string) => void;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
}
export function AgentProfileRow({
profile,
entries,
isFirst,
isLast,
onEdit,
onRemove,
onMoveUp,
onMoveDown,
}: AgentProfileRowProps): ReactElement {
const { t } = useTranslation();
const handleEdit = useCallback(() => onEdit(profile.id), [onEdit, profile.id]);
const handleRemove = useCallback(() => onRemove(profile.id), [onRemove, profile.id]);
const handleMoveUp = useCallback(() => onMoveUp(profile.id), [onMoveUp, profile.id]);
const handleMoveDown = useCallback(() => onMoveDown(profile.id), [onMoveDown, profile.id]);
const formatFeatureCount = useCallback(
(count: number) =>
count === 1
? t("settings.host.agentProfiles.featureCountOne", { count })
: t("settings.host.agentProfiles.featureCount", { count }),
[t],
);
const tags = useMemo(
() => buildAgentProfileTags({ profile, entries, formatFeatureCount }),
[entries, formatFeatureCount, profile],
);
const summary = useMemo(() => tags.map((tag) => tag.label).join(" · "), [tags]);
const rowStyle = useMemo(
() => [settingsStyles.row, isFirst ? null : settingsStyles.rowBorder, styles.row],
[isFirst],
);
return (
<View style={rowStyle} testID={`agent-profile-row-${profile.id}`}>
<View style={styles.iconWrapper}>
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
</View>
<View style={settingsStyles.rowContent}>
<View style={styles.titleLine}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{profile.name}
</Text>
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
</View>
{profile.notes ? (
<View style={styles.notes}>
<ThemedFileText size={ICON_SIZE.xs} uniProps={mutedColorMapping} />
<Text
style={styles.notesText}
numberOfLines={2}
testID={`agent-profile-notes-${profile.id}`}
>
{profile.notes}
</Text>
</View>
) : null}
</View>
<View style={styles.rowActions}>
<Button
variant="ghost"
size="sm"
leftIcon={moveUpIcon}
onPress={handleMoveUp}
disabled={isFirst}
accessibilityLabel={t("settings.host.agentProfiles.moveUp")}
testID={`agent-profile-move-up-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={moveDownIcon}
onPress={handleMoveDown}
disabled={isLast}
accessibilityLabel={t("settings.host.agentProfiles.moveDown")}
testID={`agent-profile-move-down-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={editIcon}
onPress={handleEdit}
accessibilityLabel={t("settings.host.agentProfiles.editProfile")}
testID={`agent-profile-edit-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={removeIcon}
onPress={handleRemove}
accessibilityLabel={t("settings.host.agentProfiles.remove")}
testID={`agent-profile-remove-${profile.id}`}
/>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
gap: theme.spacing[2],
minHeight: 56,
alignItems: "center",
paddingVertical: theme.spacing[3],
},
iconWrapper: {
width: theme.iconSize.md,
alignSelf: "flex-start",
alignItems: "center",
justifyContent: "center",
marginTop: theme.spacing[1],
},
titleLine: {
flexDirection: "row",
alignItems: "baseline",
gap: theme.spacing[2],
minWidth: 0,
},
summary: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
notes: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[1],
marginTop: theme.spacing[1],
},
notesText: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
rowActions: {
flexDirection: "row",
alignItems: "center",
},
}));
@@ -0,0 +1,219 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Alert, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { Button } from "@/components/ui/button";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useAgentProfiles } from "../internal/use-agent-profiles";
import type { AgentProfileValue } from "../internal/profile-form-model";
import { AgentProfileEditModal } from "./agent-profile-edit-modal";
import { AgentProfileRow } from "./agent-profile-row";
const ThemedPlus = withUnistyles(Plus);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
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 {
mode: "create" | "edit";
profile?: AgentProfile;
}
export function AgentProfilesSection({ serverId }: { serverId: string }): ReactElement {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const { profiles, isSupported, saveProfiles } = useAgentProfiles(serverId);
const { entries } = useProvidersSnapshot(serverId, { cwd: null });
const [editTarget, setEditTarget] = useState<EditTarget | null>(null);
const handleAddOpen = useCallback(() => setEditTarget({ mode: "create" }), []);
const handleEditClose = useCallback(() => setEditTarget(null), []);
const handleEditOpen = useCallback(
(id: string) => {
const profile = profiles?.find((entry) => entry.id === id);
if (!profile) {
return;
}
setEditTarget({ mode: "edit", profile });
},
[profiles],
);
const handleSave = useCallback(
async (value: AgentProfileValue) => {
const current = profiles ?? [];
const editing = editTarget?.mode === "edit" ? editTarget.profile : undefined;
// The edited profile is replaced, not merged: `value` omits the fields the
// user cleared, so spreading it over the stored record would silently keep
// the old model, mode, thinking option or notes.
const next: AgentProfile[] = editing
? current.map((entry) => (entry.id === editing.id ? { id: entry.id, ...value } : entry))
: [...current, { id: generateAgentProfileId(), ...value }];
await saveProfiles(next);
},
[editTarget, profiles, saveProfiles],
);
const reorder = useCallback(
async (id: string, offset: -1 | 1) => {
if (!profiles) {
return;
}
const index = profiles.findIndex((entry) => entry.id === id);
const target = index + offset;
if (index < 0 || target < 0 || target >= profiles.length) {
return;
}
const next = [...profiles];
const [item] = next.splice(index, 1);
next.splice(target, 0, item);
try {
await saveProfiles(next);
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
},
[profiles, saveProfiles, t],
);
const handleMoveUp = useCallback((id: string) => void reorder(id, -1), [reorder]);
const handleMoveDown = useCallback((id: string) => void reorder(id, 1), [reorder]);
const handleRemove = useCallback(
(id: string) => {
const profile = profiles?.find((entry) => entry.id === id);
if (!profile) {
return;
}
void confirmDialog({
title: t("settings.host.agentProfiles.removeConfirmTitle"),
message: t("settings.host.agentProfiles.removeConfirmMessage", { name: profile.name }),
confirmLabel: t("settings.host.agentProfiles.remove"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
}).then(async (confirmed) => {
if (!confirmed || !profiles) {
return;
}
try {
await saveProfiles(profiles.filter((entry) => entry.id !== id));
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
return;
});
},
[profiles, saveProfiles, t],
);
const addButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={addIcon}
onPress={handleAddOpen}
disabled={!profiles}
accessibilityLabel={t("settings.host.agentProfiles.addProfileTitle")}
testID="agent-profiles-add-button"
/>
),
[handleAddOpen, profiles, t],
);
if (!isConnected || !isSupported) {
return (
<SettingsSection
title={t("settings.host.agentProfiles.sectionTitle")}
testID="agent-profiles-section"
>
<View style={settingsStyles.card} testID="agent-profiles-unavailable">
<View style={styles.emptyCard}>
<Text style={styles.emptyText}>
{isConnected
? t("settings.host.agentProfiles.unsupported")
: t("settings.host.agentProfiles.unavailable")}
</Text>
</View>
</View>
</SettingsSection>
);
}
return (
<>
<SettingsSection
title={t("settings.host.agentProfiles.sectionTitle")}
trailing={addButton}
testID="agent-profiles-section"
>
<View style={settingsStyles.card} testID="agent-profiles-card">
{profiles && profiles.length > 0 ? (
profiles.map((profile, index) => (
<AgentProfileRow
key={profile.id}
profile={profile}
entries={entries}
isFirst={index === 0}
isLast={index === profiles.length - 1}
onEdit={handleEditOpen}
onRemove={handleRemove}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))
) : (
<View style={styles.emptyCard}>
<Text style={styles.emptyText} testID="agent-profiles-empty">
{t("settings.host.agentProfiles.emptyState")}
</Text>
<Button size="sm" leftIcon={addIcon} onPress={handleAddOpen} disabled={!profiles}>
{t("settings.host.agentProfiles.newProfile")}
</Button>
</View>
)}
</View>
</SettingsSection>
<AgentProfileEditModal
serverId={serverId}
visible={editTarget !== null}
mode={editTarget?.mode ?? "create"}
{...(editTarget?.profile ? { profile: editTarget.profile } : {})}
onClose={handleEditClose}
onSave={handleSave}
/>
</>
);
}
const styles = StyleSheet.create((theme) => ({
emptyCard: {
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[3],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
}));