mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
* feat(app): create agent profiles from the model chooser Restore quick model pinning by creating an agent profile directly from a model row. A trailing plus on unprofiled model rows opens the pre-filled profile form; rows that already name the provider+model show the profile glyph and open it for edit. - seed the profile form model with provider, model, and suggested name - expose useAgentProfileEditor from @/agent-profiles for the write path - hover-refactor model rows per docs/hover.md and add the trailing action - wire the composer desktop selector and compact model sheet (close-then-open) Closes #3529 * fix(app): match favourites star position for model row profile action The star sat after the selection checkmark at the far-right edge. Put the profile create/edit action in the same slot instead of before the checkmark. * fix(app): disambiguate per-model create profile action The per-model + buttons shared the accessible name "Create profile" with the empty-state row, which broke the empty-host picker test (strict mode matched five buttons). Name the action "Create profile from <model>" so it is specific and screen readers get the model context. * fix(app): keep per-model create label out of model row names Naming the action "Create profile from <model>" put the model label into a second button on the row, so regex selectors targeting a model row by name matched both. Use a generic "Create profile from this model" label that neither collides with the empty-state "Create profile" row nor with model row names.
222 lines
6.2 KiB
TypeScript
222 lines
6.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type {
|
|
ProviderSelectionModelRow,
|
|
ProviderSelectorProvider,
|
|
} from "@/provider-selection/provider-selection";
|
|
import {
|
|
resolveInitialModelBrowserView,
|
|
resolveModelBrowserAllView,
|
|
groupProfilesByProviderModel,
|
|
} from "./model-browser-view";
|
|
|
|
function provider(
|
|
id: string,
|
|
label: string,
|
|
rows: ProviderSelectionModelRow[] = [],
|
|
): ProviderSelectorProvider {
|
|
return {
|
|
id,
|
|
label,
|
|
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,
|
|
};
|
|
}
|
|
|
|
describe("model browser initial view", () => {
|
|
const codex = provider("codex", "Codex");
|
|
const pi = provider("pi", "Pi");
|
|
|
|
it("opens a sole provider directly", () => {
|
|
expect(
|
|
resolveInitialModelBrowserView({
|
|
providers: [pi],
|
|
selectedProvider: "",
|
|
selectedModel: "",
|
|
hasProfiles: false,
|
|
}),
|
|
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
|
|
});
|
|
|
|
it("opens the selected provider when there is one", () => {
|
|
expect(
|
|
resolveInitialModelBrowserView({
|
|
providers: [codex, pi],
|
|
selectedProvider: "pi",
|
|
selectedModel: "pi-pro",
|
|
hasProfiles: false,
|
|
}),
|
|
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
|
|
});
|
|
|
|
it("opens the root so pinned profiles are reachable", () => {
|
|
expect(
|
|
resolveInitialModelBrowserView({
|
|
providers: [codex, pi],
|
|
selectedProvider: "pi",
|
|
selectedModel: "pi-pro",
|
|
hasProfiles: true,
|
|
}),
|
|
).toEqual({ kind: "all" });
|
|
});
|
|
|
|
it("opens a sole provider directly regardless of root content", () => {
|
|
expect(
|
|
resolveInitialModelBrowserView({
|
|
providers: [pi],
|
|
selectedProvider: "pi",
|
|
selectedModel: "pi-pro",
|
|
hasProfiles: true,
|
|
}),
|
|
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
|
|
});
|
|
|
|
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("groupProfilesByProviderModel", () => {
|
|
it("groups profiles by provider and model, skipping profiles without a model", () => {
|
|
const lookup = groupProfilesByProviderModel([
|
|
{ provider: "claude", modelId: "opus-5" },
|
|
{ provider: "claude", modelId: "opus-5" },
|
|
{ provider: "claude", modelId: "sonnet-4.6" },
|
|
{ provider: "claude", modelId: "" },
|
|
{ provider: "codex", modelId: "gpt-5.4" },
|
|
]);
|
|
|
|
expect(lookup.get("claude:opus-5")).toHaveLength(2);
|
|
expect(lookup.get("claude:sonnet-4.6")).toHaveLength(1);
|
|
expect(lookup.get("codex:gpt-5.4")).toHaveLength(1);
|
|
expect(lookup.has("claude:")).toBe(false);
|
|
});
|
|
|
|
it("trims model ids so whitespace cannot create a separate key", () => {
|
|
const lookup = groupProfilesByProviderModel([
|
|
{ provider: "claude", modelId: "opus-5" },
|
|
{ provider: "claude", modelId: " opus-5 " },
|
|
]);
|
|
|
|
expect(lookup.get("claude:opus-5")).toHaveLength(2);
|
|
});
|
|
|
|
it("returns an empty map for no refs", () => {
|
|
expect(groupProfilesByProviderModel([]).size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("model browser all view", () => {
|
|
const claude = provider("claude", "Claude Code", [
|
|
modelRow("claude", "Claude Code", "opus-5", "Opus 5"),
|
|
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: "", isSearchFocused: false }),
|
|
).toEqual({
|
|
kind: "browse",
|
|
});
|
|
});
|
|
|
|
it("shows every searchable model as soon as empty search receives focus", () => {
|
|
const view = resolveModelBrowserAllView({
|
|
providers,
|
|
normalizedQuery: "",
|
|
isSearchFocused: true,
|
|
});
|
|
|
|
expect(view.kind).toBe("searchResults");
|
|
expect(view.kind === "searchResults" ? view.rows.map((row) => row.favoriteKey) : []).toEqual([
|
|
"claude:opus-5",
|
|
"claude:sonnet-4.6",
|
|
"copilot:claude-opus-5",
|
|
"codex:gpt-5.4",
|
|
]);
|
|
});
|
|
|
|
it("ranks the same model label across every provider that offers it", () => {
|
|
const view = resolveModelBrowserAllView({
|
|
providers,
|
|
normalizedQuery: "opus",
|
|
isSearchFocused: true,
|
|
});
|
|
|
|
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",
|
|
isSearchFocused: true,
|
|
});
|
|
|
|
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",
|
|
isSearchFocused: true,
|
|
}),
|
|
).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",
|
|
isSearchFocused: true,
|
|
}),
|
|
).toEqual({ kind: "noSearchMatches" });
|
|
});
|
|
});
|