diff --git a/docs/custom-providers.md b/docs/custom-providers.md index e31fefe06..17bd1ceeb 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -245,6 +245,8 @@ requires_openai_auth = false You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment. +"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word. + Example: two different Anthropic accounts as separate profiles: ```json diff --git a/docs/data-model.md b/docs/data-model.md index 6802c29f9..3a29076ea 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -185,6 +185,8 @@ Single file, validated with `PersistedConfigSchema`. mcp: { enabled: boolean, injectIntoAgents: boolean }, git: { maxProcessesPerSecond: number, maxProcessConcurrency: number }, appendSystemPrompt: string, // appended to supported provider system/developer prompts + terminalProfiles: TerminalProfile[], // named shell commands; omitted means DEFAULT_TERMINAL_PROFILES + agentProfiles: AgentProfile[], // named agent launch bundles; omitted means none cors: { allowedOrigins: string[] }, relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false auth: { password: string } // bcrypt hash, optional @@ -231,6 +233,21 @@ Single file, validated with `PersistedConfigSchema`. All fields are optional with sensible defaults. +### Profile lists + +`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the +array, never merges entries, so a client sends the complete next list on every add, edit, reorder +and remove. List order is the display order. + +Absent and empty mean different things for terminal profiles — omitting the key falls back to +`DEFAULT_TERMINAL_PROFILES`, while `[]` means the user removed them all. Agent profiles have no +defaults, so both mean none. + +`PersistedConfigSchema` parses strictly, so a daemon that predates a field drops it on write +rather than storing something it cannot describe. That is why the client gates the agent profiles +UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an +older daemon. + ### Git process limits Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per diff --git a/docs/glossary.md b/docs/glossary.md index ce2e9c952..4dd156761 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -24,7 +24,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. - **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). - **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log. -- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. +- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy. +- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced. +- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent. - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). diff --git a/packages/app/e2e/browser/agent-profiles-picker.spec.ts b/packages/app/e2e/browser/agent-profiles-picker.spec.ts new file mode 100644 index 000000000..87fcffcab --- /dev/null +++ b/packages/app/e2e/browser/agent-profiles-picker.spec.ts @@ -0,0 +1,126 @@ +import { test } from "../support/fixtures"; +import { + applyProfileFromPicker, + closeModelPicker, + drillIntoProvider, + expectComposerDoesNotName, + expectAgentProfilesEmptyPrompt, + expectProfileEditTooltip, + expectComposerMode, + expectComposerModel, + expectModelRowSelected, + expectNothingSelectedInPickerRoot, + expectProfileEditIsPencilOnly, + expectProfilePinnedAboveProviders, + expectProfileVisibleForProvider, + openModelPicker, + openAgentProfilesFromEmptyPrompt, + seedAgentProfiles, +} from "../support/helpers/agent-profiles"; +import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +const PROFILE = { + id: "agent_profile_e2e_ui_work", + name: "UI work", + icon: "🎨", + provider: "mock", + model: "one-minute-stream", + modeId: "approval-test", + notes: "Use for UI work.", +}; + +const PROFILE_SUMMARY = "Mock Load Test · One minute stream · Approval test"; + +test.describe("Agent profiles in the model picker", () => { + test("an empty host still exposes agent profile settings from the picker", async ({ page }) => { + const seed = await seedAgentProfiles([]); + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "agent-profiles-empty-", + title: "Agent profiles empty", + }); + + try { + await openAgentRoute(page, workspace); + await expectComposerVisible(page); + await openModelPicker(page); + await expectAgentProfilesEmptyPrompt(page); + await openAgentProfilesFromEmptyPrompt(page); + } finally { + await workspace.cleanup(); + await seed.restore(); + } + }); + + test("applying a pinned profile materializes it into the composer and is then forgotten", async ({ + page, + }) => { + const seed = await seedAgentProfiles([PROFILE]); + // A live agent is one provider's process, so the profile has to name that + // same provider or the picker will not offer it at all. + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "agent-profiles-picker-", + title: "Agent profiles picker", + model: "ten-second-stream", + modeId: "load-test", + }); + + try { + await test.step("the agent starts on its seeded model and mode", async () => { + await openAgentRoute(page, workspace); + await expectComposerVisible(page); + await expectComposerModel(page, "Ten second stream"); + await expectComposerMode(page, "Load test"); + }); + + await test.step("the profile is pinned above the provider list", async () => { + await openModelPicker(page); + await expectProfilePinnedAboveProviders(page, { + name: PROFILE.name, + summary: PROFILE_SUMMARY, + }); + await expectProfileEditIsPencilOnly(page); + await expectProfileEditTooltip(page); + }); + + await test.step("applying it writes its model and mode into the composer", async () => { + await applyProfileFromPicker(page, PROFILE.name); + await expectComposerModel(page, "One minute stream"); + await expectComposerMode(page, "Approval test"); + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "one-minute-stream", + modeId: "approval-test", + }); + }); + + await test.step("the composer names the model, never the profile", async () => { + await expectComposerDoesNotName(page, PROFILE.name); + }); + + await test.step("reopening the picker shows no profile marked as selected", async () => { + await openModelPicker(page); + await expectProfilePinnedAboveProviders(page, { + name: PROFILE.name, + summary: PROFILE_SUMMARY, + }); + await expectNothingSelectedInPickerRoot(page); + }); + + await test.step("the model it materialized is the one marked selected", async () => { + await drillIntoProvider(page, "mock"); + await expectProfileVisibleForProvider(page, { + name: PROFILE.name, + summary: PROFILE_SUMMARY, + }); + await expectModelRowSelected(page, { provider: "mock", modelId: "one-minute-stream" }); + await closeModelPicker(page); + }); + } finally { + await workspace.cleanup(); + await seed.restore(); + } + }); +}); diff --git a/packages/app/e2e/browser/agent-profiles-settings.spec.ts b/packages/app/e2e/browser/agent-profiles-settings.spec.ts new file mode 100644 index 000000000..8aa2f3864 --- /dev/null +++ b/packages/app/e2e/browser/agent-profiles-settings.spec.ts @@ -0,0 +1,128 @@ +import { test } from "../support/fixtures"; +import { + createAgentProfile, + createAgentProfileFromEmptyState, + editAgentProfile, + expectAgentProfile, + expectAgentProfileForm, + expectAgentProfileOrder, + expectAgentProfileTagsGone, + expectHostAgentProfiles, + expectNoAgentProfiles, + moveAgentProfileUp, + openAgentProfileSettings, + removeAgentProfile, + seedAgentProfiles, + stageLegacyFavoritesForHostMigration, +} from "../support/helpers/agent-profiles"; + +const MOCK_PROVIDER_LABEL = "Mock Load Test"; + +test.describe("Agent profiles settings", () => { + test("legacy model favourites migrate into provider-and-model-only host profiles", async ({ + page, + }) => { + const seed = await seedAgentProfiles([]); + const migratedProfile = { + id: "legacy_favorite:mock:one-minute-stream", + name: "One minute stream", + provider: "mock", + model: "one-minute-stream", + }; + + try { + await stageLegacyFavoritesForHostMigration(page, [ + { provider: "mock", modelId: "one-minute-stream" }, + ]); + + await expectHostAgentProfiles([migratedProfile]); + await openAgentProfileSettings(page); + await expectAgentProfile(page, { + name: migratedProfile.name, + tags: [MOCK_PROVIDER_LABEL, "One minute stream"], + }); + } finally { + await seed.restore(); + } + }); + + test("host owner creates, edits, reorders and removes agent profiles", async ({ page }) => { + // The journey owns the list, so it starts from an empty one whatever the + // worker ran before, and hands it back untouched. + const seed = await seedAgentProfiles([]); + + try { + await test.step("the section starts empty", async () => { + await openAgentProfileSettings(page); + await expectNoAgentProfiles(page); + }); + + await test.step("create a profile from the modal", async () => { + await createAgentProfileFromEmptyState(page, { + name: "UI work", + provider: MOCK_PROVIDER_LABEL, + model: "Ten second stream", + thinking: "High", + mode: "Approval test", + notes: "Use for UI work — components, layout and design tokens.", + }); + await expectAgentProfile(page, { + name: "UI work", + tags: [MOCK_PROVIDER_LABEL, "Ten second stream", "Approval test", "High"], + notes: "Use for UI work — components, layout and design tokens.", + }); + }); + + await test.step("editing to a model without thinking levels drops the level", async () => { + await editAgentProfile(page, "UI work", { + model: "One minute stream", + notes: "Now for quick checks.", + }); + await expectAgentProfile(page, { + name: "UI work", + tags: [MOCK_PROVIDER_LABEL, "One minute stream", "Approval test"], + notes: "Now for quick checks.", + }); + await expectAgentProfileTagsGone(page, { + name: "UI work", + tags: ["Ten second stream", "High"], + }); + }); + + await test.step("the edit form reopens on what was stored", async () => { + await expectAgentProfileForm(page, "UI work", { + provider: MOCK_PROVIDER_LABEL, + model: "One minute stream", + mode: "Approval test", + notes: "Now for quick checks.", + }); + }); + + await test.step("a second profile lands below the first", async () => { + await createAgentProfile(page, { + name: "Deep review", + provider: MOCK_PROVIDER_LABEL, + model: "Five minute stream", + }); + await expectAgentProfileOrder(page, ["UI work", "Deep review"]); + }); + + await test.step("reorder puts the second profile on top", async () => { + await moveAgentProfileUp(page, "Deep review"); + await expectAgentProfileOrder(page, ["Deep review", "UI work"]); + }); + + await test.step("removing a profile confirms by name", async () => { + await removeAgentProfile(page, "UI work"); + await expectAgentProfileOrder(page, ["Deep review"]); + }); + + await test.step("removing the last profile returns the empty state", async () => { + await removeAgentProfile(page, "Deep review"); + await expectNoAgentProfiles(page); + }); + } finally { + await seed.restore(); + } + }); +}); diff --git a/packages/app/e2e/browser/model-search-across-providers.spec.ts b/packages/app/e2e/browser/model-search-across-providers.spec.ts new file mode 100644 index 000000000..4a4ab31ea --- /dev/null +++ b/packages/app/e2e/browser/model-search-across-providers.spec.ts @@ -0,0 +1,160 @@ +import { test } from "../support/fixtures"; +import { + closeModelPicker, + expectModelSearchEmptyState, + expectModelPickerHeight, + expectModelPickerWidth, + expectModelSearchResult, + expectPinnedProfilesHidden, + openModelPicker, + readModelPickerHeight, + readModelPickerWidth, + searchAllModels, + seedAgentProfiles, + seedModelProvider, + expectSearchResultsVirtualized, +} from "../support/helpers/agent-profiles"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; + +const MOCK_PROVIDER_LABEL = "Mock Load Test"; + +// Its fast model deliberately carries the same label as the mock provider's, so +// the only thing telling the two result rows apart is the provider name. +const STUDIO = { + id: "mock-studio", + label: "Mock Studio", + models: [ + { id: "studio-fast", label: "Ten second stream", description: "Studio quick pass" }, + { id: "studio-deep", label: "Studio deep think", description: "Studio long pass" }, + ], +}; + +// The cross-provider search lives on the picker's root view, and a profile is +// what keeps the picker opening there. +const ROOT_VIEW_PROFILE = { + id: "agent_profile_e2e_search_root", + name: "Search anchor", + provider: "mock", +}; + +const LARGE_CATALOG_SIZE = 200; +const LARGE_CATALOG = { + id: "mock-bulk", + label: "Mock Bulk", + models: Array.from({ length: LARGE_CATALOG_SIZE }, (_, index) => ({ + id: `bulk-${index}`, + label: `Bulk model ${index}`, + description: `Bulk search result ${index}`, + })), +}; + +test.describe("Cross-provider model search", () => { + test("one query over the picker root reaches every provider and names each result's provider", async ({ + page, + }) => { + const provider = await seedModelProvider(STUDIO); + const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]); + const workspace = await seedWorkspace({ repoPrefix: "model-search-providers-" }); + + try { + await test.step("open a draft composer that can reach every provider", async () => { + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await openModelPicker(page); + }); + const restingWidth = await readModelPickerWidth(page); + + await test.step("one query returns the same model label from two providers", async () => { + await searchAllModels(page, "ten second stream"); + await expectModelSearchResult(page, { + provider: "mock", + modelId: "ten-second-stream", + modelLabel: "Ten second stream", + providerLabel: MOCK_PROVIDER_LABEL, + }); + await expectModelSearchResult(page, { + provider: STUDIO.id, + modelId: "studio-fast", + modelLabel: "Ten second stream", + providerLabel: STUDIO.label, + }); + await expectModelPickerWidth(page, restingWidth); + }); + + await test.step("searching by provider name reaches that provider's own models", async () => { + await searchAllModels(page, "studio deep"); + await expectModelSearchResult(page, { + provider: STUDIO.id, + modelId: "studio-deep", + modelLabel: "Studio deep think", + providerLabel: STUDIO.label, + }); + }); + + await test.step("results replace the pinned profiles", async () => { + await expectPinnedProfilesHidden(page); + }); + + // Search falls back to subsequence matching, so "no matches" needs letters + // that cannot be picked out of a model label or description in order. + await test.step("a query with no matches repeats the query back", async () => { + await searchAllModels(page, "zzz"); + await expectModelSearchEmptyState(page, "zzz"); + await closeModelPicker(page); + }); + } finally { + await workspace.cleanup(); + await profile.restore(); + await provider.restore(); + } + }); + + test("desktop search keeps its frame stable and virtualizes a host-wide catalog", async ({ + page, + }) => { + const provider = await seedModelProvider(LARGE_CATALOG); + const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]); + const workspace = await seedWorkspace({ repoPrefix: "model-search-large-catalog-" }); + + try { + await test.step("open the host-wide model picker", async () => { + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await openModelPicker(page); + }); + + const restingHeight = await readModelPickerHeight(page); + const restingWidth = await readModelPickerWidth(page); + + await test.step("a broad query renders only the visible result window", async () => { + await searchAllModels(page, "bulk model"); + await expectSearchResultsVirtualized(page, { + provider: LARGE_CATALOG.id, + total: LARGE_CATALOG_SIZE, + }); + await expectModelPickerHeight(page, restingHeight); + await expectModelPickerWidth(page, restingWidth); + }); + + await test.step("narrowing to one result does not resize the picker", async () => { + await searchAllModels(page, "bulk model 199"); + await expectModelSearchResult(page, { + provider: LARGE_CATALOG.id, + modelId: "bulk-199", + modelLabel: "Bulk model 199", + providerLabel: LARGE_CATALOG.label, + }); + await expectModelPickerHeight(page, restingHeight); + await expectModelPickerWidth(page, restingWidth); + }); + } finally { + await workspace.cleanup(); + await profile.restore(); + await provider.restore(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/agent-profiles.ts b/packages/app/e2e/support/helpers/agent-profiles.ts new file mode 100644 index 000000000..1f77369ee --- /dev/null +++ b/packages/app/e2e/support/helpers/agent-profiles.ts @@ -0,0 +1,564 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; +import { gotoAppShell, openSettings } from "./app"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { getServerId } from "./server-id"; +import { expectAppRoute } from "./route-assertions"; +import { openSettingsHost } from "./settings"; + +// ─── Daemon-side seeding ─────────────────────────────────────────────────── + +interface AgentProfilesDaemonClient { + connect(): Promise; + close(): Promise; + getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>; + patchDaemonConfig(config: { + agentProfiles?: AgentProfile[]; + providers?: Record>; + removeProviders?: string[]; + }): Promise; +} + +export interface HostSeed { + /** Puts the host's config back the way the test found it and drops the client. */ + restore(): Promise; +} + +async function connectAgentProfilesClient(): Promise { + return connectDaemonClient({ clientIdPrefix: "agent-profiles-e2e" }); +} + +/** + * Writes profiles into host config directly. Journeys that are *about* the + * settings UI create theirs through it; every other journey starts from a host + * that already has them. + */ +export async function seedAgentProfiles(profiles: AgentProfile[]): Promise { + const client = await connectAgentProfilesClient(); + const previous = (await client.getDaemonConfig()).config.agentProfiles ?? []; + await client.patchDaemonConfig({ agentProfiles: profiles }); + return { + async restore() { + await client.patchDaemonConfig({ agentProfiles: previous }).catch(() => undefined); + await client.close().catch(() => undefined); + }, + }; +} + +export async function stageLegacyFavoritesForHostMigration( + page: Page, + favorites: Array<{ provider: string; modelId: string }>, +): Promise { + const serverId = getServerId(); + await gotoAppShell(page); + await page.evaluate( + ({ favorites: storedFavorites, serverId: hostId }) => { + const preferencesKey = "@paseo:create-agent-preferences"; + const raw = localStorage.getItem(preferencesKey); + const preferences = raw ? JSON.parse(raw) : {}; + localStorage.setItem( + preferencesKey, + JSON.stringify({ ...preferences, favoriteModels: storedFavorites }), + ); + localStorage.removeItem(`@paseo:legacy-favorites-to-agent-profiles:v1:${hostId}`); + + // Preserve this upgrade-shaped storage across the fixture's next-page + // default seed; a reload is the startup boundary under test. + const nonce = localStorage.getItem("@paseo:e2e-seed-nonce"); + if (nonce) { + localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce); + } + }, + { favorites, serverId }, + ); + await page.reload(); +} + +export async function expectHostAgentProfiles(expected: AgentProfile[]): Promise { + const client = await connectAgentProfilesClient(); + try { + await expect + .poll(async () => (await client.getDaemonConfig()).config.agentProfiles ?? [], { + timeout: 30_000, + }) + .toEqual(expected); + } finally { + await client.close().catch(() => undefined); + } +} + +export interface SeededProviderModel { + id: string; + label: string; + description: string; +} + +/** + * Registers a second provider that reports a fixed catalog. The E2E daemon has + * exactly one provider with models (`mock`), so nothing cross-provider can be + * proven without one more. + * + * `extends: "mock"` is rejected — the config validator only accepts the six + * shipped providers plus `acp`. Extending `claude` with a replacement `models` + * list is what avoids running anything: replacement models skip catalog + * discovery, and Claude's static modes mean the provider never spawns. The + * command only has to resolve for the availability probe, hence `node`. + */ +export async function seedModelProvider(input: { + id: string; + label: string; + models: SeededProviderModel[]; +}): Promise { + const client = await connectAgentProfilesClient(); + await client.patchDaemonConfig({ + providers: { + [input.id]: { + extends: "claude", + label: input.label, + description: `${input.label} test provider`, + enabled: true, + command: ["node"], + models: input.models, + }, + }, + }); + return { + async restore() { + await client.patchDaemonConfig({ removeProviders: [input.id] }).catch(() => undefined); + await client.close().catch(() => undefined); + }, + }; +} + +// ─── Settings: navigation ────────────────────────────────────────────────── + +/** Reach agent profiles through the same visible Settings path a person uses. */ +export async function openAgentProfileSettings(page: Page): Promise { + const serverId = getServerId(); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, serverId); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "agents")); + await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 }); +} + +export async function expectNoAgentProfiles(page: Page): Promise { + const card = page.getByTestId("agent-profiles-card"); + await expect(card.getByTestId("agent-profiles-empty")).toBeVisible(); + await expect(card.getByRole("button", { name: "New profile", exact: true })).toBeVisible(); +} + +// ─── Settings: the profile list ──────────────────────────────────────────── + +export function agentProfileRow(page: Page, name: string): Locator { + return page.locator('[data-testid^="agent-profile-row-"]').filter({ hasText: name }); +} + +export async function expectAgentProfile( + page: Page, + expected: { name: string; tags: string[]; notes?: string }, +): Promise { + const row = agentProfileRow(page, expected.name); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(expected.tags.join(" · "), { exact: true })).toBeVisible(); + if (expected.notes !== undefined) { + await expect(row.getByText(expected.notes, { exact: true })).toBeVisible(); + } +} + +export async function expectAgentProfileTagsGone( + page: Page, + input: { name: string; tags: string[] }, +): Promise { + const row = agentProfileRow(page, input.name); + for (const tag of input.tags) { + await expect(row.getByText(tag, { exact: true })).toHaveCount(0); + } +} + +/** Order is what the user sees, so it is measured on screen rather than in the DOM. */ +export async function expectAgentProfileOrder(page: Page, names: string[]): Promise { + await expect + .poll( + async () => { + const measured = await Promise.all( + names.map(async (name) => ({ name, top: await boxTop(agentProfileRow(page, name)) })), + ); + return measured.sort((a, b) => a.top - b.top).map((entry) => entry.name); + }, + { timeout: 15_000 }, + ) + .toEqual(names); +} + +export async function moveAgentProfileUp(page: Page, name: string): Promise { + await agentProfileRow(page, name).getByRole("button", { name: "Move up", exact: true }).click(); +} + +export async function moveAgentProfileDown(page: Page, name: string): Promise { + await agentProfileRow(page, name).getByRole("button", { name: "Move down", exact: true }).click(); +} + +/** + * The remove confirmation is `window.confirm` on browser web, so the dialog is + * consumed here — and its text is asserted, since naming the profile is the + * whole point of confirming. + */ +export async function removeAgentProfile(page: Page, name: string): Promise { + const dialogMessage = page.waitForEvent("dialog").then(async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + return message; + }); + await agentProfileRow(page, name).getByRole("button", { name: "Remove", exact: true }).click(); + expect(await dialogMessage).toContain(`Remove "${name}"?`); + await expect(agentProfileRow(page, name)).toHaveCount(0, { timeout: 30_000 }); +} + +// ─── Settings: the edit modal ────────────────────────────────────────────── + +export interface AgentProfileDraft { + name?: string; + provider?: string; + model?: string; + thinking?: string; + mode?: string; + notes?: string; +} + +function editModal(page: Page): Locator { + return page.getByTestId("agent-profile-edit-modal"); +} + +async function chooseSelectFieldOption( + page: Page, + input: { field: "provider" | "model" | "mode" | "thinking"; option: string }, +): Promise { + await editModal(page).getByTestId(`agent-profile-${input.field}-trigger`).click(); + const option = page + .getByTestId("combobox-desktop-container") + .getByRole("button", { name: input.option, exact: true }); + await expect(option).toBeVisible({ timeout: 30_000 }); + await option.click(); + await expect( + page.getByRole("button", { name: `${capitalize(input.field)} (${input.option})` }), + ).toBeVisible({ timeout: 30_000 }); +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promise { + const modal = editModal(page); + if (draft.name !== undefined) { + await modal.getByRole("textbox", { name: "Name", exact: true }).fill(draft.name); + } + // Provider first: it resets every provider-scoped field below it. + if (draft.provider !== undefined) { + await chooseSelectFieldOption(page, { field: "provider", option: draft.provider }); + } + // Thinking after model: thinking levels hang off the model, and switching the + // model drops a level the new one does not offer. + if (draft.model !== undefined) { + await chooseSelectFieldOption(page, { field: "model", option: draft.model }); + } + if (draft.thinking !== undefined) { + await chooseSelectFieldOption(page, { field: "thinking", option: draft.thinking }); + } + if (draft.mode !== undefined) { + await chooseSelectFieldOption(page, { field: "mode", option: draft.mode }); + } + if (draft.notes !== undefined) { + await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes); + } +} + +async function saveAgentProfile(page: Page): Promise { + await editModal(page).getByRole("button", { name: "Save", exact: true }).click(); + await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 }); +} + +export async function createAgentProfile(page: Page, draft: AgentProfileDraft): Promise { + await page.getByRole("button", { name: "Add agent profile", exact: true }).click(); + await expect(editModal(page)).toBeVisible({ timeout: 30_000 }); + await fillAgentProfileForm(page, draft); + await saveAgentProfile(page); +} + +export async function createAgentProfileFromEmptyState( + page: Page, + draft: AgentProfileDraft, +): Promise { + await page + .getByTestId("agent-profiles-card") + .getByRole("button", { name: "New profile", exact: true }) + .click(); + await expect(editModal(page)).toBeVisible({ timeout: 30_000 }); + await fillAgentProfileForm(page, draft); + await saveAgentProfile(page); +} + +export async function editAgentProfile( + page: Page, + name: string, + changes: AgentProfileDraft, +): Promise { + await agentProfileRow(page, name) + .getByRole("button", { name: "Edit profile", exact: true }) + .click(); + await expect(editModal(page)).toBeVisible({ timeout: 30_000 }); + await fillAgentProfileForm(page, changes); + await saveAgentProfile(page); +} + +/** Opens the modal for a profile purely to read back what it stored. */ +export async function expectAgentProfileForm( + page: Page, + name: string, + expected: { provider?: string; model?: string; mode?: string; notes?: string }, +): Promise { + await agentProfileRow(page, name) + .getByRole("button", { name: "Edit profile", exact: true }) + .click(); + const modal = editModal(page); + await expect(modal).toBeVisible({ timeout: 30_000 }); + await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name); + for (const [field, value] of Object.entries(expected)) { + if (field === "notes") { + await expect( + modal.getByRole("textbox", { name: "Notes for agents", exact: true }), + ).toHaveValue(value); + continue; + } + await expect( + modal.getByRole("button", { name: `${capitalize(field)} (${value})` }), + ).toBeVisible({ timeout: 30_000 }); + } + await modal.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 }); +} + +// ─── The model picker ────────────────────────────────────────────────────── + +/** Desktop web renders the model browser inside the combobox popover. */ +function pickerViewport(page: Page): Locator { + return page.getByTestId("combobox-desktop-container"); +} + +export async function openModelPicker(page: Page): Promise { + await page.getByTestId("combined-model-selector").filter({ visible: true }).first().click(); + await expect(pickerViewport(page)).toBeVisible({ timeout: 30_000 }); +} + +export async function closeModelPicker(page: Page): Promise { + await page.keyboard.press("Escape"); + await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 }); +} + +export function profilePickerRow(page: Page, name: string): Locator { + return pickerViewport(page) + .locator('[data-testid^="model-profile-row-"]') + .filter({ hasText: name }); +} + +/** + * Profiles are pinned above the provider list, so "pinned" is an ordering claim: + * the profile row's top edge sits above every provider row's. + */ +export async function expectProfilePinnedAboveProviders( + page: Page, + input: { name: string; summary: string }, +): Promise { + const viewport = pickerViewport(page); + await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 }); + const row = profilePickerRow(page, input.name); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(input.summary, { exact: true })).toBeVisible(); + + const profileTop = await boxTop(row); + const providerRows = viewport.locator('[data-testid^="model-provider-"]'); + await expect(providerRows.first()).toBeVisible(); + const providerCount = await providerRows.count(); + for (let index = 0; index < providerCount; index += 1) { + expect(profileTop).toBeLessThan(await boxTop(providerRows.nth(index))); + } +} + +export async function expectProfileVisibleForProvider( + page: Page, + input: { name: string; summary: string }, +): Promise { + const viewport = pickerViewport(page); + await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 }); + const row = profilePickerRow(page, input.name); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(input.summary, { exact: true })).toBeVisible(); +} + +export async function expectProfileEditIsPencilOnly(page: Page): Promise { + const viewport = pickerViewport(page); + await expect( + viewport.getByRole("button", { name: "Edit agent profiles", exact: true }), + ).toBeVisible(); + await expect(viewport.getByText("Edit", { exact: true })).toHaveCount(0); +} + +export async function expectAgentProfilesEmptyPrompt(page: Page): Promise { + const viewport = pickerViewport(page); + await expect(viewport.getByText("Profiles", { exact: true })).toHaveCount(0); + await expect( + viewport.getByRole("button", { name: "Edit agent profiles", exact: true }), + ).toHaveCount(0); + await expect(viewport.getByRole("button", { name: "Create profile", exact: true })).toBeVisible({ + timeout: 30_000, + }); +} + +export async function expectProfileEditTooltip(page: Page): Promise { + const edit = pickerViewport(page).getByRole("button", { + name: "Edit agent profiles", + exact: true, + }); + await edit.hover(); + await expect(page.getByText("Edit agent profiles", { exact: true })).toBeVisible(); +} + +export async function openAgentProfilesFromEmptyPrompt(page: Page): Promise { + await pickerViewport(page).getByRole("button", { name: "Create profile", exact: true }).click(); + await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 }); +} + +async function boxTop(locator: Locator): Promise { + const box = await locator.boundingBox(); + if (!box) { + throw new Error("Expected a laid-out element to measure"); + } + return box.y; +} + +export async function applyProfileFromPicker(page: Page, name: string): Promise { + await profilePickerRow(page, name).click(); + await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 }); +} + +/** + * Applying a profile materializes it and forgets it: nothing in the root view + * claims selection. Model rows do carry `aria-selected`, so a count of zero here + * is the absence of a checkmark, not the absence of the attribute everywhere. + */ +export async function expectNothingSelectedInPickerRoot(page: Page): Promise { + await expect(pickerViewport(page).locator("[aria-selected]")).toHaveCount(0); +} + +export async function expectAgentProfilesEditShortcut(page: Page): Promise { + await expect( + pickerViewport(page).getByRole("button", { name: "Edit agent profiles", exact: true }), + ).toBeVisible({ timeout: 30_000 }); +} + +export async function drillIntoProvider(page: Page, providerId: string): Promise { + await pickerViewport(page).getByTestId(`model-provider-${providerId}`).click(); +} + +export async function expectModelRowSelected( + page: Page, + input: { provider: string; modelId: string }, +): Promise { + await expect( + pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`), + ).toHaveAttribute("aria-selected", "true", { timeout: 30_000 }); +} + +// ─── Cross-provider model search ─────────────────────────────────────────── + +export async function searchAllModels(page: Page, query: string): Promise { + const input = page.getByTestId("model-search-all-input"); + await expect(input).toBeVisible({ timeout: 30_000 }); + await input.fill(query); +} + +export async function expectModelSearchResult( + page: Page, + expected: { provider: string; modelId: string; providerLabel: string; modelLabel: string }, +): Promise { + const row = pickerViewport(page).getByTestId( + `model-row-${expected.provider}-${expected.modelId}`, + ); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(expected.modelLabel, { exact: true })).toBeVisible(); + await expect( + row.getByText(new RegExp(`^${escapeForRegex(expected.providerLabel)}\\b`)), + ).toBeVisible(); +} + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Search results replace the whole root view, pinned profiles included. */ +export async function expectPinnedProfilesHidden(page: Page): Promise { + await expect(pickerViewport(page).locator('[data-testid^="model-profile-row-"]')).toHaveCount(0); +} + +export async function expectModelSearchEmptyState(page: Page, query: string): Promise { + const empty = page.getByTestId("model-search-empty"); + await expect(empty).toBeVisible({ timeout: 30_000 }); + await expect(empty.getByText(`No models match "${query}"`, { exact: true })).toBeVisible(); +} + +export async function readModelPickerHeight(page: Page): Promise { + const box = await pickerViewport(page).boundingBox(); + if (!box) { + throw new Error("Expected the model picker to be laid out"); + } + return box.height; +} + +export async function readModelPickerWidth(page: Page): Promise { + const box = await pickerViewport(page).boundingBox(); + if (!box) { + throw new Error("Expected the model picker to be laid out"); + } + return box.width; +} + +export async function expectModelPickerHeight(page: Page, expectedHeight: number): Promise { + await expect.poll(() => readModelPickerHeight(page)).toBe(expectedHeight); +} + +export async function expectModelPickerWidth(page: Page, expectedWidth: number): Promise { + await expect.poll(() => readModelPickerWidth(page)).toBe(expectedWidth); +} + +export async function expectSearchResultsVirtualized( + page: Page, + input: { provider: string; total: number }, +): Promise { + const mountedRows = pickerViewport(page).locator(`[data-testid^="model-row-${input.provider}-"]`); + await expect.poll(() => mountedRows.count()).toBeGreaterThan(0); + expect(await mountedRows.count()).toBeLessThan(input.total); +} + +// ─── Composer agent controls ─────────────────────────────────────────────── + +export async function expectComposerModel(page: Page, modelLabel: string): Promise { + await expect( + page.getByRole("button", { name: `Select model (${modelLabel})`, exact: true }), + ).toBeVisible({ timeout: 30_000 }); +} + +export async function expectComposerMode(page: Page, modeLabel: string): Promise { + await expect( + page.getByRole("button", { name: `Select agent mode (${modeLabel})`, exact: true }).first(), + ).toBeVisible({ timeout: 30_000 }); +} + +/** The composer never shows the profile's name — a profile is not a selection. */ +export async function expectComposerDoesNotName(page: Page, profileName: string): Promise { + await expect( + page.locator('[data-testid="message-input-root"]:visible').getByText(profileName), + ).toHaveCount(0); +} diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 6f03dfbdc..d87db35ef 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -40,6 +40,7 @@ type HostSection = | "workspaces" | "providers" | "usage" + | "terminals" | "host"; export async function openSettingsSection(page: Page, section: SettingsSection): Promise { diff --git a/packages/app/src/agent-profiles/index.ts b/packages/app/src/agent-profiles/index.ts new file mode 100644 index 000000000..a00ad7617 --- /dev/null +++ b/packages/app/src/agent-profiles/index.ts @@ -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"; diff --git a/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx b/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx new file mode 100644 index 000000000..e9795614e --- /dev/null +++ b/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx @@ -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 = { + 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 { color: string }> = (() => { + const byColor = {} as Record { 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 ; +} diff --git a/packages/app/src/agent-profiles/internal/capabilities.test.ts b/packages/app/src/agent-profiles/internal/capabilities.test.ts new file mode 100644 index 000000000..7708e965a --- /dev/null +++ b/packages/app/src/agent-profiles/internal/capabilities.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/agent-profiles/internal/capabilities.ts b/packages/app/src/agent-profiles/internal/capabilities.ts new file mode 100644 index 000000000..caf5fb222 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/capabilities.ts @@ -0,0 +1,8 @@ +interface AgentProfileCapabilities { + agentProfiles?: boolean; + agentConfigApply?: boolean; +} + +export function supportsAgentProfiles(features: AgentProfileCapabilities | undefined): boolean { + return features?.agentProfiles === true && features.agentConfigApply === true; +} diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts new file mode 100644 index 000000000..b228c32f3 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts @@ -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 { + 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"); + }); +}); diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.ts b/packages/app/src/agent-profiles/internal/materialize-profile.ts new file mode 100644 index 000000000..bdedf604a --- /dev/null +++ b/packages/app/src/agent-profiles/internal/materialize-profile.ts @@ -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; +} + +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 } + : {}), + }; +} diff --git a/packages/app/src/agent-profiles/internal/profile-appearance.ts b/packages/app/src/agent-profiles/internal/profile-appearance.ts new file mode 100644 index 000000000..3602f7e51 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/profile-appearance.ts @@ -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"; +} diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts new file mode 100644 index 000000000..0b5fe6253 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts @@ -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[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" }), + ); + }); +}); diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.ts b/packages/app/src/agent-profiles/internal/profile-form-model.ts new file mode 100644 index 000000000..ba3a9e522 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/profile-form-model.ts @@ -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` 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; + + 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 })), + }; +} diff --git a/packages/app/src/agent-profiles/internal/profile-summary.ts b/packages/app/src/agent-profiles/internal/profile-summary.ts new file mode 100644 index 000000000..a0507e6b9 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/profile-summary.ts @@ -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; +} diff --git a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts new file mode 100644 index 000000000..38799d2a2 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts @@ -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( + () => + 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], + ); +} diff --git a/packages/app/src/agent-profiles/internal/use-agent-profiles.ts b/packages/app/src/agent-profiles/internal/use-agent-profiles.ts new file mode 100644 index 000000000..fe5105b18 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/use-agent-profiles.ts @@ -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; +} + +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, + }; +} diff --git a/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts b/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts new file mode 100644 index 000000000..fb78320ca --- /dev/null +++ b/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts @@ -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]); +} diff --git a/packages/app/src/agent-profiles/internal/use-profile-form-model.ts b/packages/app/src/agent-profiles/internal/use-profile-form-model.ts new file mode 100644 index 000000000..f800252be --- /dev/null +++ b/packages/app/src/agent-profiles/internal/use-profile-form-model.ts @@ -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); +} diff --git a/packages/app/src/agent-profiles/migration/index.test.ts b/packages/app/src/agent-profiles/migration/index.test.ts new file mode 100644 index 000000000..cf40001e7 --- /dev/null +++ b/packages/app/src/agent-profiles/migration/index.test.ts @@ -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(); + + constructor(initial: Record = {}) { + for (const [key, value] of Object.entries(initial)) { + this.values.set(key, JSON.stringify(value)); + } + } + + async getItem(key: string): Promise { + return this.values.get(key) ?? null; + } + + async setItem(key: string, value: string): Promise { + 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"]); + }); +}); diff --git a/packages/app/src/agent-profiles/migration/index.ts b/packages/app/src/agent-profiles/migration/index.ts new file mode 100644 index 000000000..964ef248f --- /dev/null +++ b/packages/app/src/agent-profiles/migration/index.ts @@ -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["favoriteModels"] +>[number]; + +export interface LegacyFavoriteMigrationStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} + +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): 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(); + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readProviderCatalog( + host: LegacyFavoriteMigrationHost, + favorites: readonly LegacyFavorite[], +): Promise { + 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>(); + + constructor(private readonly storage: LegacyFavoriteMigrationStorage) {} + + migrateHost(serverId: string, host: LegacyFavoriteMigrationHost): Promise { + 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 { + 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); diff --git a/packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx b/packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx new file mode 100644 index 000000000..9d958daff --- /dev/null +++ b/packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx @@ -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 ( + + + + ); +} + +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 ( + + + {selected ? ( + + ) : null} + + + ); +} + +/** + * 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(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 ( + + + + + + + + + + + {AGENT_PROFILE_COLORS.map((option) => ( + + ))} + + + + {AGENT_PROFILE_ICON_KEYS.map((key) => ( + + ))} + + + + + ); +} + +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) { + 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; diff --git a/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx new file mode 100644 index 000000000..c4e63a847 --- /dev/null +++ b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx @@ -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; +} + +/** + * 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(() => + 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 ( + + ); +} + +function toSelectOptions(options: AgentProfileFormOption[]): SelectFieldOption[] { + 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( + () => ({ + 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 ( + + + + + + + + + + + + + + 6} + title={t("settings.host.agentProfiles.providerLabel")} + size={controlSize} + testID="agent-profile-provider-field" + triggerTestID="agent-profile-provider-trigger" + /> + + {state.disclosure.showModelField ? ( + 6} + title={t("settings.host.agentProfiles.modelLabel")} + size={controlSize} + testID="agent-profile-model-field" + triggerTestID="agent-profile-model-trigger" + /> + ) : null} + + {state.disclosure.showModeField ? ( + 6} + title={t("settings.host.agentProfiles.modeLabel")} + size={controlSize} + testID="agent-profile-mode-field" + triggerTestID="agent-profile-mode-trigger" + /> + ) : null} + + {state.disclosure.showThinkingField ? ( + 6} + title={t("settings.host.agentProfiles.thinkingLabel")} + size={controlSize} + testID="agent-profile-thinking-field" + triggerTestID="agent-profile-thinking-trigger" + /> + ) : null} + + {state.disclosure.showFeaturesField ? ( + + + {state.features.map((feature, index) => ( + + ))} + + + ) : null} + + + + + + {state.submitError ? ( + + {state.submitError} + + ) : null} + + + + + + + + ); +} + +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[]>( + () => + 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(() => { + 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 ( + + + + ); + } + + return ( + + + + {feature.label} + + {feature.description ? ( + + {feature.description} + + ) : null} + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/agent-profiles/settings/agent-profile-row.tsx b/packages/app/src/agent-profiles/settings/agent-profile-row.tsx new file mode 100644 index 000000000..4a766ec0f --- /dev/null +++ b/packages/app/src/agent-profiles/settings/agent-profile-row.tsx @@ -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 = ; +const moveDownIcon = ; +const editIcon = ; +const removeIcon = ; + +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 ( + + + + + + + + {profile.name} + + + {summary} + + + {profile.notes ? ( + + + + {profile.notes} + + + ) : null} + + + + + )} + + + + + + ); +} + +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", + }, +})); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 13fd94904..431d1cc66 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -72,6 +72,7 @@ import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-rout import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { AgentNavigationListener } from "@/desktop/agent-navigation"; +import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { getDesktopHost } from "@/desktop/host"; @@ -101,6 +102,7 @@ import { useHostRegistryLoaded, useHostMutations, useHostRuntimeClient, + useHostRuntimeIsConnected, useHosts, } from "@/runtime/host-runtime"; import { getDaemonStartService } from "@/runtime/daemon-start-service"; @@ -108,6 +110,7 @@ import { applyAppearance } from "@/screens/settings/appearance/apply-appearance" import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; import { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme"; +import { useSessionStore } from "@/stores/session-store"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; @@ -266,11 +269,33 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { return ( - {null} + ); } +function LegacyFavoriteProfileMigrationBootstrap({ + serverId, + client, +}: { + serverId: string; + client: NonNullable>; +}) { + const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); + const isConnected = useHostRuntimeIsConnected(serverId); + + useEffect(() => { + if (!serverInfo || !isConnected) { + return; + } + void legacyFavoriteProfileMigration.migrateHost(serverId, client).catch((error) => { + console.warn("[AgentProfiles] Failed to migrate legacy favourites", error); + }); + }, [client, isConnected, serverId, serverInfo]); + + return null; +} + function HostSessionManager() { const hosts = useHosts(); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index b689bdc6a..94191f4eb 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -128,9 +128,13 @@ const styles = StyleSheet.create((theme) => ({ // Horizontal padding matches the model picker's row indent: the picker uses // children mode (desktopChildrenScrollContent, no scroll padding), so the // row content starts at item.paddingHorizontal = spacing[3]. + // The search row below owns the gap under the title: the input already + // carries its own vertical padding, so a paddingBottom here would stack on + // top of two more and push the title far off the field. inlineHeaderRow: { paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], + paddingTop: theme.spacing[2], + paddingBottom: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 179e53132..5053e138d 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -3,6 +3,7 @@ import { Pressable, Text, View, type PressableStateCallbackType } from "react-na import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { AgentProfilePicker } from "@/agent-profiles"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox"; @@ -12,7 +13,6 @@ import type { ProviderSelectorProvider } from "@/provider-selection/provider-sel import { ICON_SIZE, type Theme } from "@/styles/theme"; const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = []; -const EMPTY_FAVORITE_KEYS = new Set(); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedMapping = (theme: Theme) => ({ @@ -27,8 +27,9 @@ interface CombinedModelSelectorProps { selectedModel: string; onSelect: (provider: AgentProvider, modelId: string) => void; isLoading: boolean; - favoriteKeys?: Set; - onToggleFavorite?: (provider: string, modelId: string) => void; + profiles?: AgentProfilePicker | null; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; renderTrigger?: (input: { selectedModelLabel: string; onPress: () => void; @@ -66,8 +67,9 @@ export function CombinedModelSelector({ selectedModel, onSelect, isLoading, - favoriteKeys = EMPTY_FAVORITE_KEYS, - onToggleFavorite, + profiles = null, + onApplyProfile, + onEditProfiles, renderTrigger, onOpen, onClose, @@ -89,7 +91,7 @@ export function CombinedModelSelector({ selectedProvider, selectedModel, isLoading, - favoriteKeys, + profiles, serverId, }); const { prepareToOpen, reset } = browser; @@ -153,13 +155,28 @@ export function CombinedModelSelector({ [disabled, isOpen, renderTrigger, triggerFill], ); + const handleApplyProfile = useCallback( + (profileId: string) => { + onApplyProfile?.(profileId); + handleOpenChange(false); + }, + [handleOpenChange, onApplyProfile], + ); + + const handleEditProfiles = useCallback(() => { + handleOpenChange(false); + onEditProfiles?.(); + }, [handleOpenChange, onEditProfiles]); + const selectorBody = isContentReady ? ( ) : ( @@ -230,7 +247,9 @@ export function CombinedModelSelector({ anchorRef={anchorRef} desktopPlacement={desktopPlacement} desktopMinWidth={desktopMinWidth} + desktopLockWidth desktopFixedHeight={browser.desktopFixedHeight} + desktopChildrenScrollEnabled={false} header={browser.header} mobileChildrenScrollEnabled={!browser.isProviderView || !isNative} mobileChildrenContentContainerStyle={styles.mobileBrowserContent} diff --git a/packages/app/src/components/model-browser-view.test.ts b/packages/app/src/components/model-browser-view.test.ts index 719832b09..edb499a4a 100644 --- a/packages/app/src/components/model-browser-view.test.ts +++ b/packages/app/src/components/model-browser-view.test.ts @@ -1,12 +1,35 @@ import { describe, expect, it } from "vitest"; -import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; -import { resolveInitialModelBrowserView } from "./model-browser-view"; +import type { + ProviderSelectionModelRow, + ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; +import { resolveInitialModelBrowserView, resolveModelBrowserAllView } from "./model-browser-view"; -function provider(id: string, label: string): ProviderSelectorProvider { +function provider( + id: string, + label: string, + rows: ProviderSelectionModelRow[] = [], +): ProviderSelectorProvider { return { id, label, - modelSelection: { kind: "models", rows: [] }, + modelSelection: { kind: "models", rows }, + }; +} + +function modelRow( + providerId: string, + providerLabel: string, + modelId: string, + modelLabel: string, +): ProviderSelectionModelRow { + return { + favoriteKey: `${providerId}:${modelId}`, + provider: providerId, + providerLabel, + modelId, + modelLabel, + description: modelId, }; } @@ -20,30 +43,111 @@ describe("model browser initial view", () => { providers: [pi], selectedProvider: "", selectedModel: "", - favoriteKeys: new Set(), + hasProfiles: false, }), ).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" }); }); - it("opens the selected provider when its model is not a favorite", () => { + it("opens the selected provider when there is one", () => { expect( resolveInitialModelBrowserView({ providers: [codex, pi], selectedProvider: "pi", selectedModel: "pi-pro", - favoriteKeys: new Set(), + hasProfiles: false, }), ).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" }); }); - it("opens the provider overview when the selected model is a favorite", () => { + it("opens the root so pinned profiles are reachable", () => { expect( resolveInitialModelBrowserView({ providers: [codex, pi], selectedProvider: "pi", selectedModel: "pi-pro", - favoriteKeys: new Set(["pi:pi-pro"]), + hasProfiles: true, + }), + ).toEqual({ kind: "all" }); + }); + + it("keeps the root even when a single provider would otherwise be skipped", () => { + expect( + resolveInitialModelBrowserView({ + providers: [pi], + selectedProvider: "pi", + selectedModel: "pi-pro", + hasProfiles: true, + }), + ).toEqual({ kind: "all" }); + }); + + it("falls back to the root when the selected provider is gone", () => { + expect( + resolveInitialModelBrowserView({ + providers: [codex, pi], + selectedProvider: "gemini", + selectedModel: "gemini-3", + hasProfiles: false, }), ).toEqual({ kind: "all" }); }); }); + +describe("model browser all view", () => { + const claude = provider("claude", "Claude Code", [ + modelRow("claude", "Claude Code", "opus-5", "Opus 5"), + modelRow("claude", "Claude Code", "sonnet-4.6", "Sonnet 4.6"), + ]); + const copilot = provider("copilot", "Copilot", [ + modelRow("copilot", "Copilot", "claude-opus-5", "Opus 5"), + ]); + const codex = provider("codex", "Codex", [modelRow("codex", "Codex", "gpt-5.4", "GPT-5.4")]); + const providers = [claude, copilot, codex]; + + it("browses providers while the query is empty", () => { + expect(resolveModelBrowserAllView({ providers, normalizedQuery: "" })).toEqual({ + kind: "browse", + }); + }); + + it("ranks the same model label across every provider that offers it", () => { + const view = resolveModelBrowserAllView({ providers, normalizedQuery: "opus" }); + + expect(view.kind).toBe("searchResults"); + expect(view.kind === "searchResults" ? view.rows.map((row) => row.favoriteKey) : []).toEqual([ + "claude:opus-5", + "copilot:claude-opus-5", + ]); + }); + + it("matches models by their provider label", () => { + const view = resolveModelBrowserAllView({ providers, normalizedQuery: "codex" }); + + expect(view.kind === "searchResults" ? view.rows.map((row) => row.modelId) : []).toEqual([ + "gpt-5.4", + ]); + }); + + it("reports no matches instead of falling back to the provider list", () => { + expect(resolveModelBrowserAllView({ providers, normalizedQuery: "zzzz" })).toEqual({ + kind: "noSearchMatches", + }); + }); + + it("ignores providers that are still loading or errored", () => { + const loading: ProviderSelectorProvider = { + id: "opencode", + label: "OpenCode", + modelSelection: { kind: "loading" }, + }; + const failed: ProviderSelectorProvider = { + id: "pi", + label: "Pi", + modelSelection: { kind: "error", message: "unavailable" }, + }; + + expect( + resolveModelBrowserAllView({ providers: [loading, failed], normalizedQuery: "opus" }), + ).toEqual({ kind: "noSearchMatches" }); + }); +}); diff --git a/packages/app/src/components/model-browser-view.ts b/packages/app/src/components/model-browser-view.ts index 9452b956d..acdde18b0 100644 --- a/packages/app/src/components/model-browser-view.ts +++ b/packages/app/src/components/model-browser-view.ts @@ -1,20 +1,57 @@ -import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; +import { + filterAndRankModelRows, + getAllProviderModelRows, + type ProviderSelectionModelRow, + type ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; export type ModelBrowserView = | { kind: "all" } | { kind: "provider"; providerId: string; providerLabel: string }; +/** What the root view shows: the provider drill-down, or ranked cross-provider results. */ +export type ModelBrowserAllView = + | { kind: "browse" } + | { kind: "searchResults"; rows: ProviderSelectionModelRow[] } + | { kind: "noSearchMatches" }; + +export function resolveModelBrowserAllView({ + providers, + normalizedQuery, +}: { + providers: ProviderSelectorProvider[]; + normalizedQuery: string; +}): ModelBrowserAllView { + if (!normalizedQuery) { + return { kind: "browse" }; + } + const rows = filterAndRankModelRows(getAllProviderModelRows(providers), normalizedQuery); + if (rows.length === 0) { + return { kind: "noSearchMatches" }; + } + return { kind: "searchResults", rows }; +} + +/** + * Where the picker lands when it opens. Agent profiles live on the root view, so + * a host that has any profile always opens there — including the single-provider + * case, which would otherwise skip the root entirely and hide them. + */ export function resolveInitialModelBrowserView({ providers, selectedProvider, selectedModel, - favoriteKeys, + hasProfiles, }: { providers: ProviderSelectorProvider[]; selectedProvider: string; selectedModel: string; - favoriteKeys: Set; + hasProfiles: boolean; }): ModelBrowserView { + if (hasProfiles) { + return { kind: "all" }; + } + const singleProvider = providers.length === 1 ? providers[0] : undefined; if (singleProvider) { return { @@ -24,12 +61,7 @@ export function resolveInitialModelBrowserView({ }; } - const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`; - const shouldOpenSelectedProvider = - selectedProvider.length > 0 && - selectedModel.length > 0 && - !favoriteKeys.has(selectedFavoriteKey); - if (shouldOpenSelectedProvider) { + if (selectedProvider.length > 0 && selectedModel.length > 0) { const provider = providers.find((entry) => entry.id === selectedProvider); if (provider) { return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index e9c34b0ec..e00a0328b 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -16,15 +16,30 @@ import { import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { AlertTriangle, Check, ChevronRight, Search, Settings, Star } from "lucide-react-native"; +import { + AlertTriangle, + Check, + ChevronRight, + Pencil, + Plus, + Search, + Settings, +} from "lucide-react-native"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { + AgentProfileGlyph, + type AgentProfilePicker, + type AgentProfilePickerRow as AgentProfilePickerRowModel, +} from "@/agent-profiles"; import type { SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getProviderIcon } from "@/components/provider-icons"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { + buildProviderQualifiedDescription, buildSelectedTriggerLabel, filterAndRankModelRows, getAllProviderModelRows, @@ -38,6 +53,7 @@ import { useCurrentOverlayLayer } from "@/lib/overlay-root"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { resolveInitialModelBrowserView, + resolveModelBrowserAllView, type ModelBrowserView, } from "@/components/model-browser-view"; @@ -50,9 +66,10 @@ const ThemedAlertTriangle = withUnistyles(AlertTriangle); const ThemedCheck = withUnistyles(Check); const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const ThemedPencil = withUnistyles(Pencil); +const ThemedPlus = withUnistyles(Plus); const ThemedSearch = withUnistyles(Search); const ThemedSettings = withUnistyles(Settings); -const ThemedStar = withUnistyles(Star); function ProviderSettingsAction({ accessibilityLabel, @@ -84,6 +101,29 @@ function ProviderSettingsAction({ ); } +function AgentProfilesEditAction({ onPress }: { onPress: () => void }) { + const { t } = useTranslation(); + return ( + + + + + + + + {t("modelSelector.editProfilesLabel")} + + + ); +} + const IndependentScrollGestureContext = createContext | null>( null, ); @@ -92,29 +132,21 @@ const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); +const foregroundExtraMutedMapping = (theme: Theme) => ({ + color: theme.colors.foregroundExtraMuted, +}); + const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({ color: disabled ? theme.colors.border : theme.colors.foregroundMuted, }); -const favoriteStarMapping = - (isFavorite: boolean, hovered: boolean) => - (theme: Theme): { color: string; fill: string } => { - const favoriteColor = theme.colors.palette.amber[500]; - if (isFavorite) { - return { color: favoriteColor, fill: favoriteColor }; - } - return { - color: hovered ? theme.colors.foregroundMuted : theme.colors.border, - fill: "transparent", - }; - }; - interface ModelBrowserInput { providers: ProviderSelectorProvider[]; selectedProvider: string; selectedModel: string; isLoading: boolean; - favoriteKeys: Set; + /** Pinned above the provider list on the root view. `null` hides the section. */ + profiles?: AgentProfilePicker | null; serverId?: string | null; } @@ -122,7 +154,7 @@ export interface ModelBrowserState { providers: ProviderSelectorProvider[]; selectedProvider: string; selectedModel: string; - favoriteKeys: Set; + profiles: AgentProfilePicker | null; view: ModelBrowserView; searchQuery: string; header: SheetHeader; @@ -138,7 +170,9 @@ export interface ModelBrowserState { interface ModelBrowserProps { state: ModelBrowserState; onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; + /** Applying a profile resolves the pick and dismisses, exactly like a model row. */ + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; onRetryProvider?: (provider: AgentProvider) => void; isRetryingProvider?: boolean; scrolling?: "sheet" | "independent"; @@ -150,7 +184,7 @@ interface ModelBrowserContentProps extends Omit; + profiles: AgentProfilePicker | null; onDrillDown: (providerId: string, providerLabel: string) => void; scrolling: "sheet" | "independent"; } @@ -177,22 +211,6 @@ function HeaderSettingsIcon({ disabled }: { disabled: boolean }) { return ; } -function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) { - const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]); - return ; -} - -function favoriteButtonStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [ - styles.favoriteButton, - Boolean(hovered) && styles.favoriteButtonHovered, - pressed && styles.favoriteButtonPressed, - ]; -} - function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) { return [ styles.rowIconButton, @@ -201,18 +219,19 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho ]; } +function countModelsInView(view: ModelBrowserView, providers: ProviderSelectorProvider[]): number { + if (view.kind === "all") { + return getAllProviderModelRows(providers).length; + } + const provider = providers.find((entry) => entry.id === view.providerId); + return provider?.modelSelection.kind === "models" ? getProviderModelRows(provider).length : 0; +} + function resolveDesktopFixedHeight( view: ModelBrowserView, providers: ProviderSelectorProvider[], -): number | undefined { - if (view.kind !== "provider") { - return undefined; - } - const provider = providers.find((entry) => entry.id === view.providerId); - if (!provider || provider.modelSelection.kind !== "models") { - return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT; - } - const modelCount = getProviderModelRows(provider).length; +): number { + const modelCount = countModelsInView(view, providers); return Math.min( Math.max( DESKTOP_PROVIDER_VIEW_MIN_HEIGHT, @@ -227,13 +246,14 @@ export function useModelBrowser({ selectedProvider, selectedModel, isLoading, - favoriteKeys, + profiles = null, serverId = null, }: ModelBrowserInput): ModelBrowserState { const { t } = useTranslation(); const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); + const hasProfiles = (profiles?.rows.length ?? 0) > 0; const initialView = useMemo( () => @@ -241,9 +261,9 @@ export function useModelBrowser({ providers, selectedProvider, selectedModel, - favoriteKeys, + hasProfiles, }), - [favoriteKeys, providers, selectedModel, selectedProvider], + [hasProfiles, providers, selectedModel, selectedProvider], ); const prepareToOpen = useCallback(() => { @@ -260,18 +280,33 @@ export function useModelBrowser({ reset(); }, [reset]); - const drillDown = useCallback((providerId: string, providerLabel: string) => { - setView({ kind: "provider", providerId, providerLabel }); - }, []); + const drillDown = useCallback( + (providerId: string, providerLabel: string) => { + setView({ kind: "provider", providerId, providerLabel }); + reset(); + }, + [reset], + ); const handleSearchQueryChange = useCallback((value: string) => { setSearchQuery(value); }, []); - const singleProviderView = providers.length === 1; + // A pinned profiles section makes the root worth returning to, so the back + // affordance stays even when the drill-down was the only provider. + const singleProviderView = providers.length === 1 && !hasProfiles; const header = useMemo(() => { if (view.kind === "all") { - return { title: t("modelSelector.title") }; + return { + title: t("modelSelector.title"), + search: { + onChange: handleSearchQueryChange, + resetKey: `all:${searchResetKey}`, + placeholder: t("modelSelector.searchAllPlaceholder"), + autoFocus: isWeb, + testID: "model-search-all-input", + }, + }; } return { title: view.providerLabel, @@ -280,13 +315,15 @@ export function useModelBrowser({ ), back: singleProviderView ? undefined : { onPress: handleBackToAll }, actions: ( - + + + ), search: { onChange: handleSearchQueryChange, @@ -333,7 +370,7 @@ export function useModelBrowser({ providers, selectedProvider, selectedModel, - favoriteKeys, + profiles, view, searchQuery, header, @@ -351,22 +388,6 @@ function normalizeSearchQuery(value: string): string { return value.trim().toLowerCase(); } -function sortFavoritesFirst( - rows: ProviderSelectionModelRow[], - favoriteKeys: Set, -): ProviderSelectionModelRow[] { - const favorites: ProviderSelectionModelRow[] = []; - const rest: ProviderSelectionModelRow[] = []; - for (const row of rows) { - if (favoriteKeys.has(row.favoriteKey)) { - favorites.push(row); - } else { - rest.push(row); - } - } - return [...favorites, ...rest]; -} - interface ModelBrowserPressableProps { children: React.ReactNode | ((state: PressableStateCallbackType) => React.ReactNode); style?: @@ -375,6 +396,8 @@ interface ModelBrowserPressableProps { onPress: () => void; hitSlop?: number; accessibilityLabel?: string; + /** Only rows that can express selection pass this; the rest stay unannotated. */ + accessibilitySelected?: boolean; testID?: string; } @@ -384,6 +407,7 @@ function ModelBrowserPressable({ onPress, hitSlop, accessibilityLabel, + accessibilitySelected, testID, }: ModelBrowserPressableProps) { const independentScrollGesture = useContext(IndependentScrollGestureContext); @@ -420,6 +444,10 @@ function ModelBrowserPressable({ }, [onPress], ); + const accessibilityState = useMemo( + () => (accessibilitySelected === undefined ? undefined : { selected: accessibilitySelected }), + [accessibilitySelected], + ); if (!independentScrollGesture) { return ( @@ -429,6 +457,8 @@ function ModelBrowserPressable({ style={style} accessibilityRole="button" accessibilityLabel={accessibilityLabel} + accessibilityState={accessibilityState} + aria-selected={accessibilitySelected} testID={testID} > {children} @@ -445,6 +475,8 @@ function ModelBrowserPressable({ accessible accessibilityRole="button" accessibilityLabel={accessibilityLabel} + accessibilityState={accessibilityState} + aria-selected={accessibilitySelected} accessibilityActions={[{ name: "activate" }]} onAccessibilityAction={handleAccessibilityAction} style={resolvedStyle} @@ -466,6 +498,7 @@ function ModelBrowserRow({ selected = false, selectionIndicator = false, tone = "default", + labelMuted = false, spacing = "model", onPress, testID, @@ -477,6 +510,8 @@ function ModelBrowserRow({ selected?: boolean; selectionIndicator?: boolean; tone?: ModelBrowserRowTone; + /** For rows that offer an action rather than name a thing you can pick. */ + labelMuted?: boolean; spacing?: "model" | "provider"; onPress: () => void; testID?: string; @@ -498,11 +533,21 @@ function ModelBrowserRow({ const hasTrailing = selected || trailingSlot; return ( - + {leadingSlot} - + {label} {description ? ( @@ -531,55 +576,30 @@ function ModelBrowserRow({ function ModelRow({ row, isSelected, - isFavorite, - elevated = false, + showProviderLabel = false, onPress, - onToggleFavorite, }: { row: ProviderSelectionModelRow; isSelected: boolean; - isFavorite: boolean; - elevated?: boolean; + showProviderLabel?: boolean; onPress: () => void; - onToggleFavorite?: (provider: string, modelId: string) => void; }) { - const { t } = useTranslation(); - const handleToggleFavorite = useCallback( - () => onToggleFavorite?.(row.provider, row.modelId), - [onToggleFavorite, row.modelId, row.provider], - ); const leadingSlot = useMemo( () => , [row.provider], ); - const trailingSlot = useMemo( - () => - onToggleFavorite ? ( - - {({ hovered }) => } - - ) : null, - [handleToggleFavorite, isFavorite, onToggleFavorite, row.modelId, row.provider, t], - ); + + const description = showProviderLabel ? buildProviderQualifiedDescription(row) : row.description; return ( ); } @@ -587,17 +607,13 @@ function ModelRow({ function SelectableModelRow({ row, isSelected, - isFavorite, - elevated, + showProviderLabel, onSelect, - onToggleFavorite, }: { row: ProviderSelectionModelRow; isSelected: boolean; - isFavorite: boolean; - elevated?: boolean; + showProviderLabel?: boolean; onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; }) { const handlePress = useCallback(() => { onSelect(row.provider, row.modelId); @@ -606,51 +622,110 @@ function SelectableModelRow({ ); } -function FavoritesSection({ - favoriteRows, - selectedProvider, - selectedModel, - favoriteKeys, - onSelect, - onToggleFavorite, +function AgentProfilePickerRowView({ + row, + onApply, }: { - favoriteRows: ProviderSelectionModelRow[]; - selectedProvider: string; - selectedModel: string; - favoriteKeys: Set; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; + row: AgentProfilePickerRowModel; + onApply: (profileId: string) => void; +}) { + const handlePress = useCallback(() => onApply(row.id), [onApply, row.id]); + const leadingSlot = useMemo( + () => , + [row.color, row.icon], + ); + return ( + + ); +} + +/** + * Pinned above the provider list. Rows are actions, not selections: applying a + * profile writes its values into the composer and nothing stays bound to it, so + * there is no checkmark and no active row to show. + */ +function AgentProfilesPickerSection({ + rows, + onApplyProfile, + onEditProfiles, +}: { + rows: AgentProfilePickerRowModel[]; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; }) { const { t } = useTranslation(); - if (favoriteRows.length === 0) return null; + const handleApply = useCallback( + (profileId: string) => onApplyProfile?.(profileId), + [onApplyProfile], + ); return ( - + - {t("modelSelector.favorites")} + {t("modelSelector.profiles")} + {onEditProfiles ? : null} - {favoriteRows.map((row) => ( - + {rows.map((row) => ( + ))} ); } +function CreateAgentProfileRow({ onPress }: { onPress: () => void }) { + const { t } = useTranslation(); + const leadingSlot = useMemo( + () => ( + + + + ), + [], + ); + return ( + + ); +} + +function AgentProfilesPickerContent({ + rows, + onApplyProfile, + onEditProfiles, +}: { + rows: AgentProfilePickerRowModel[]; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; +}) { + if (rows.length === 0) { + return onEditProfiles ? : null; + } + return ( + + ); +} + function GroupProviderButton({ provider, onDrillDown, @@ -764,15 +839,18 @@ function IndependentScrollBoundary({ children }: { children: React.ReactElement function IndependentModelList({ rows, renderItem, + header, }: { rows: ProviderSelectionModelRow[]; renderItem: ({ item }: { item: ProviderSelectionModelRow }) => React.ReactElement; + header?: React.ReactElement; }) { return ( ; onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; - normalizedQuery: string; + showProviderLabel?: boolean; + header?: React.ReactElement; scrolling: "sheet" | "independent"; }) { const isCompact = useIsCompactFormFactor(); - const displayRows = useMemo( - () => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)), - [favoriteKeys, normalizedQuery, rows], - ); const renderItem = useCallback( ({ item }: { item: ProviderSelectionModelRow }) => ( ), - [favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider], + [onSelect, selectedModel, selectedProvider, showProviderLabel], ); const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []); if (scrolling === "independent") { - return ; + return ; } if (isCompact && isNative) { return ( - {displayRows.map((row) => ( + {header} + {rows.map((row) => ( {renderItem({ item: row })} ))} @@ -902,15 +975,111 @@ function ProviderErrorEmptyState({ ); } +function ModelSearchEmptyState() { + const { t } = useTranslation(); + return ( + + + {t("modelSelector.noMatches")} + + ); +} + +function ProviderModelBrowserContent({ + view, + provider, + profiles, + selectedProvider, + selectedModel, + normalizedQuery, + onSelect, + onApplyProfile, + onEditProfiles, + onRetryProvider, + isRetryingProvider, + scrolling, +}: { + view: Extract; + provider: ProviderSelectorProvider | null; + profiles: AgentProfilePicker | null; + selectedProvider: string; + selectedModel: string; + normalizedQuery: string; + onSelect: (provider: string, modelId: string) => void; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; + onRetryProvider?: (provider: AgentProvider) => void; + isRetryingProvider: boolean; + scrolling: "sheet" | "independent"; +}) { + const { t } = useTranslation(); + const visibleRows = useMemo( + () => (provider ? filterAndRankModelRows(getProviderModelRows(provider), normalizedQuery) : []), + [normalizedQuery, provider], + ); + const providerProfileRows = useMemo( + () => profiles?.rows.filter((row) => row.provider === view.providerId) ?? [], + [profiles, view.providerId], + ); + const profileHeader = useMemo( + () => + normalizedQuery.length === 0 && profiles ? ( + + ) : undefined, + [normalizedQuery, onApplyProfile, onEditProfiles, profiles, providerProfileRows], + ); + + if (!provider) return ; + const selection = provider.modelSelection; + if (selection.kind === "loading") { + return ( + + + + + {t("modelSelector.loadingShort")} + + ); + } + if (selection.kind === "error") { + return ( + + ); + } + if (visibleRows.length === 0) { + return profileHeader ?? ; + } + return ( + + ); +} + function ModelBrowserContent({ view, providers, selectedProvider, selectedModel, searchQuery, - favoriteKeys, + profiles, onSelect, - onToggleFavorite, + onApplyProfile, + onEditProfiles, onDrillDown, onRetryProvider, isRetryingProvider = false, @@ -921,62 +1090,54 @@ function ModelBrowserContent({ const selectedViewProvider = useMemo( () => view.kind === "provider" - ? providers.find((provider) => provider.id === view.providerId) + ? (providers.find((provider) => provider.id === view.providerId) ?? null) : null, [providers, view], ); - const visibleRows = useMemo( - () => - selectedViewProvider - ? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery) - : [], - [normalizedQuery, selectedViewProvider], - ); - const favoriteRows = useMemo( - () => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)), - [favoriteKeys, providers], - ); - const hasResults = favoriteRows.length > 0 || providers.length > 0; - const emptyState = ( - - - {t("modelSelector.noMatches")} - + const allView = useMemo( + () => resolveModelBrowserAllView({ providers, normalizedQuery }), + [normalizedQuery, providers], ); + const hasResults = profiles !== null || providers.length > 0; if (view.kind === "provider") { - if (!selectedViewProvider) return emptyState; - const selection = selectedViewProvider.modelSelection; - if (selection.kind === "loading") { - return ( - - - - - {t("modelSelector.loadingShort")} - - ); - } - if (selection.kind === "error") { - return ( - - ); - } - if (visibleRows.length === 0) return emptyState; return ( - + ); + } + + if (allView.kind === "noSearchMatches") { + return ( + + + + {t("modelSelector.noMatchesForQuery", { query: searchQuery.trim() })} + + + ); + } + + if (allView.kind === "searchResults") { + return ( + ); @@ -984,18 +1145,24 @@ function ModelBrowserContent({ const allProvidersContent = ( - - {providers.length > 0 ? ( - + {profiles ? ( + ) : null} - {!hasResults ? emptyState : null} + {providers.length > 0 ? ( + + {profiles ? ( + + {t("modelSelector.providers")} + + ) : null} + + + ) : null} + {!hasResults ? : null} ); @@ -1009,7 +1176,8 @@ function ModelBrowserContent({ export function ModelBrowser({ state, onSelect, - onToggleFavorite, + onApplyProfile, + onEditProfiles, onRetryProvider, isRetryingProvider = false, scrolling = "sheet", @@ -1021,9 +1189,10 @@ export function ModelBrowser({ selectedProvider={state.selectedProvider} selectedModel={state.selectedModel} searchQuery={state.searchQuery} - favoriteKeys={state.favoriteKeys} + profiles={state.profiles} onSelect={onSelect} - onToggleFavorite={onToggleFavorite} + onApplyProfile={onApplyProfile} + onEditProfiles={onEditProfiles} onDrillDown={state.drillDown} onRetryProvider={onRetryProvider} isRetryingProvider={isRetryingProvider} @@ -1033,7 +1202,12 @@ export function ModelBrowser({ } const styles = StyleSheet.create((theme) => ({ - favoritesContainer: { + headerActionRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + profilesContainer: { backgroundColor: theme.colors.surface1, borderBottomWidth: 1, borderBottomColor: theme.colors.border, @@ -1051,6 +1225,7 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[1], }, sectionHeadingText: { + flex: 1, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, @@ -1099,6 +1274,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, flexShrink: 0, }, + browserRowLabelMuted: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flexShrink: 0, + }, browserRowDescription: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, @@ -1156,6 +1336,10 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, + tooltipText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + }, virtualizedModelList: { flex: 1, }, @@ -1166,19 +1350,6 @@ const styles = StyleSheet.create((theme) => ({ virtualizedProviderListContent: { paddingTop: 0, }, - favoriteButton: { - width: 24, - height: 24, - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - }, - favoriteButtonHovered: { - backgroundColor: theme.colors.surface2, - }, - favoriteButtonPressed: { - backgroundColor: theme.colors.surface1, - }, providerIconMuted: { color: theme.colors.foregroundMuted, }, diff --git a/packages/app/src/components/ui/combobox-frame-style.test.ts b/packages/app/src/components/ui/combobox-frame-style.test.ts index 84f884c4e..69026b141 100644 --- a/packages/app/src/components/ui/combobox-frame-style.test.ts +++ b/packages/app/src/components/ui/combobox-frame-style.test.ts @@ -9,6 +9,7 @@ function buildWidthStyle(input: { }): Pick { const [frameStyle] = buildDesktopFrameStyle({ desktopMinWidth: input.desktopMinWidth, + desktopLockWidth: false, referenceWidth: input.referenceWidth, desktopFixedHeight: undefined, desktopPositionStyle: { left: 0, top: 0 }, @@ -55,4 +56,18 @@ describe("buildDesktopFrameStyle", () => { maxWidth: 400, }); }); + + it("locks the frame to its opening floor when content must not resize it", () => { + const [frameStyle] = buildDesktopFrameStyle({ + desktopMinWidth: 360, + desktopLockWidth: true, + referenceWidth: 120, + desktopFixedHeight: undefined, + desktopPositionStyle: { left: 0, top: 0 }, + shouldHideDesktopContent: false, + availableHeight: undefined, + }) as ViewStyle[]; + + expect(frameStyle).toMatchObject({ width: 360, minWidth: 360, maxWidth: 360 }); + }); }); diff --git a/packages/app/src/components/ui/combobox-frame-style.ts b/packages/app/src/components/ui/combobox-frame-style.ts index 0e825abaf..8c1a495c6 100644 --- a/packages/app/src/components/ui/combobox-frame-style.ts +++ b/packages/app/src/components/ui/combobox-frame-style.ts @@ -2,6 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native"; export interface DesktopFrameStyleInput { desktopMinWidth: number | undefined; + desktopLockWidth: boolean; referenceWidth: number | null; desktopFixedHeight: number | undefined; desktopPositionStyle: StyleProp; @@ -12,6 +13,7 @@ export interface DesktopFrameStyleInput { export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp { const { desktopMinWidth, + desktopLockWidth, referenceWidth, desktopFixedHeight, desktopPositionStyle, @@ -28,11 +30,13 @@ export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp ? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) } : null; const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200); + const widthStyle = desktopLockWidth + ? { width: floor, minWidth: floor, maxWidth: floor } + : { minWidth: floor, maxWidth: Math.max(400, floor) }; return [ { position: "absolute" as const, - minWidth: floor, - maxWidth: Math.max(400, floor), + ...widthStyle, }, fixedHeightStyle, desktopPositionStyle, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index b756ecc9c..da9dc2e19 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -108,6 +108,8 @@ export interface ComboboxProps { */ header?: SheetHeader; mobileChildrenScrollEnabled?: boolean; + /** Let custom desktop children own scrolling, for example a virtualized list. */ + desktopChildrenScrollEnabled?: boolean; /** Overrides the mobile scroll container spacing for custom child content. */ mobileChildrenContentContainerStyle?: StyleProp; presentation?: "push" | "replace"; @@ -122,6 +124,8 @@ export interface ComboboxProps { desktopPreventInitialFlash?: boolean; /** Minimum width for the desktop popover (overrides trigger-based width). */ desktopMinWidth?: number; + /** Keep the desktop popover at its opening width as its content changes. */ + desktopLockWidth?: boolean; /** Fixed height for the desktop popover (overrides default 400px max). */ desktopFixedHeight?: number; /** Content rendered above the scroll area on desktop (sticky header). */ @@ -1080,17 +1084,28 @@ interface DesktopBodyProps { handleSelect: (id: string) => void; renderOption: RenderOptionFn | undefined; hasChildren: boolean; + childrenScrollEnabled: boolean; children: ReactNode; } function DesktopComboboxChildrenBody(props: { header: SheetHeader | undefined; stickyHeader: ReactNode; + scrollEnabled: boolean; children: ReactNode; }): ReactElement { + const header = props.header ? : props.stickyHeader; + if (!props.scrollEnabled) { + return ( + <> + {header} + {props.children} + + ); + } return ( <> - {props.header ? : props.stickyHeader} + {header} {props.hasChildren ? ( - + {props.children} ) : ( @@ -1284,6 +1303,7 @@ export function Combobox({ title, header, mobileChildrenScrollEnabled = true, + desktopChildrenScrollEnabled = true, mobileChildrenContentContainerStyle, presentation, open, @@ -1291,6 +1311,7 @@ export function Combobox({ desktopPlacement = "bottom-start", desktopPreventInitialFlash = true, desktopMinWidth, + desktopLockWidth, desktopFixedHeight, stickyHeader, footer, @@ -1538,6 +1559,7 @@ export function Combobox({ () => buildDesktopFrameStyle({ desktopMinWidth, + desktopLockWidth: Boolean(desktopLockWidth), referenceWidth, desktopFixedHeight, desktopPositionStyle, @@ -1546,6 +1568,7 @@ export function Combobox({ }), [ desktopMinWidth, + desktopLockWidth, referenceWidth, desktopFixedHeight, desktopPositionStyle, @@ -1630,6 +1653,7 @@ export function Combobox({ handleSelect={handleSelect} renderOption={renderOption} hasChildren={hasChildren} + childrenScrollEnabled={desktopChildrenScrollEnabled} > {children} @@ -1786,6 +1810,10 @@ const styles = StyleSheet.create((theme) => ({ desktopChildrenScrollContent: { // No padding — custom children (e.g. model selector) control their own spacing }, + desktopChildrenBody: { + flex: 1, + minHeight: 0, + }, desktopScrollContentAboveSearch: { flexGrow: 1, justifyContent: "flex-end", diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 642f08046..a659b46d1 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -9,6 +9,7 @@ import { type RefObject, } from "react"; import { useTranslation } from "react-i18next"; +import { router } from "expo-router"; import { View, Text, @@ -36,12 +37,7 @@ import { filterSelectableModels } from "@/provider-selection/model-catalog"; import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; -import { - buildFavoriteModelKey, - mergeProviderPreferences, - toggleFavoriteModel, - useFormPreferences, -} from "@/hooks/use-form-preferences"; +import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { AgentModeControl, @@ -81,6 +77,12 @@ import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout- import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; +import { + useAgentProfilePicker, + type AgentProfileApplyTarget, + type AgentProfilePicker, +} from "@/agent-profiles"; +import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; interface AgentControlOption { id: string; @@ -106,8 +108,9 @@ interface ControlledAgentControlsProps { disabled?: boolean; isModelLoading?: boolean; modelSelectorProviders?: ProviderSelectorProvider[]; - favoriteKeys?: Set; - onToggleFavoriteModel?: (provider: string, modelId: string) => void; + agentProfiles?: AgentProfilePicker | null; + onApplyAgentProfile?: (profileId: string) => void; + onEditAgentProfiles?: () => void; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; onDropdownClose?: () => void; @@ -241,6 +244,21 @@ function toThinkingControlOptions(options: AgentControlOption[] | undefined): Ag })); } +/** + * The picker's edit shortcut. Agent profiles are host config, so it lands on the + * host settings section that owns the list. + */ +function useEditAgentProfilesNavigation( + serverId: string | null, + isSupported: boolean, +): (() => void) | undefined { + const handleEdit = useCallback(() => { + if (!serverId) return; + router.push(buildSettingsHostSectionRoute(serverId, "agents")); + }, [serverId]); + return serverId && isSupported ? handleEdit : undefined; +} + function buildFallbackModelSelectorProviders( provider: string, modelOptions: AgentControlOption[] | undefined, @@ -255,7 +273,7 @@ function buildFallbackModelSelectorProviders( modelSelection: { kind: "models", rows: modelOptions.map((option) => ({ - favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }), + favoriteKey: `${provider}:${option.id}`, provider, providerLabel: provider, modelId: option.id, @@ -416,8 +434,9 @@ function ControlledAgentControls({ disabled = false, isModelLoading = false, modelSelectorProviders, - favoriteKeys = new Set(), - onToggleFavoriteModel, + agentProfiles = null, + onApplyAgentProfile, + onEditAgentProfiles, features, onSetFeature, onDropdownClose, @@ -670,12 +689,13 @@ function ControlledAgentControls({ selectedThinkingOptionId={selectedThinkingOptionId} features={features} onSetFeature={onSetFeature} - onToggleFavoriteModel={onToggleFavoriteModel} + onApplyAgentProfile={onApplyAgentProfile} + onEditAgentProfiles={onEditAgentProfiles} onDropdownClose={onDropdownClose} onModelSelectorOpen={onModelSelectorOpen} onRetryModelProvider={onRetryModelProvider} isRetryingModelProvider={isRetryingModelProvider} - favoriteKeys={favoriteKeys} + agentProfiles={agentProfiles} disabled={disabled} isModelLoading={isModelLoading} canSelectProvider={canSelectProvider} @@ -716,12 +736,13 @@ function ControlledAgentControls({ selectedThinkingOptionId={selectedThinkingOptionId} features={features} onSetFeature={onSetFeature} - onToggleFavoriteModel={onToggleFavoriteModel} + onApplyAgentProfile={onApplyAgentProfile} + onEditAgentProfiles={onEditAgentProfiles} onDropdownClose={onDropdownClose} onModelSelectorOpen={onModelSelectorOpen} onRetryModelProvider={onRetryModelProvider} isRetryingModelProvider={isRetryingModelProvider} - favoriteKeys={favoriteKeys} + agentProfiles={agentProfiles} disabled={disabled} isModelLoading={isModelLoading} canSelectModel={canSelectModel} @@ -758,12 +779,13 @@ interface DesktopAgentControlsContentProps { selectedThinkingOptionId?: string; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; - onToggleFavoriteModel?: (provider: string, modelId: string) => void; + onApplyAgentProfile?: (profileId: string) => void; + onEditAgentProfiles?: () => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; isRetryingModelProvider: boolean; - favoriteKeys: Set; + agentProfiles: AgentProfilePicker | null; disabled: boolean; isModelLoading: boolean; canSelectProvider: boolean; @@ -817,12 +839,13 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { selectedThinkingOptionId, features, onSetFeature, - onToggleFavoriteModel, + onApplyAgentProfile, + onEditAgentProfiles, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, isRetryingModelProvider, - favoriteKeys, + agentProfiles, disabled, isModelLoading, canSelectProvider, @@ -903,8 +926,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { selectedProvider={provider} selectedModel={selectedModelId ?? ""} onSelect={handleDesktopModelSelect} - favoriteKeys={favoriteKeys} - onToggleFavorite={onToggleFavoriteModel} + profiles={agentProfiles} + onApplyProfile={onApplyAgentProfile} + onEditProfiles={onEditAgentProfiles} isLoading={isModelLoading} disabled={modelDisabled} onOpen={onModelSelectorOpen} @@ -1021,12 +1045,13 @@ interface SheetAgentControlsContentProps { selectedThinkingOptionId?: string; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; - onToggleFavoriteModel?: (provider: string, modelId: string) => void; + onApplyAgentProfile?: (profileId: string) => void; + onEditAgentProfiles?: () => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; isRetryingModelProvider: boolean; - favoriteKeys: Set; + agentProfiles: AgentProfilePicker | null; disabled: boolean; isModelLoading: boolean; canSelectModel: boolean; @@ -1061,12 +1086,13 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { selectedThinkingOptionId, features, onSetFeature, - onToggleFavoriteModel, + onApplyAgentProfile, + onEditAgentProfiles, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, isRetryingModelProvider, - favoriteKeys, + agentProfiles, disabled, isModelLoading, canSelectModel, @@ -1158,8 +1184,9 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { selectedProvider={provider} selectedModel={selectedModelId ?? ""} onSelect={handleSheetModelSelect} - favoriteKeys={favoriteKeys} - onToggleFavorite={onToggleFavoriteModel} + profiles={agentProfiles} + onApplyProfile={onApplyAgentProfile} + onEditProfiles={onEditAgentProfiles} isLoading={isModelLoading} disabled={modelDisabled} onOpen={onModelSelectorOpen} @@ -1428,7 +1455,7 @@ export const AgentControls = memo(function AgentControls({ onDropdownClose, isCompactLayout, }: AgentControlsProps) { - const { preferences, updatePreferences } = useFormPreferences(); + const { updatePreferences } = useFormPreferences(); const agent = useSessionStore( useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)), ); @@ -1483,13 +1510,6 @@ export const AgentControls = memo(function AgentControls({ const modelOptions = useMemo(() => { return (models ?? []).map((model) => ({ id: model.id, label: model.label })); }, [models]); - const favoriteKeys = useMemo( - () => - new Set( - (preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)), - ), - [preferences.favoriteModels], - ); const thinkingOptions = useMemo(() => { return (modelSelection.thinkingOptions ?? []).map((option) => ({ @@ -1527,16 +1547,19 @@ export const AgentControls = memo(function AgentControls({ [handleSelectModel], ); - const handleToggleFavoriteModel = useCallback( - (provider: string, modelId: string) => { - void updatePreferences((current) => - toggleFavoriteModel({ preferences: current, provider, modelId }), - ).catch((error) => { - console.warn("[AgentControls] toggle favorite model failed", error); - }); - }, - [updatePreferences], + // A running agent is one provider's process, so only that provider's profiles + // can apply to it. + const profileProviders = useMemo(() => (agentProvider ? [agentProvider] : []), [agentProvider]); + const profileTarget = useMemo( + () => ({ kind: "agent", agentId }), + [agentId], ); + const agentProfiles = useAgentProfilePicker({ + serverId, + availableProviders: profileProviders, + target: profileTarget, + }); + const handleEditAgentProfiles = useEditAgentProfilesNavigation(serverId, agentProfiles !== null); const handleSelectThinkingOption = useCallback( (thinkingOptionId: string) => { @@ -1645,8 +1668,9 @@ export const AgentControls = memo(function AgentControls({ modelOptions={modelOptions} selectedModelId={modelSelection.activeModelId ?? undefined} onSelectModel={handleSelectModel} - favoriteKeys={favoriteKeys} - onToggleFavoriteModel={handleToggleFavoriteModel} + agentProfiles={agentProfiles} + onApplyAgentProfile={agentProfiles?.applyProfile} + onEditAgentProfiles={handleEditAgentProfiles} thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} onSelectThinkingOption={handleSelectThinkingOption} @@ -1668,7 +1692,7 @@ export const AgentControls = memo(function AgentControls({ export function DraftAgentControls({ providerDefinitions, selectedProvider, - onSelectProvider: _onSelectProvider, + onSelectProvider, modeOptions, selectedMode, onSelectMode, @@ -1692,17 +1716,9 @@ export function DraftAgentControls({ modelSelectorServerId = null, isCompactLayout, }: DraftAgentControlsProps) { - const { preferences, updatePreferences } = useFormPreferences(); const mappedThinkingOptions = useMemo(() => { return toThinkingControlOptions(thinkingOptions); }, [thinkingOptions]); - const favoriteKeys = useMemo( - () => - new Set( - (preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)), - ), - [preferences.favoriteModels], - ); const effectiveSelectedThinkingOption = selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; @@ -1716,15 +1732,39 @@ export function DraftAgentControls({ [models], ); - const handleToggleFavorite = useCallback( - (provider: string, modelId: string) => { - void updatePreferences((current) => - toggleFavoriteModel({ preferences: current, provider, modelId }), - ).catch((error) => { - console.warn("[DraftAgentControls] toggle favorite model failed", error); - }); - }, - [updatePreferences], + // The draft form is the one surface that can switch provider, so every profile + // the host can actually run is offered here. + const profileProviders = useMemo( + () => modelSelectorProviders.map((entry) => entry.id), + [modelSelectorProviders], + ); + const profileTarget = useMemo( + () => ({ + kind: "draft", + controls: { + selectProvider: onSelectProvider, + selectProviderAndModel: onSelectProviderAndModel, + selectMode: onSelectMode, + selectThinkingOption: onSelectThinkingOption, + setFeature: onSetFeature, + }, + }), + [ + onSelectMode, + onSelectProvider, + onSelectProviderAndModel, + onSelectThinkingOption, + onSetFeature, + ], + ); + const agentProfiles = useAgentProfilePicker({ + serverId: modelSelectorServerId, + availableProviders: profileProviders, + target: profileTarget, + }); + const handleEditAgentProfiles = useEditAgentProfilesNavigation( + modelSelectorServerId, + agentProfiles !== null, ); const modeControl = useMemo( @@ -1751,8 +1791,9 @@ export function DraftAgentControls({ onSelectModel={onSelectModel} onSelectProviderAndModel={onSelectProviderAndModel} isModelLoading={isAllModelsLoading} - favoriteKeys={favoriteKeys} - onToggleFavoriteModel={handleToggleFavorite} + agentProfiles={agentProfiles} + onApplyAgentProfile={agentProfiles?.applyProfile} + onEditAgentProfiles={handleEditAgentProfiles} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} selectedThinkingOptionId={effectiveSelectedThinkingOption} onSelectThinkingOption={onSelectThinkingOption} diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index a3ea5e022..b49813dd4 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { AgentProfilePicker } from "@/agent-profiles"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { getProviderIcon } from "@/components/provider-icons"; @@ -24,8 +25,9 @@ interface CompactModelSheetProps { selectedModel: string; onSelect: (provider: string, modelId: string) => void; isLoading: boolean; - favoriteKeys: Set; - onToggleFavorite?: (provider: string, modelId: string) => void; + profiles?: AgentProfilePicker | null; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; onOpen?: () => void; onClose?: () => void; onRetryProvider?: (provider: AgentProvider) => void; @@ -47,8 +49,9 @@ export function CompactModelSheet({ selectedModel, onSelect, isLoading, - favoriteKeys, - onToggleFavorite, + profiles = null, + onApplyProfile, + onEditProfiles, onOpen, onClose, onRetryProvider, @@ -66,7 +69,7 @@ export function CompactModelSheet({ selectedProvider, selectedModel, isLoading, - favoriteKeys, + profiles, serverId, }); const { prepareToOpen, reset } = browser; @@ -104,6 +107,19 @@ export function CompactModelSheet({ [close, onSelect], ); + const handleApplyProfile = useCallback( + (profileId: string) => { + onApplyProfile?.(profileId); + close(); + }, + [close, onApplyProfile], + ); + + const handleEditProfiles = useCallback(() => { + close(); + onEditProfiles?.(); + }, [close, onEditProfiles]); + const toggle = useCallback(() => { if (isOpen) { close(); @@ -168,7 +184,8 @@ export function CompactModelSheet({ { }); }); + it("preserves legacy favourites across preference writes until host migration", async () => { + const favoriteModels = [{ provider: "claude", modelId: "opus" }]; + const storage = new FakeCreateAgentPreferenceStorage({ stored: { favoriteModels } }); + const preferences = new CreateAgentPreferencesService(storage); + + const save = preferences.update({ isolation: "worktree" }); + await storage.nextWrite(); + storage.finishOldestWrite(); + await save; + + expect(storage.savedPreferences()).toEqual({ favoriteModels, isolation: "worktree" }); + }); + it("treats stored preferences without an isolation choice as undefined", () => { expect(parseFormPreferences({ provider: "codex" }).isolation).toBeUndefined(); }); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 2c8cfd2be..7153fa8b4 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -1,20 +1,6 @@ import { z } from "zod"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -export interface FavoriteModelPreference { - provider: string; - modelId: string; -} - -export interface FavoriteModelRow { - favoriteKey: string; - provider: string; - providerLabel: string; - modelId: string; - modelLabel: string; - description?: string; -} - const providerPreferencesSchema = z.object({ model: z.string().optional(), mode: z.string().optional(), @@ -30,6 +16,9 @@ const launchTargetSchema = z.discriminatedUnion("kind", [ const formPreferencesSchema = z.object({ provider: z.string().optional(), providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(), + // COMPAT(agentProfileFavoriteMigration): favourites were removed in v0.3.2. + // Keep the legacy payload alive until every capable host has had a chance to + // import it; ordinary preference writes must not erase it first. favoriteModels: z .array( z.object({ @@ -138,38 +127,3 @@ export function mergeCreateAgentSelectionPreferences(args: { }, }); } - -export function buildFavoriteModelKey(input: FavoriteModelPreference): string { - return `${input.provider}:${input.modelId}`; -} - -export function isFavoriteModel(args: { - preferences: FormPreferences; - provider: string; - modelId: string; -}): boolean { - const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId }); - return (args.preferences.favoriteModels ?? []).some( - (favorite) => buildFavoriteModelKey(favorite) === favoriteKey, - ); -} - -export function toggleFavoriteModel(args: { - preferences: FormPreferences; - provider: string; - modelId: string; -}): FormPreferences { - const favorite = { provider: args.provider, modelId: args.modelId }; - const favoriteKey = buildFavoriteModelKey(favorite); - const existingFavorites = args.preferences.favoriteModels ?? []; - const hasFavorite = existingFavorites.some( - (entry) => buildFavoriteModelKey(entry) === favoriteKey, - ); - - return { - ...args.preferences, - favoriteModels: hasFavorite - ? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey) - : [...existingFavorites, favorite], - }; -} diff --git a/packages/app/src/hooks/use-form-preferences.test.ts b/packages/app/src/hooks/use-form-preferences.test.ts index 4dd277420..be0653fbc 100644 --- a/packages/app/src/hooks/use-form-preferences.test.ts +++ b/packages/app/src/hooks/use-form-preferences.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - buildFavoriteModelKey, - isFavoriteModel, - mergeProviderPreferences, - toggleFavoriteModel, -} from "./use-form-preferences"; +import { mergeProviderPreferences } from "./use-form-preferences"; describe("mergeProviderPreferences", () => { it("stores the selected model for a provider", () => { @@ -95,92 +90,3 @@ describe("mergeProviderPreferences", () => { }); }); }); - -describe("favorite model preferences", () => { - it("builds a stable favorite key from provider and model", () => { - expect(buildFavoriteModelKey({ provider: "claude", modelId: "sonnet-4.6" })).toBe( - "claude:sonnet-4.6", - ); - }); - - it("adds a model to favorites without dropping other preferences", () => { - expect( - toggleFavoriteModel({ - preferences: { - provider: "claude", - providerPreferences: { - claude: { - model: "claude-sonnet-4-6", - }, - }, - }, - provider: "codex", - modelId: "gpt-5.4", - }), - ).toEqual({ - provider: "claude", - providerPreferences: { - claude: { - model: "claude-sonnet-4-6", - }, - }, - favoriteModels: [ - { - provider: "codex", - modelId: "gpt-5.4", - }, - ], - }); - }); - - it("removes a model from favorites when toggled again", () => { - expect( - toggleFavoriteModel({ - preferences: { - favoriteModels: [ - { - provider: "codex", - modelId: "gpt-5.4", - }, - ], - }, - provider: "codex", - modelId: "gpt-5.4", - }), - ).toEqual({ - favoriteModels: [], - }); - }); - - it("reports whether a model is favorited", () => { - expect( - isFavoriteModel({ - preferences: { - favoriteModels: [ - { - provider: "codex", - modelId: "gpt-5.4", - }, - ], - }, - provider: "codex", - modelId: "gpt-5.4", - }), - ).toBe(true); - - expect( - isFavoriteModel({ - preferences: { - favoriteModels: [ - { - provider: "codex", - modelId: "gpt-5.4", - }, - ], - }, - provider: "claude", - modelId: "sonnet-4.6", - }), - ).toBe(false); - }); -}); diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index ee69498b6..72006faa5 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -1,13 +1,8 @@ import { useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - buildFavoriteModelKey, DEFAULT_FORM_PREFERENCES, - isFavoriteModel, mergeProviderPreferences, - toggleFavoriteModel, - type FavoriteModelPreference, - type FavoriteModelRow, type FormPreferences, type ProviderPreferences, } from "@/create-agent-preferences/preferences"; @@ -18,9 +13,9 @@ import { const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"]; -export type { FavoriteModelPreference, FavoriteModelRow, FormPreferences, ProviderPreferences }; +export type { FormPreferences, ProviderPreferences }; -export { buildFavoriteModelKey, isFavoriteModel, mergeProviderPreferences, toggleFavoriteModel }; +export { mergeProviderPreferences }; async function loadFormPreferences(): Promise { return createAgentPreferencesService.load(); diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index ca2c42f73..55b5995ee 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -318,7 +318,7 @@ describe("translation resources", () => { it("includes provider selector and pairing keys for the Batch 4D migration", () => { expect(en.modelSelector.title).toBe("Select provider"); - expect(en.modelSelector.favorites).toBe("Favorites"); + expect(en.modelSelector.profiles).toBe("Profiles"); expect(en.providerCatalog.title).toBe("Add provider"); expect(en.providerCatalog.actions.installInstructions).toBe("Install instructions"); expect(en.pairing.link.title).toBe("Paste pairing link"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f96174068..a3baaf06c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1396,14 +1396,18 @@ export const ar: TranslationResources = { loadingSelector: "جارٍ تحميل محدد النموذج...", error: "خطأ", defaultModel: "تقصير", - favorites: "المفضلة", - favoriteModel: "النموذج المفضل", - unfavoriteModel: "نموذج غير مفضل", + profiles: "الملفات الشخصية", + providers: "المزودون", + editProfiles: "تحرير", + editProfilesLabel: "تحرير ملفات الوكيل", + createProfile: "إنشاء ملف شخصي", modelCount: "{{count}} نموذج", modelCountPlural: "{{count}} نماذج", retry: "أعد المحاولة", retrying: "جارٍ إعادة المحاولة...", noMatches: "لا توجد نماذج تطابق بحثك", + noMatchesForQuery: 'لا توجد نماذج تطابق "{{query}}"', + searchAllPlaceholder: "ابحث في كل النماذج...", searchPlaceholder: "نماذج البحث...", openProviderSettings: "افتح إعدادات{{provider}}", }, @@ -2190,6 +2194,42 @@ export const ar: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "ملفات تعريف الوكيل", + unavailable: "اتصل بهذا المضيف لإدارة ملفات تعريف الوكيل", + unsupported: "يشغّل هذا المضيف نسخة Daemon لا تدعم ملفات تعريف الوكيل بعد", + emptyState: "لا توجد ملفات تعريف بعد. أضف واحدًا لبدء الوكلاء من مزود ونموذج محفوظين.", + addProfileTitle: "إضافة ملف تعريف وكيل", + newProfile: "ملف تعريف جديد", + editProfile: "تعديل الملف", + editProfileTitle: "تعديل ملف تعريف الوكيل", + nameLabel: "الاسم", + namePlaceholder: "عمل الواجهة", + iconLabel: "الأيقونة", + noIcon: "بلا", + providerLabel: "المزود", + providerPlaceholder: "اختر مزودًا", + noProviders: "لا يوجد مزودون متاحون على هذا المضيف", + modelLabel: "النموذج", + noModels: "لا توجد نماذج يمكن الاختيار من بينها لهذا المزود", + modeLabel: "الوضع", + noModes: "لا توجد أوضاع يمكن الاختيار من بينها لهذا المزود", + thinkingLabel: "التفكير", + noThinkingOptions: "لا توجد مستويات تفكير لهذا النموذج", + featuresLabel: "السمات", + featureCount: "{{count}} سمة", + featureCountOne: "{{count}} سمة", + notesLabel: "ملاحظات للوكلاء", + notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.", + notesHint: 'تُعاد بواسطة أداة MCP "list_profiles". اكتبها كتعليمة لوكيل آخر.', + save: "حفظ", + saving: "جارٍ الحفظ...", + remove: "إزالة", + removeConfirmTitle: "إزالة الملف؟", + removeConfirmMessage: 'إزالة "{{name}}"؟', + moveUp: "نقل لأعلى", + moveDown: "نقل لأسفل", + }, daemon: { rename: { editLabel: "تحرير التسمية", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9c18be9d5..520a43199 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1407,14 +1407,18 @@ export const en = { loadingSelector: "Loading model selector...", error: "Error", defaultModel: "Default", - favorites: "Favorites", - favoriteModel: "Favorite model", - unfavoriteModel: "Unfavorite model", + profiles: "Profiles", + providers: "Providers", + editProfiles: "Edit", + editProfilesLabel: "Edit agent profiles", + createProfile: "Create profile", modelCount: "{{count}} model", modelCountPlural: "{{count}} models", retry: "Retry", retrying: "Retrying...", noMatches: "No models match your search", + noMatchesForQuery: 'No models match "{{query}}"', + searchAllPlaceholder: "Search all models...", searchPlaceholder: "Search models...", openProviderSettings: "Open {{provider}} settings", }, @@ -2201,6 +2205,43 @@ export const en = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Agent profiles", + unavailable: "Connect to this host to manage agent profiles", + unsupported: "This host runs a daemon that does not support agent profiles yet", + emptyState: "No profiles yet. Add one to start agents from a saved provider and model.", + addProfileTitle: "Add agent profile", + newProfile: "New profile", + editProfile: "Edit profile", + editProfileTitle: "Edit agent profile", + nameLabel: "Name", + namePlaceholder: "UI work", + iconLabel: "Icon", + noIcon: "None", + providerLabel: "Provider", + providerPlaceholder: "Select a provider", + noProviders: "No providers are available on this host", + modelLabel: "Model", + noModels: "This provider has no models to choose from", + modeLabel: "Mode", + noModes: "This provider has no modes to choose from", + thinkingLabel: "Thinking", + noThinkingOptions: "This model has no thinking levels", + featuresLabel: "Features", + featureCount: "{{count}} features", + featureCountOne: "{{count}} feature", + notesLabel: "Notes for agents", + notesPlaceholder: "Use for UI work — components, layout and design tokens.", + notesHint: + "Returned by the list_profiles MCP tool. Write it as an instruction to another agent.", + save: "Save", + saving: "Saving...", + remove: "Remove", + removeConfirmTitle: "Remove profile?", + removeConfirmMessage: 'Remove "{{name}}"?', + moveUp: "Move up", + moveDown: "Move down", + }, daemon: { rename: { editLabel: "Edit label", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 3f468de94..c811ca446 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1437,14 +1437,18 @@ export const es: TranslationResources = { loadingSelector: "Cargando selector de modelo...", error: "Error", defaultModel: "Por defecto", - favorites: "Favoritos", - favoriteModel: "modelo favorito", - unfavoriteModel: "Modelo no favorito", + profiles: "Perfiles", + providers: "Proveedores", + editProfiles: "Editar", + editProfilesLabel: "Editar perfiles de agente", + createProfile: "Crear perfil", modelCount: "{{count}} modelo", modelCountPlural: "{{count}} modelos", retry: "Rever", retrying: "Reintentando...", noMatches: "Ningún modelo coincide con tu búsqueda", + noMatchesForQuery: "Ningún modelo coincide con «{{query}}»", + searchAllPlaceholder: "Buscar en todos los modelos...", searchPlaceholder: "Buscar modelos...", openProviderSettings: "Abrir configuración de{{provider}}", }, @@ -2240,6 +2244,44 @@ export const es: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Perfiles de agente", + unavailable: "Conéctese a este host para administrar perfiles de agente", + unsupported: "Este host ejecuta un daemon que aún no admite perfiles de agente", + emptyState: + "Aún no hay perfiles. Agregue uno para iniciar agentes con un proveedor y un modelo guardados.", + addProfileTitle: "Agregar perfil de agente", + newProfile: "Nuevo perfil", + editProfile: "Editar perfil", + editProfileTitle: "Editar perfil de agente", + nameLabel: "Nombre", + namePlaceholder: "Trabajo de UI", + iconLabel: "Icono", + noIcon: "Ninguno", + providerLabel: "Proveedor", + providerPlaceholder: "Seleccione un proveedor", + noProviders: "No hay proveedores disponibles en este host", + modelLabel: "Modelo", + noModels: "Este proveedor no tiene modelos para elegir", + modeLabel: "Modo", + noModes: "Este proveedor no tiene modos para elegir", + thinkingLabel: "Pensamiento", + noThinkingOptions: "Este modelo no tiene niveles de pensamiento", + featuresLabel: "Características", + featureCount: "{{count}} características", + featureCountOne: "{{count}} característica", + notesLabel: "Notas para los agentes", + notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.", + notesHint: + 'Devuelto por la herramienta MCP "list_profiles". Escríbalo como una instrucción para otro agente.', + save: "Guardar", + saving: "Guardando...", + remove: "Eliminar", + removeConfirmTitle: "¿Eliminar perfil?", + removeConfirmMessage: '¿Eliminar "{{name}}"?', + moveUp: "Mover hacia arriba", + moveDown: "Mover hacia abajo", + }, daemon: { rename: { editLabel: "Editar etiqueta", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 482617e91..22d4bec50 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1441,14 +1441,18 @@ export const fr: TranslationResources = { loadingSelector: "Chargement du sélecteur de modèle...", error: "Erreur", defaultModel: "Défaut", - favorites: "Favoris", - favoriteModel: "Modèle préféré", - unfavoriteModel: "Modèle défavorisé", + profiles: "Profils", + providers: "Fournisseurs", + editProfiles: "Modifier", + editProfilesLabel: "Modifier les profils d'agent", + createProfile: "Créer un profil", modelCount: "{{count}} modèle", modelCountPlural: "{{count}} modèles", retry: "Réessayer", retrying: "Nouvelle tentative...", noMatches: "Aucun modèle ne correspond à votre recherche", + noMatchesForQuery: "Aucun modèle ne correspond à « {{query}} »", + searchAllPlaceholder: "Rechercher dans tous les modèles...", searchPlaceholder: "Rechercher des modèles...", openProviderSettings: "Ouvrir les paramètres{{provider}}", }, @@ -2244,6 +2248,46 @@ export const fr: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Profils d'agent", + unavailable: "Connectez-vous à cet hôte pour gérer les profils d'agent", + unsupported: + "Cet hôte exécute un daemon qui ne prend pas encore en charge les profils d'agent", + emptyState: + "Aucun profil pour le moment. Ajoutez-en un pour démarrer des agents à partir d'un fournisseur et d'un modèle enregistrés.", + addProfileTitle: "Ajouter un profil d'agent", + newProfile: "Nouveau profil", + editProfile: "Modifier le profil", + editProfileTitle: "Modifier le profil d'agent", + nameLabel: "Nom", + namePlaceholder: "Travail sur l'UI", + iconLabel: "Icône", + noIcon: "Aucune", + providerLabel: "Fournisseur", + providerPlaceholder: "Sélectionner un fournisseur", + noProviders: "Aucun fournisseur n'est disponible sur cet hôte", + modelLabel: "Modèle", + noModels: "Ce fournisseur n'a aucun modèle à choisir", + modeLabel: "Mode", + noModes: "Ce fournisseur n'a aucun mode à choisir", + thinkingLabel: "Pensée", + noThinkingOptions: "Ce modèle n'a aucun niveau de pensée", + featuresLabel: "Caractéristiques", + featureCount: "{{count}} caractéristiques", + featureCountOne: "{{count}} caractéristique", + notesLabel: "Notes pour les agents", + notesPlaceholder: + "À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.", + notesHint: + "Renvoyé par l'outil MCP « list_profiles ». Rédigez-le comme une instruction destinée à un autre agent.", + save: "Enregistrer", + saving: "Enregistrement...", + remove: "Supprimer", + removeConfirmTitle: "Supprimer le profil ?", + removeConfirmMessage: "Supprimer « {{name}} » ?", + moveUp: "Déplacer vers le haut", + moveDown: "Déplacer vers le bas", + }, daemon: { rename: { editLabel: "Modifier l'étiquette", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 787b0458b..e6dd25e26 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1410,14 +1410,18 @@ export const ja: TranslationResources = { loadingSelector: "モデルセレクターを読み込み中...", error: "エラー", defaultModel: "デフォルト", - favorites: "お気に入り", - favoriteModel: "お気に入りモデル", - unfavoriteModel: "お気に入りを解除", + profiles: "プロファイル", + providers: "プロバイダー", + editProfiles: "編集", + editProfilesLabel: "エージェントプロファイルを編集", + createProfile: "プロファイルを作成", modelCount: "{{count}}つのモデル", modelCountPlural: "{{count}}つのモデル", retry: "再試行", retrying: "再試行中...", noMatches: "検索に一致するモデルがありません", + noMatchesForQuery: "「{{query}}」に一致するモデルがありません", + searchAllPlaceholder: "すべてのモデルを検索...", searchPlaceholder: "モデルを検索...", openProviderSettings: "{{provider}}の設定を開く", }, @@ -2210,6 +2214,44 @@ export const ja: TranslationResources = { emptyState: "プロファイルがまだありません。特定のコマンドでターミナルを起動するために追加してください。", }, + agentProfiles: { + sectionTitle: "エージェントプロファイル", + unavailable: "エージェントプロファイルを管理するにはこのホストに接続してください", + unsupported: "このホストで動作するデーモンはまだエージェントプロファイルに対応していません", + emptyState: + "プロファイルがまだありません。保存したプロバイダーとモデルでエージェントを起動するために追加してください。", + addProfileTitle: "エージェントプロファイルを追加", + newProfile: "新しいプロファイル", + editProfile: "プロファイルを編集", + editProfileTitle: "エージェントプロファイルを編集", + nameLabel: "名前", + namePlaceholder: "UI作業", + iconLabel: "アイコン", + noIcon: "なし", + providerLabel: "プロバイダー", + providerPlaceholder: "プロバイダーを選択", + noProviders: "このホストで利用できるプロバイダーがありません", + modelLabel: "モデル", + noModels: "このプロバイダーには選択できるモデルがありません", + modeLabel: "モード", + noModes: "このプロバイダーには選択できるモードがありません", + thinkingLabel: "思考", + noThinkingOptions: "このモデルには思考レベルがありません", + featuresLabel: "機能", + featureCount: "{{count}}個の機能", + featureCountOne: "{{count}}個の機能", + notesLabel: "エージェントへのメモ", + notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。", + notesHint: + "list_profiles MCPツールによって返されます。他のエージェントへの指示として記述してください。", + save: "保存", + saving: "保存中...", + remove: "削除", + removeConfirmTitle: "プロファイルを削除しますか?", + removeConfirmMessage: '"{{name}}"を削除しますか?', + moveUp: "上に移動", + moveDown: "下に移動", + }, daemon: { rename: { editLabel: "ラベルを編集", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 33e4c9c3a..e6491869b 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1406,14 +1406,18 @@ export const ko: TranslationResources = { loadingSelector: "모델 선택기 불러오는 중...", error: "오류", defaultModel: "기본값", - favorites: "즐겨찾기", - favoriteModel: "모델 즐겨찾기", - unfavoriteModel: "모델 즐겨찾기 해제", + profiles: "프로필", + providers: "제공자", + editProfiles: "편집", + editProfilesLabel: "에이전트 프로필 편집", + createProfile: "프로필 만들기", modelCount: "모델 {{count}}개", modelCountPlural: "모델 {{count}}개", retry: "다시 시도", retrying: "다시 시도 중...", noMatches: "검색과 일치하는 모델이 없습니다", + noMatchesForQuery: '"{{query}}"과(와) 일치하는 모델이 없습니다', + searchAllPlaceholder: "모든 모델 검색...", searchPlaceholder: "모델 검색...", openProviderSettings: "{{provider}} 설정 열기", }, @@ -2202,6 +2206,44 @@ export const ko: TranslationResources = { save: "저장", emptyState: "아직 프로필이 없습니다. 특정 명령으로 터미널을 실행하려면 하나 추가하세요.", }, + agentProfiles: { + sectionTitle: "에이전트 프로필", + unavailable: "에이전트 프로필을 관리하려면 이 호스트에 연결하세요", + unsupported: "이 호스트에서 실행 중인 데몬은 아직 에이전트 프로필을 지원하지 않습니다", + emptyState: + "아직 프로필이 없습니다. 저장된 프로바이더와 모델로 에이전트를 시작하려면 하나 추가하세요.", + addProfileTitle: "에이전트 프로필 추가", + newProfile: "새 프로필", + editProfile: "프로필 편집", + editProfileTitle: "에이전트 프로필 편집", + nameLabel: "이름", + namePlaceholder: "UI 작업", + iconLabel: "아이콘", + noIcon: "없음", + providerLabel: "프로바이더", + providerPlaceholder: "프로바이더 선택", + noProviders: "이 호스트에서 사용 가능한 프로바이더가 없습니다", + modelLabel: "모델", + noModels: "이 프로바이더에는 선택할 수 있는 모델이 없습니다", + modeLabel: "모드", + noModes: "이 프로바이더에는 선택할 수 있는 모드가 없습니다", + thinkingLabel: "사고", + noThinkingOptions: "이 모델에는 사고 수준이 없습니다", + featuresLabel: "기능", + featureCount: "기능 {{count}}개", + featureCountOne: "기능 {{count}}개", + notesLabel: "에이전트를 위한 메모", + notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.", + notesHint: + "list_profiles MCP 도구가 반환합니다. 다른 에이전트에게 전달할 지침으로 작성하세요.", + save: "저장", + saving: "저장하는 중...", + remove: "제거", + removeConfirmTitle: "프로필을 제거할까요?", + removeConfirmMessage: '"{{name}}"을(를) 제거할까요?', + moveUp: "위로 이동", + moveDown: "아래로 이동", + }, daemon: { rename: { editLabel: "레이블 편집", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2349fea3c..21b4091ea 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1423,14 +1423,18 @@ export const ptBR: TranslationResources = { loadingSelector: "Carregando seletor de modelos...", error: "Erro", defaultModel: "Padrão", - favorites: "Favoritos", - favoriteModel: "Favoritar modelo", - unfavoriteModel: "Remover modelo dos favoritos", + profiles: "Perfis", + providers: "Provedores", + editProfiles: "Editar", + editProfilesLabel: "Editar perfis de agente", + createProfile: "Criar perfil", modelCount: "{{count}} modelo", modelCountPlural: "{{count}} modelos", retry: "Tentar novamente", retrying: "Tentando novamente...", noMatches: "Nenhum modelo corresponde à sua busca", + noMatchesForQuery: "Nenhum modelo corresponde a «{{query}}»", + searchAllPlaceholder: "Buscar em todos os modelos...", searchPlaceholder: "Buscar modelos...", openProviderSettings: "Abrir configurações de {{provider}}", }, @@ -2225,6 +2229,44 @@ export const ptBR: TranslationResources = { emptyState: "Nenhum perfil ainda. Adicione um para abrir terminais com um comando específico.", }, + agentProfiles: { + sectionTitle: "Perfis de agente", + unavailable: "Conecte-se a este host para gerenciar perfis de agente", + unsupported: "Este host executa um daemon que ainda não é compatível com perfis de agente", + emptyState: + "Nenhum perfil ainda. Adicione um para iniciar agentes a partir de um provedor e modelo salvos.", + addProfileTitle: "Adicionar perfil de agente", + newProfile: "Novo perfil", + editProfile: "Editar perfil", + editProfileTitle: "Editar perfil de agente", + nameLabel: "Nome", + namePlaceholder: "Trabalho de UI", + iconLabel: "Ícone", + noIcon: "Nenhum", + providerLabel: "Provedor", + providerPlaceholder: "Selecione um provedor", + noProviders: "Nenhum provedor está disponível neste host", + modelLabel: "Modelo", + noModels: "Este provedor não tem modelos para escolher", + modeLabel: "Modo", + noModes: "Este provedor não tem modos para escolher", + thinkingLabel: "Raciocínio", + noThinkingOptions: "Este modelo não tem níveis de raciocínio", + featuresLabel: "Recursos", + featureCount: "{{count}} recursos", + featureCountOne: "{{count}} recurso", + notesLabel: "Notas para os agentes", + notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.", + notesHint: + 'Retornado pela ferramenta MCP "list_profiles". Escreva como uma instrução para outro agente.', + save: "Salvar", + saving: "Salvando...", + remove: "Remover", + removeConfirmTitle: "Remover perfil?", + removeConfirmMessage: 'Remover "{{name}}"?', + moveUp: "Mover para cima", + moveDown: "Mover para baixo", + }, daemon: { rename: { editLabel: "Editar rótulo", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9528a8503..7ac503334 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1428,14 +1428,18 @@ export const ru: TranslationResources = { loadingSelector: "Загрузка выбора модели...", error: "Ошибка", defaultModel: "По умолчанию", - favorites: "Избранное", - favoriteModel: "Любимая модель", - unfavoriteModel: "Нелюбимая модель", + profiles: "Профили", + providers: "Провайдеры", + editProfiles: "Изменить", + editProfilesLabel: "Изменить профили агентов", + createProfile: "Создать профиль", modelCount: "{{count}} модель", modelCountPlural: "{{count}} моделей", retry: "Повторить попытку", retrying: "Повторная попытка...", noMatches: "Ни одна модель не соответствует вашему запросу", + noMatchesForQuery: "Нет моделей, соответствующих «{{query}}»", + searchAllPlaceholder: "Поиск по всем моделям...", searchPlaceholder: "Поиск моделей...", openProviderSettings: "Открыть настройки{{provider}}", }, @@ -2229,6 +2233,44 @@ export const ru: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Профили агента", + unavailable: "Подключитесь к этому хосту, чтобы управлять профилями агента", + unsupported: "На этом хосте работает daemon, который пока не поддерживает профили агента", + emptyState: + "Пока нет профилей. Добавьте один, чтобы запускать агентов с сохранённым провайдером и моделью.", + addProfileTitle: "Добавить профиль агента", + newProfile: "Новый профиль", + editProfile: "Изменить профиль", + editProfileTitle: "Изменить профиль агента", + nameLabel: "Имя", + namePlaceholder: "Работа над UI", + iconLabel: "Значок", + noIcon: "Нет", + providerLabel: "Провайдер", + providerPlaceholder: "Выберите провайдера", + noProviders: "На этом хосте нет доступных провайдеров", + modelLabel: "Модель", + noModels: "У этого провайдера нет моделей для выбора", + modeLabel: "Режим", + noModes: "У этого провайдера нет режимов для выбора", + thinkingLabel: "Мышление", + noThinkingOptions: "У этой модели нет уровней мышления", + featuresLabel: "Функции", + featureCount: "{{count}} функций", + featureCountOne: "{{count}} функция", + notesLabel: "Заметки для агентов", + notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.", + notesHint: + "Возвращается инструментом MCP «list_profiles». Напишите это как инструкцию для другого агента.", + save: "Сохранить", + saving: "Сохранение...", + remove: "Удалить", + removeConfirmTitle: "Удалить профиль?", + removeConfirmMessage: "Удалить «{{name}}»?", + moveUp: "Переместить вверх", + moveDown: "Переместить вниз", + }, daemon: { rename: { editLabel: "Изменить ярлык", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 08ada0995..e44f6c103 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1379,14 +1379,18 @@ export const zhCN: TranslationResources = { loadingSelector: "正在加载模型选择器...", error: "错误", defaultModel: "默认", - favorites: "收藏", - favoriteModel: "收藏模型", - unfavoriteModel: "取消收藏模型", + profiles: "配置文件", + providers: "提供方", + editProfiles: "编辑", + editProfilesLabel: "编辑智能体配置文件", + createProfile: "创建配置文件", modelCount: "{{count}} 个模型", modelCountPlural: "{{count}} 个模型", retry: "重试", retrying: "正在重试...", noMatches: "没有匹配的模型", + noMatchesForQuery: "没有与“{{query}}”匹配的模型", + searchAllPlaceholder: "搜索所有模型...", searchPlaceholder: "搜索模型...", openProviderSettings: "打开 {{provider}} 设置", }, @@ -2167,6 +2171,42 @@ export const zhCN: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Agent 配置", + unavailable: "连接到这个 Host 以管理 Agent 配置", + unsupported: "此 Host 运行的 Daemon 尚不支持 Agent 配置", + emptyState: "还没有配置。添加一个以便使用保存的 Provider 和 Model 启动 Agent。", + addProfileTitle: "添加 Agent 配置", + newProfile: "新建配置", + editProfile: "编辑配置", + editProfileTitle: "编辑 Agent 配置", + nameLabel: "名称", + namePlaceholder: "UI 工作", + iconLabel: "图标", + noIcon: "无", + providerLabel: "Provider", + providerPlaceholder: "选择一个 Provider", + noProviders: "此 Host 上没有可用的 Provider", + modelLabel: "Model", + noModels: "此 Provider 没有可选择的 Model", + modeLabel: "Mode", + noModes: "此 Provider 没有可选择的 Mode", + thinkingLabel: "Thinking", + noThinkingOptions: "此 Model 没有 Thinking 级别", + featuresLabel: "Features", + featureCount: "{{count}} 个 Feature", + featureCountOne: "{{count}} 个 Feature", + notesLabel: "给 Agent 的说明", + notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。", + notesHint: "由 list_profiles MCP 工具返回。请将其写成给另一个 Agent 的指令。", + save: "保存", + saving: "保存中...", + remove: "移除", + removeConfirmTitle: "移除配置?", + removeConfirmMessage: "移除「{{name}}」?", + moveUp: "上移", + moveDown: "下移", + }, daemon: { rename: { editLabel: "编辑标签", diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 4c40fe61b..690bd439a 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -3,6 +3,7 @@ import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/prot import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { i18n } from "@/i18n/i18next"; import { + buildProviderQualifiedDescription, buildProviderSelectorProviders, buildSelectableProviderSelectorProviders, buildSelectedTriggerLabel, @@ -246,6 +247,20 @@ describe("combined model selector data", () => { expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4"); }); + it("names the provider first when a model row is shown outside its provider", () => { + const row = { + favoriteKey: "copilot:claude-opus-5", + provider: "copilot", + providerLabel: "Copilot", + modelId: "claude-opus-5", + modelLabel: "Opus 5", + description: "claude-opus-5", + }; + + expect(buildProviderQualifiedDescription(row)).toBe("Copilot · claude-opus-5"); + expect(buildProviderQualifiedDescription({ ...row, description: undefined })).toBe("Copilot"); + }); + it("resolves selected labels from explicit provider model-selection state", () => { const providers = buildSelectableProviderSelectorProviders([ snapshotEntry({ diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index 6e3a48de3..ca8be17b8 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -6,12 +6,28 @@ import type { } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; -import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; import { i18n } from "@/i18n/i18next"; import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { filterSelectableModels } from "./model-catalog"; -export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean }; +export interface ProviderSelectionModelRow { + /** + * Row identity — `provider:modelId`. Used as the React key and as the row's + * stable handle across list rebuilds. The name is historical; it has nothing + * to do with the removed favourites feature. + */ + favoriteKey: string; + provider: string; + providerLabel: string; + modelId: string; + modelLabel: string; + description?: string; + isDefault?: boolean; +} + +function buildModelRowKey(provider: string, modelId: string): string { + return `${provider}:${modelId}`; +} export type ProviderModelSelection = | { kind: "models"; rows: ProviderSelectionModelRow[] } @@ -44,7 +60,7 @@ function buildModelRows( models: AgentModelDefinition[], ): ProviderSelectionModelRow[] { return models.map((model) => ({ - favoriteKey: buildFavoriteModelKey({ provider, modelId: model.id }), + favoriteKey: buildModelRowKey(provider, model.id), provider, providerLabel, modelId: model.id, @@ -59,7 +75,7 @@ function buildSyntheticDefaultRow( providerLabel: string, ): ProviderSelectionModelRow { return { - favoriteKey: buildFavoriteModelKey({ provider, modelId: "" }), + favoriteKey: buildModelRowKey(provider, ""), provider, providerLabel, modelId: "", @@ -198,6 +214,14 @@ export function buildSelectedTriggerLabel(modelLabel: string): string { return modelLabel; } +/** + * Cross-provider result rows need the provider named up front: the same model + * label ships on several providers at once. + */ +export function buildProviderQualifiedDescription(row: ProviderSelectionModelRow): string { + return row.description ? `${row.providerLabel} · ${row.description}` : row.providerLabel; +} + export function matchesModelSearch( row: ProviderSelectionModelRow, normalizedQuery: string, diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 82d029046..f6170394c 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -310,7 +310,6 @@ describe("mergeSelectedComposerPreferences", () => { }, claude: { model: "claude-sonnet-4-6" }, }, - favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }], }, provider: "codex", updates: { model: "gpt-5.4" }, @@ -326,7 +325,6 @@ describe("mergeSelectedComposerPreferences", () => { }, claude: { model: "claude-sonnet-4-6" }, }, - favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }], }); }); diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 9a2a41334..85c0ac25d 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -21,6 +21,7 @@ import { getTerminalProfileIcon, DEFAULT_TERMINAL_PROFILES, } from "@getpaseo/protocol/terminal-profiles"; +import { AgentProfilesSection } from "@/agent-profiles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { Alert as InlineAlert } from "@/components/ui/alert"; @@ -288,6 +289,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) { {t("settings.host.agents.unavailable")} )} + ); } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index d078d8473..6df09fb27 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -110,7 +110,11 @@ import type { AgentProvider, AgentSessionConfig, } from "@getpaseo/protocol/agent-types"; -import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; +import type { + AgentConfigApply, + MutableDaemonConfig, + MutableDaemonConfigPatch, +} from "@getpaseo/protocol/messages"; import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; import { @@ -3131,6 +3135,44 @@ export class DaemonClient { return payload.notice ?? null; } + /** + * Applies a whole agent-config bundle in one request. Use this instead of + * chaining the single-field setters when the values belong together so client + * interruption and other mutations cannot interleave between steps. A + * provider rejection can still leave earlier steps applied. + * Gated on `server_info.features.agentConfigApply`. + */ + async applyAgentConfig( + agentId: string, + config: AgentConfigApply, + ): Promise { + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "agent.config.apply.request", + agentId, + config, + requestId, + }); + const payload = await this.sendRequest({ + requestId, + message, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "agent.config.apply.response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); + if (!payload.accepted) { + throw new Error(payload.error ?? "applyAgentConfig rejected"); + } + return payload.notice ?? null; + } + async restartServer(reason?: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 5cf5f5099..bb95c66a3 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -134,6 +134,35 @@ export const TerminalProfileSchema = z export type TerminalProfile = z.infer; +/** + * A named launch bundle: a provider plus the agent-config values a client would + * otherwise set one control at a time. Field names mirror `AgentSessionConfig` + * so applying a profile is a copy rather than a translation table. + * + * There is deliberately no system prompt here. `AgentSessionConfig.systemPrompt` + * is creation-only, so a profile carrying one would apply when starting a new + * agent and silently do nothing when applied to a running one. + */ +export const AgentProfileSchema = z + .object({ + id: z.string(), + name: z.string(), + /** A key into the client's icon registry, not a glyph. Unknown keys draw the default. */ + icon: z.string().optional(), + /** An identity colour name shared with host badges. Unknown values draw unthemed. */ + color: z.string().optional(), + provider: z.string(), + model: z.string().optional(), + modeId: z.string().optional(), + thinkingOptionId: z.string().optional(), + featureValues: z.record(z.string(), z.unknown()).optional(), + /** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */ + notes: z.string().optional(), + }) + .passthrough(); + +export type AgentProfile = z.infer; + const MutableBrowserToolsConfigSchema = z .object({ enabled: z.boolean().default(false), @@ -160,6 +189,7 @@ export const MutableDaemonConfigSchema = z enableTerminalAgentHooks: z.boolean().default(false), appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), + agentProfiles: z.array(AgentProfileSchema).optional(), }) .passthrough(); @@ -177,6 +207,7 @@ export const MutableDaemonConfigPatchSchema = z enableTerminalAgentHooks: z.boolean().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), + agentProfiles: z.array(AgentProfileSchema).optional(), }) .partial() .passthrough(); @@ -1544,6 +1575,38 @@ export const SetAgentFeatureResponseMessageSchema = z.object({ payload: AgentActionResponsePayloadSchema, }); +/** + * Every agent-config value a client can change in one shot. Each field is + * optional and an omitted field is left alone; `null` on model and thinking + * clears them, matching the single-field RPCs above. + */ +export const AgentConfigApplySchema = z.object({ + modelId: z.string().nullable().optional(), + modeId: z.string().optional(), + thinkingOptionId: z.string().nullable().optional(), + featureValues: z.record(z.string(), z.unknown()).optional(), +}); + +export type AgentConfigApply = z.infer; + +/** + * Applies a whole config bundle to one agent. The four single-field RPCs above + * stay for individual control edits. One request prevents client interruption + * and other mutations from interleaving between bundle steps; provider-level + * rejection can still leave earlier steps applied. + */ +export const AgentConfigApplyRequestMessageSchema = z.object({ + type: z.literal("agent.config.apply.request"), + agentId: z.string(), + config: AgentConfigApplySchema, + requestId: z.string(), +}); + +export const AgentConfigApplyResponseMessageSchema = z.object({ + type: z.literal("agent.config.apply.response"), + payload: AgentActionResponsePayloadSchema, +}); + export const AgentDetachRequestMessageSchema = z.object({ type: z.literal("agent.detach.request"), agentId: z.string(), @@ -2702,6 +2765,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModelRequestMessageSchema, SetAgentThinkingRequestMessageSchema, SetAgentFeatureRequestMessageSchema, + AgentConfigApplyRequestMessageSchema, AgentDetachRequestMessageSchema, AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, @@ -3071,6 +3135,13 @@ export const ServerInfoStatusPayloadSchema = z fsEntryDuplicate: z.boolean().optional(), // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. checkoutDiscardChanges: z.boolean().optional(), + // COMPAT(agentProfiles): added in v0.3.2, remove gate after 2027-02-11. + // An older daemon parses its persisted config strictly, so writing + // agentProfiles to one is silently dropped. The client hides the feature + // rather than letting a save appear to succeed. + agentProfiles: z.boolean().optional(), + // COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11. + agentConfigApply: z.boolean().optional(), }) .optional(), }) @@ -5648,6 +5719,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModelResponseMessageSchema, SetAgentThinkingResponseMessageSchema, SetAgentFeatureResponseMessageSchema, + AgentConfigApplyResponseMessageSchema, AgentDetachResponseMessageSchema, AgentRewindResponseMessageSchema, UpdateAgentResponseMessageSchema, @@ -5842,6 +5914,7 @@ export type SetAgentModeResponseMessage = z.infer; export type SetAgentThinkingResponseMessage = z.infer; export type SetAgentFeatureResponseMessage = z.infer; +export type AgentConfigApplyResponseMessage = z.infer; export type AgentDetachResponseMessage = z.infer; export type AgentRewindResponseMessage = z.infer; export type UpdateAgentResponseMessage = z.infer; @@ -6005,6 +6078,7 @@ export type SetAgentModeRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; export type SetAgentFeatureRequestMessage = z.infer; +export type AgentConfigApplyRequestMessage = z.infer; export type AgentDetachRequestMessage = z.infer; export type AgentPermissionResponseMessage = z.infer; export type CheckoutStatusRequest = z.infer; diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 209befc21..50867f10d 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -146,6 +146,15 @@ let parentAgentCwd: string; let worktreeRepoCwd: string; let launchConfigsByProvider: Record; +const seededAgentProfile = { + id: "ui-profile", + name: "UI work", + provider: "claude", + model: "claude-test-model", + modeId: "bypassPermissions", + notes: "Use for UI work: components, layout, design tokens. Not for backend.", +}; + function createRecordingAgentClients(): Record { const baseClients = createTestAgentClients(); launchConfigsByProvider = {}; @@ -285,7 +294,10 @@ beforeAll(async () => { parentAgentCwd = await makeCwd("parent-agent-cwd"); worktreeRepoCwd = await makeCwd("worktree-repo"); - daemonHandle = await createTestPaseoDaemon({ agentClients: createRecordingAgentClients() }); + daemonHandle = await createTestPaseoDaemon({ + agentClients: createRecordingAgentClients(), + agentProfiles: [seededAgentProfile], + }); topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`); const parentPayload = await callToolStructured(topLevelClient, "create_agent", { @@ -825,6 +837,12 @@ describe("Suite D: Provider Tools", () => { expect(payload.provider).toBe("claude"); expect(Array.isArray(payload.models)).toBe(true); }); + + test("list_profiles returns configured agent profiles, including notes", async () => { + const payload = await callToolStructured(topLevelClient, "list_profiles"); + const profiles = recordArr(payload.profiles); + expect(profiles).toEqual([seededAgentProfile]); + }); }); describe("Suite E: Worktree Tools", () => { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ce5367663..349eb15c5 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -52,6 +52,8 @@ import type { ForgeService } from "../../services/forge-service.js"; import { areEquivalentPaths } from "../../utils/path.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages"; +import type { DaemonConfigStore } from "../daemon-config-store.js"; import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js"; import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js"; import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js"; @@ -4901,6 +4903,78 @@ describe("provider listing MCP tool", () => { }); }); +function daemonConfigStoreStub(agentProfiles?: AgentProfile[]): Pick { + const config = MutableDaemonConfigSchema.parse({ + relay: { enabled: true }, + mcp: { injectIntoAgents: true }, + ...(agentProfiles !== undefined ? { agentProfiles } : {}), + }); + return { get: () => config }; +} + +describe("agent profile listing MCP tool", () => { + const logger = createTestLogger(); + + it("returns configured profiles, including notes", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const profiles: AgentProfile[] = [ + { + id: "ui-profile", + name: "UI work", + provider: "claude", + model: "claude-test-model", + modeId: "bypassPermissions", + thinkingOptionId: "high", + featureValues: { fast_mode: true }, + notes: "Use for UI work: components, layout, design tokens. Not for backend.", + }, + ]; + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + daemonConfigStore: daemonConfigStoreStub(profiles), + logger, + }); + const tool = registeredTool(server, "list_profiles"); + + const response = await tool.handler({}); + + expect(response.structuredContent).toEqual({ profiles }); + }); + + it("returns an empty array when no profiles are configured", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + daemonConfigStore: daemonConfigStoreStub(), + logger, + }); + const tool = registeredTool(server, "list_profiles"); + + const response = await tool.handler({}); + + expect(response.structuredContent).toEqual({ profiles: [] }); + }); + + it("returns an empty array when no daemon config store is provided", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "list_profiles"); + + const response = await tool.handler({}); + + expect(response.structuredContent).toEqual({ profiles: [] }); + }); +}); + describe("provider MCP tools", () => { const logger = createTestLogger(); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 2aa69906d..fcc314ac1 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -4,6 +4,8 @@ import type { Logger } from "pino"; import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentManager } from "../agent-manager.js"; +import { AgentProfileSchema } from "@getpaseo/protocol/messages"; +import type { DaemonConfigStore } from "../../daemon-config-store.js"; import { AgentFeatureSchema, AgentPermissionRequestPayloadSchema, @@ -98,6 +100,7 @@ export interface PaseoToolHostDependencies { getDaemonTcpPort?: () => number | null; scheduleService?: ScheduleService | null; providerSnapshotManager: ProviderSnapshotManager; + daemonConfigStore?: Pick; github?: ForgeService; workspaceGitService?: Pick< WorkspaceGitService, @@ -543,6 +546,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase workspaceScripts, scheduleService, providerSnapshotManager, + daemonConfigStore, callerAgentId, resolveSpeakHandler, resolveCallerContext, @@ -2919,6 +2923,30 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); + registerTool( + "list_profiles", + { + title: "List agent profiles", + description: + "List agent profiles: named provider/model/mode bundles a human configured for specific " + + "kinds of work. Read each profile's `notes` to pick the one that fits the task you're " + + "delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " + + "`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " + + "list if none are configured.", + inputSchema: {}, + outputSchema: { + profiles: z.array(AgentProfileSchema), + }, + }, + async () => { + const profiles = daemonConfigStore?.get().agentProfiles ?? []; + return { + content: [], + structuredContent: ensureValidJson({ profiles }), + }; + }, + ); + registerTool( "inspect_provider", { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e5f12972c..fad665b10 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -168,7 +168,7 @@ import type { PushNotificationSender } from "./push/index.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; -import type { FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages"; +import type { AgentProfile, FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages"; import type { AgentProviderRuntimeSettingsMap, ProviderOverride, @@ -397,6 +397,7 @@ export interface PaseoDaemonConfig { enableTerminalAgentHooks?: boolean; appendSystemPrompt?: string; terminalProfiles?: TerminalProfile[]; + agentProfiles?: AgentProfile[]; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -536,6 +537,10 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae initialConfig.terminalProfiles = config.terminalProfiles; } + if (config.agentProfiles !== undefined) { + initialConfig.agentProfiles = config.agentProfiles; + } + return initialConfig; } @@ -1235,6 +1240,7 @@ export async function createPaseoDaemon( getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), scheduleService, providerSnapshotManager, + daemonConfigStore, github, workspaceGitService, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 93ab9409a..2930d1f0b 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -438,6 +438,18 @@ function resolveBrowserToolsEnabled(persisted: ReturnType) { + return { + terminalProfiles: persisted.daemon?.terminalProfiles, + agentProfiles: persisted.daemon?.agentProfiles, + }; +} + function resolveStaticLoadConfigSettings( env: NodeJS.ProcessEnv, cli: CliConfigOverrides | undefined, @@ -450,7 +462,7 @@ function resolveStaticLoadConfigSettings( browserToolsEnabled: resolveBrowserToolsEnabled(persisted), autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false, appendSystemPrompt: resolveAppendSystemPrompt(persisted), - terminalProfiles: persisted.daemon?.terminalProfiles, + ...resolveProfileLists(persisted), hostnames: mergeHostnames([ persisted.daemon?.hostnames, parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), @@ -479,6 +491,7 @@ export function loadConfig( autoArchiveAfterMerge, appendSystemPrompt, terminalProfiles, + agentProfiles, hostnames, trustedProxies, appBaseUrl, @@ -520,6 +533,7 @@ export function loadConfig( enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, appendSystemPrompt, terminalProfiles, + agentProfiles, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index e73150fec..1d9bcb1e8 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -85,6 +85,76 @@ describe("DaemonConfigStore", () => { expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true); }); + test("patch round-trips agent profiles through the strictly-parsed persisted config", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }); + + store.patch({ + agentProfiles: [ + { + id: "profile_ui", + name: "UI work", + icon: "🎨", + provider: "claude", + model: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + featureValues: { webSearch: true }, + notes: "Use for components, layout and design tokens.", + }, + ], + }); + + expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([ + { + id: "profile_ui", + name: "UI work", + icon: "🎨", + provider: "claude", + model: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + featureValues: { webSearch: true }, + notes: "Use for components, layout and design tokens.", + }, + ]); + expect(store.get().agentProfiles).toHaveLength(1); + }); + + test("patch replaces the whole agent profile list rather than merging entries", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + agentProfiles: [ + { id: "a", name: "Keep", provider: "claude" }, + { id: "b", name: "Drop", provider: "codex" }, + ], + }); + + store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] }); + + expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]); + expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1); + }); + test("rolls back config when a field transition fails", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index f77fccc06..9ed029e08 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -359,6 +359,7 @@ function mergeMutableConfigIntoPersistedConfig(params: { ...(mutable.terminalProfiles !== undefined ? { terminalProfiles: mutable.terminalProfiles } : {}), + ...(mutable.agentProfiles !== undefined ? { agentProfiles: mutable.agentProfiles } : {}), }, agents: nextAgents, } as PersistedConfig; diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index e86445068..9a4dbd11b 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -9,7 +9,7 @@ import { } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; -import { TerminalProfileSchema } from "@getpaseo/protocol/messages"; +import { AgentProfileSchema, TerminalProfileSchema } from "@getpaseo/protocol/messages"; import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema"; export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); @@ -259,6 +259,7 @@ export const PersistedConfigSchema = z enableTerminalAgentHooks: z.boolean().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), + agentProfiles: z.array(AgentProfileSchema).optional(), cors: z .object({ allowedOrigins: z.array(z.string()).optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3f47aeb3b..1fb8228a8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2010,6 +2010,8 @@ export class Session { return this.agentConfigSession.handleSetAgentFeatureRequest(msg); case "set_agent_thinking_request": return this.agentConfigSession.handleSetAgentThinkingRequest(msg); + case "agent.config.apply.request": + return this.agentConfigSession.handleAgentConfigApplyRequest(msg); case "get_daemon_config_request": this.emit({ type: "get_daemon_config_response", diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts index f6d35a1f3..1592c5bb3 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -14,6 +14,8 @@ class FakeAgentConfigOperations implements AgentConfigOperations { readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = []; readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = []; readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = []; + /** Cross-operation ordering, which the per-operation arrays above cannot show. */ + readonly callLog: string[] = []; modeNotice: AgentProviderNotice | null = null; thinkingNotice: AgentProviderNotice | null = null; loadFailure: Error | null = null; @@ -26,17 +28,20 @@ class FakeAgentConfigOperations implements AgentConfigOperations { async setMode(agentId: string, modeId: string): Promise { this.modeCalls.push({ agentId, modeId }); + this.callLog.push("mode"); if (this.failWith) throw this.failWith; return this.modeNotice; } async setModel(agentId: string, modelId: string | null): Promise { this.modelCalls.push({ agentId, modelId }); + this.callLog.push("model"); if (this.failWith) throw this.failWith; } async setFeature(agentId: string, featureId: string, value: unknown): Promise { this.featureCalls.push({ agentId, featureId, value }); + this.callLog.push(`feature:${featureId}`); if (this.failWith) throw this.failWith; } @@ -45,6 +50,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations { thinkingOptionId: string | null, ): Promise { this.thinkingCalls.push({ agentId, thinkingOptionId }); + this.callLog.push("thinking"); if (this.failWith) throw this.failWith; return this.thinkingNotice; } @@ -302,4 +308,123 @@ describe("AgentConfigSession", () => { payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "thinking boom" }, }); }); + + test("apply: sets model before thinking and features last, in one accepted response", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { + modelId: "opus-5", + modeId: "plan", + thinkingOptionId: "high", + featureValues: { webSearch: true }, + }, + requestId: "req-1", + }); + + expect(operations.callLog).toEqual(["model", "mode", "thinking", "feature:webSearch"]); + expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]); + expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]); + expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]); + expect(operations.featureCalls).toEqual([ + { agentId: "agent-1", featureId: "webSearch", value: true }, + ]); + expect(operations.loadedAgentIds).toEqual(["agent-1"]); + expect(emitted).toEqual([ + { + type: "agent.config.apply.response", + payload: { + requestId: "req-1", + agentId: "agent-1", + accepted: true, + error: null, + notice: null, + }, + }, + ]); + }); + + test("apply: leaves omitted fields alone", async () => { + const { subsystem, operations } = makeSubsystem(); + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { modelId: "haiku-4.5" }, + requestId: "req-1", + }); + + expect(operations.callLog).toEqual(["model"]); + }); + + test("apply: clears model and thinking when they are explicitly null", async () => { + const { subsystem, operations } = makeSubsystem(); + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { modelId: null, thinkingOptionId: null }, + requestId: "req-1", + }); + + expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: null }]); + expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: null }]); + }); + + test("apply: reports the first notice and still runs the rest of the bundle", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.modeNotice = { type: "info", message: "Switched to plan mode" }; + operations.thinkingNotice = { type: "warning", message: "Thinking budget reduced" }; + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } }, + requestId: "req-1", + }); + + expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]); + expect(emitted).toEqual([ + { + type: "agent.config.apply.response", + payload: { + requestId: "req-1", + agentId: "agent-1", + accepted: true, + error: null, + notice: { type: "info", message: "Switched to plan mode" }, + }, + }, + ]); + }); + + test("apply: a mid-bundle failure rejects and leaves the earlier steps applied", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.failWith = new Error("apply boom"); + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { modelId: "opus-5", modeId: "plan" }, + requestId: "req-1", + }); + + expect(operations.callLog).toEqual(["model"]); + expect(emitted.map((m) => m.type)).toEqual(["activity_log", "agent.config.apply.response"]); + expect(emitted[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to apply agent config: apply boom", + }, + }); + expect(emitted[1]).toEqual({ + type: "agent.config.apply.response", + payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "apply boom" }, + }); + }); }); diff --git a/packages/server/src/server/session/agent-config/agent-config-session.ts b/packages/server/src/server/session/agent-config/agent-config-session.ts index dab811b98..8cf239419 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.ts @@ -1,12 +1,13 @@ import type pino from "pino"; import { v4 as uuidv4 } from "uuid"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; +import type { AgentConfigApply } from "@getpaseo/protocol/messages"; import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; /** - * The four agent-config response messages share one payload shape; deriving the - * type keeps it pinned to the protocol schema instead of restating it. + * The agent-config response messages share one payload shape; deriving the type + * keeps it pinned to the protocol schema instead of restating it. */ type AgentActionResponsePayload = Extract< SessionOutboundMessage, @@ -51,11 +52,11 @@ interface ConfigChange { } /** - * A client's per-agent config surface: set mode, model, feature, and thinking - * option. Each request shares one envelope — log, run the mutation, then emit the - * accepted response, or on failure emit an activity_log error frame followed by - * the rejected response. Reaches no state beyond the injected operations and the - * outbound channel. + * A client's per-agent config surface: set mode, model, feature, or thinking + * option one at a time, or apply all of them together. Each request shares one + * envelope — log, run the mutation, then emit the accepted response, or on + * failure emit an activity_log error frame followed by the rejected response. + * Reaches no state beyond the injected operations and the outbound channel. */ export class AgentConfigSession { private readonly host: AgentConfigSessionHost; @@ -134,6 +135,67 @@ export class AgentConfigSession { }); } + handleAgentConfigApplyRequest( + msg: Extract, + ): Promise { + const { agentId, config, requestId } = msg; + return this.applyConfigChange({ + agentId, + requestId, + logLabel: "agent.config.apply.request", + logFields: { agentId, requestId, config }, + failureText: "Failed to apply agent config", + run: () => this.applyBundle(agentId, config), + emitResponse: (payload) => this.host.emit({ type: "agent.config.apply.response", payload }), + }); + } + + /** + * Model first, because a thinking option only means anything against the model + * that offers it; features last so they settle on top of the rest. An omitted + * field is left alone. The first notice produced is the one the client sees — + * later steps still run, so a notice never truncates the bundle. + * + * This is not a transaction. A step that rejects aborts the rest, and steps + * that already applied stay applied — provider setters validate against the + * agent's live model, so a bundle that omits `modelId` can have its mode land + * and its feature rejected. Nothing is hidden by that: every AgentManager + * setter emits agent state before returning, so what applied is already + * streamed to the client alongside the rejected response and the error frame. + * + * Rolling back would re-invoke the setters that just failed, most often + * against a session that is already gone. Pre-flight validation would restate + * each provider's model-dependent checks somewhere they would drift from the + * setters that own them. What sending the bundle in one request does remove is + * the multi-round-trip failure class: interruption between hops, and + * interleaving with another client's mutation. + */ + private async applyBundle( + agentId: string, + config: AgentConfigApply, + ): Promise { + let notice: AgentProviderNotice | null = null; + + if (config.modelId !== undefined) { + await this.operations.setModel(agentId, config.modelId); + } + if (config.modeId !== undefined) { + // Await first, assign second: `notice ??= await ...` would skip the call + // entirely once an earlier step produced a notice. + const modeNotice = await this.operations.setMode(agentId, config.modeId); + notice ??= modeNotice; + } + if (config.thinkingOptionId !== undefined) { + const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId); + notice ??= thinkingNotice; + } + for (const [featureId, value] of Object.entries(config.featureValues ?? {})) { + await this.operations.setFeature(agentId, featureId, value); + } + + return notice; + } + private async applyConfigChange(change: ConfigChange): Promise { const { agentId, requestId, logLabel, logFields, failureText, run, emitResponse } = change; this.logger.info(logFields, `session: ${logLabel}`); diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 8481c4876..3891d90ea 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -12,6 +12,7 @@ import { import type { AgentClient, AgentProvider } from "../agent/agent-sdk-types.js"; import { createTestAgentClients } from "./fake-agent-client.js"; import type { PushNotificationSender } from "../push/index.js"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; interface TestPaseoDaemonOptions { daemonVersion?: string; @@ -45,6 +46,7 @@ interface TestPaseoDaemonOptions { serviceProxy?: PaseoDaemonConfig["serviceProxy"]; webUi?: PaseoDaemonConfig["webUi"]; trustedProxies?: PaseoDaemonConfig["trustedProxies"]; + agentProfiles?: AgentProfile[]; } export interface TestPaseoDaemon { @@ -194,6 +196,7 @@ async function prepareTestDaemonConfig( voiceLlmModel: options.voiceLlmModel ?? null, dictationFinalTimeoutMs: options.dictationFinalTimeoutMs, downloadTokenTtlMs: options.downloadTokenTtlMs, + agentProfiles: options.agentProfiles, }; return { config, paseoHomeRoot, paseoHome, staticDir }; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index e3d643d48..d14681b53 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1629,6 +1629,10 @@ export class VoiceAssistantWebSocketServer { fsEntryDuplicate: true, // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. checkoutDiscardChanges: true, + // COMPAT(agentProfiles): added in v0.3.2, remove gate after 2027-02-11. + agentProfiles: true, + // COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11. + agentConfigApply: true, }, }; }