mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
fix(app): resolve plugin provider icons after the snapshot arrives (#5720)
Subscribe icon consumers to snapshot SVG metadata so compiler-cached fallbacks update when provider discovery completes.
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
PlanModeIcon,
|
||||
ThinkingIcon,
|
||||
} from "@/agent-controls/icons";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcons } from "@/components/provider-icons";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import {
|
||||
buildAgentControlContributions,
|
||||
@@ -88,6 +88,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
const { t } = useTranslation();
|
||||
const { controls } = input;
|
||||
const { features, models, modes, thinking } = controls;
|
||||
const getProviderIcon = useProviderIcons(controls.serverId);
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
buildAgentControlContributions({
|
||||
@@ -96,8 +97,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
provider: controls.provider ?? null,
|
||||
labels: buildAgentControlContributionLabels(t),
|
||||
icons: {
|
||||
provider: (provider) =>
|
||||
getCommandCenterIcon(getProviderIcon(provider, controls.serverId)),
|
||||
provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)),
|
||||
thinking: getCommandCenterIcon(ThinkingIcon),
|
||||
planMode: getCommandCenterIcon(PlanModeIcon),
|
||||
mode: (modeId) =>
|
||||
@@ -129,6 +129,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
},
|
||||
}),
|
||||
[
|
||||
getProviderIcon,
|
||||
controls.ownerKey,
|
||||
controls.provider,
|
||||
controls.providerDefinitions,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, ChevronRight } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { HighlightedText } from "@/components/ui/highlighted-text";
|
||||
@@ -186,7 +186,7 @@ function SessionRow({
|
||||
const projectName = agent.projectPlacement?.projectName ?? "";
|
||||
const branch = agent.projectPlacement?.checkout.currentBranch ?? "";
|
||||
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
|
||||
const ProviderIcon = getProviderIcon(agent.provider, agent.serverId);
|
||||
const ProviderIcon = useProviderIcon(agent.provider, agent.serverId);
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
const ranges = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -63,7 +63,8 @@ vi.mock("@/constants/layout", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: () => () => null,
|
||||
useProviderIcon: () => () => null,
|
||||
useProviderIcons: () => () => () => null,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
@@ -309,7 +309,7 @@ function ImportSessionSheetRow({
|
||||
const { t } = useTranslation();
|
||||
const title = getSessionTitle(entry);
|
||||
const promptPreview = getPromptPreview(entry);
|
||||
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
|
||||
const ProviderIcon = useProviderIcon(entry.providerId, serverId);
|
||||
const accessibilityState = useMemo(
|
||||
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
|
||||
[disabled],
|
||||
@@ -559,15 +559,17 @@ export function ImportSessionSheet({
|
||||
setIsFilterOpen(false);
|
||||
}, []);
|
||||
|
||||
const getProviderIcon = useProviderIcons(serverId);
|
||||
const SelectedProviderIcon = useProviderIcon(selectedProvider, serverId);
|
||||
const filterOptionIcons = useMemo(() => {
|
||||
const map = new Map<string, React.ReactNode>();
|
||||
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
|
||||
for (const provider of filterProviders) {
|
||||
const ProviderIcon = getProviderIcon(provider, serverId);
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
|
||||
}
|
||||
return map;
|
||||
}, [filterProviders, serverId, theme.colors.foregroundMuted]);
|
||||
}, [filterProviders, getProviderIcon, theme.colors.foregroundMuted]);
|
||||
|
||||
const renderFilterOption = useCallback(
|
||||
({
|
||||
@@ -746,10 +748,7 @@ export function ImportSessionSheet({
|
||||
{selectedProvider === ALL_FILTER_VALUE ? (
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
(() => {
|
||||
const ProviderIcon = getProviderIcon(selectedProvider, serverId);
|
||||
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
|
||||
})()
|
||||
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedProviderLabel}
|
||||
|
||||
@@ -39,7 +39,7 @@ 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 { useProviderIcon } from "@/components/provider-icons";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
@@ -223,7 +223,7 @@ export function ModelProviderGlyph({
|
||||
size: number;
|
||||
tone?: ProviderGlyphTone;
|
||||
}) {
|
||||
const Icon = getProviderIcon(provider, serverId);
|
||||
const Icon = useProviderIcon(provider, serverId);
|
||||
const color =
|
||||
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
|
||||
return <Icon size={size} color={color} />;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TERMINAL_PROFILE_ICON_NAMES,
|
||||
} from "@getpaseo/protocol/provider-icon-names";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
import { replaceProviderSnapshotIcons, resolveProviderIconName } from "./provider-icon-name";
|
||||
import { resolveProviderIconName } from "./provider-icon-name";
|
||||
|
||||
describe("resolveProviderIconName", () => {
|
||||
it("returns the built-in identifier for known provider ids", () => {
|
||||
@@ -29,43 +29,7 @@ describe("resolveProviderIconName", () => {
|
||||
it("resolves a snapshot SVG for a custom provider", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider", iconSvg: svg }]);
|
||||
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "svg", svg });
|
||||
});
|
||||
|
||||
it("replaces each host snapshot without leaking icons across hosts", () => {
|
||||
const secondSvg = "<svg id='second' />";
|
||||
replaceProviderSnapshotIcons("server-1", [
|
||||
{ provider: "removed-provider", iconSvg: "<svg />" },
|
||||
{ provider: "snapshot-provider", iconSvg: "<svg />" },
|
||||
{ provider: "claude", iconSvg: "<svg />" },
|
||||
]);
|
||||
replaceProviderSnapshotIcons("server-2", [
|
||||
{ provider: "snapshot-provider", iconSvg: secondSvg },
|
||||
]);
|
||||
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({
|
||||
kind: "svg",
|
||||
svg: "<svg />",
|
||||
});
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({
|
||||
kind: "svg",
|
||||
svg: secondSvg,
|
||||
});
|
||||
expect(resolveProviderIconName("claude", "server-1")).toEqual({
|
||||
kind: "builtin",
|
||||
id: "claude",
|
||||
});
|
||||
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider" }]);
|
||||
|
||||
expect(resolveProviderIconName("removed-provider", "server-1")).toEqual({ kind: "bot" });
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "bot" });
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({
|
||||
kind: "svg",
|
||||
svg: secondSvg,
|
||||
});
|
||||
expect(resolveProviderIconName("snapshot-provider", svg)).toEqual({ kind: "svg", svg });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
BUILTIN_PROVIDER_ICON_NAMES,
|
||||
KNOWN_PROVIDER_ICON_NAMES,
|
||||
} from "@getpaseo/protocol/provider-icon-names";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export type ProviderIconName =
|
||||
| { kind: "builtin"; id: string }
|
||||
@@ -12,31 +11,10 @@ export type ProviderIconName =
|
||||
|
||||
const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES);
|
||||
const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES);
|
||||
const providerSnapshotIconSvgsByServer = new Map<string, ReadonlyMap<string, string>>();
|
||||
|
||||
export function replaceProviderSnapshotIcons(
|
||||
serverId: string,
|
||||
entries: readonly Pick<ProviderSnapshotEntry, "provider" | "iconSvg">[],
|
||||
): void {
|
||||
const icons = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.iconSvg) {
|
||||
icons.set(entry.provider, entry.iconSvg);
|
||||
}
|
||||
}
|
||||
providerSnapshotIconSvgsByServer.set(serverId, icons);
|
||||
}
|
||||
|
||||
export function resolveProviderIconName(
|
||||
provider: string,
|
||||
serverId?: string | null,
|
||||
): ProviderIconName {
|
||||
export function resolveProviderIconName(provider: string, iconSvg?: string): ProviderIconName {
|
||||
if (BUILTIN_PROVIDER_IDS.has(provider)) {
|
||||
return { kind: "builtin", id: provider };
|
||||
}
|
||||
const iconSvg = serverId
|
||||
? providerSnapshotIconSvgsByServer.get(serverId)?.get(provider)
|
||||
: undefined;
|
||||
if (iconSvg) {
|
||||
return { kind: "svg", svg: iconSvg };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { replaceProviderSnapshotIcons } from "./provider-icon-name";
|
||||
import { getProviderIcon, type ProviderIconComponent } from "./provider-icons";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
||||
import { useProviderIcon, useProviderIcons, type ProviderIconComponent } from "./provider-icons";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderIcon(Component: ProviderIconComponent) {
|
||||
if (typeof Component !== "function") throw new Error("Expected a function component");
|
||||
@@ -12,12 +16,14 @@ function renderIcon(Component: ProviderIconComponent) {
|
||||
});
|
||||
}
|
||||
|
||||
describe("getProviderIcon", () => {
|
||||
describe("useProviderIcon", () => {
|
||||
it("renders registered snapshot SVG metadata with the requested size and color", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
|
||||
|
||||
const rendered = renderIcon(getProviderIcon("rendered-provider", "server-1"));
|
||||
const rendered = renderIcon(
|
||||
renderHook(() => useProviderIcon("rendered-provider", "server-1")).result.current,
|
||||
);
|
||||
|
||||
expect(rendered).toMatchObject({
|
||||
type: SvgXml,
|
||||
@@ -28,6 +34,53 @@ describe("getProviderIcon", () => {
|
||||
it("uses the normal Bot fallback without snapshot SVG metadata", () => {
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "plain-provider" }]);
|
||||
|
||||
expect(getProviderIcon("plain-provider", "server-1")).toBe(Bot);
|
||||
expect(renderHook(() => useProviderIcon("plain-provider", "server-1")).result.current).toBe(
|
||||
Bot,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a mounted consumer when the plugin snapshot arrives after mount", () => {
|
||||
const serverId = "cold-reload-host";
|
||||
const provider = "plugin-provider";
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
replaceProviderSnapshotIcons(serverId, []);
|
||||
const { result, unmount } = renderHook(() => useProviderIcon(provider, serverId));
|
||||
expect(result.current).toBe(Bot);
|
||||
act(() => replaceProviderSnapshotIcons(serverId, [{ provider, iconSvg: svg }]));
|
||||
expect(renderIcon(result.current)).toMatchObject({
|
||||
type: SvgXml,
|
||||
props: { xml: svg, width: 18, height: 18, color: "#123456" },
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("keeps host SVGs isolated, updates replacements, and clears removed icons", () => {
|
||||
const provider = "shared-plugin-provider";
|
||||
const firstSvg = '<svg id="first" />';
|
||||
const secondSvg = '<svg id="second" />';
|
||||
replaceProviderSnapshotIcons("first-host", [{ provider, iconSvg: firstSvg }]);
|
||||
replaceProviderSnapshotIcons("second-host", [{ provider, iconSvg: secondSvg }]);
|
||||
const first = renderHook(() => useProviderIcon(provider, "first-host"));
|
||||
const second = renderHook(() => useProviderIcon(provider, "second-host"));
|
||||
const firstIcon = first.result.current;
|
||||
expect(renderIcon(firstIcon)).toMatchObject({ props: { xml: firstSvg } });
|
||||
expect(renderIcon(second.result.current)).toMatchObject({ props: { xml: secondSvg } });
|
||||
act(() => replaceProviderSnapshotIcons("second-host", [{ provider, iconSvg: firstSvg }]));
|
||||
expect(renderIcon(second.result.current)).toMatchObject({ props: { xml: firstSvg } });
|
||||
expect(first.result.current).toBe(firstIcon);
|
||||
act(() => replaceProviderSnapshotIcons("second-host", []));
|
||||
expect(second.result.current).toBe(Bot);
|
||||
expect(first.result.current).toBe(firstIcon);
|
||||
});
|
||||
|
||||
it("updates collection consumers when their host snapshot arrives", () => {
|
||||
const serverId = "collection-host";
|
||||
const provider = "collection-plugin-provider";
|
||||
const svg = '<svg id="collection" />';
|
||||
replaceProviderSnapshotIcons(serverId, []);
|
||||
const { result } = renderHook(() => useProviderIcons(serverId));
|
||||
expect(result.current(provider)).toBe(Bot);
|
||||
act(() => replaceProviderSnapshotIcons(serverId, [{ provider, iconSvg: svg }]));
|
||||
expect(renderIcon(result.current(provider))).toMatchObject({ props: { xml: svg } });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useStore } from "zustand";
|
||||
import { providerSnapshotIcons } from "@/data/provider-icons";
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { createElement, type ComponentType } from "react";
|
||||
import { createElement, useCallback, type ComponentType } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
@@ -34,7 +36,7 @@ const CATALOG_ICON_SVGS = new Map(
|
||||
);
|
||||
|
||||
const catalogIconComponents = new Map<string, ProviderIconComponent>();
|
||||
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
|
||||
const snapshotIconComponents = new Map<string, ProviderIconComponent>();
|
||||
|
||||
/** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */
|
||||
export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) {
|
||||
@@ -63,15 +65,15 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent {
|
||||
}
|
||||
|
||||
function getSnapshotProviderIcon(provider: string, svg: string): ProviderIconComponent {
|
||||
const cached = snapshotIconComponents.get(provider);
|
||||
if (cached?.svg === svg) return cached.component;
|
||||
const cached = snapshotIconComponents.get(svg);
|
||||
if (cached) return cached;
|
||||
const component = createSvgIcon(provider, svg);
|
||||
snapshotIconComponents.set(provider, { svg, component });
|
||||
snapshotIconComponents.set(svg, component);
|
||||
return component;
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent {
|
||||
const name = resolveProviderIconName(provider, serverId);
|
||||
function resolveProviderIcon(provider: string, svg?: string): ProviderIconComponent {
|
||||
const name = resolveProviderIconName(provider, svg);
|
||||
if (name.kind === "builtin") {
|
||||
return BUILTIN_PROVIDER_ICONS[name.id];
|
||||
}
|
||||
@@ -79,7 +81,25 @@ export function getProviderIcon(provider: string, serverId?: string | null): Pro
|
||||
return getCatalogProviderIcon(name.id);
|
||||
}
|
||||
if (name.kind === "svg") {
|
||||
return getSnapshotProviderIcon(`${serverId}:${provider}`, name.svg);
|
||||
return getSnapshotProviderIcon(provider, name.svg);
|
||||
}
|
||||
return Bot;
|
||||
}
|
||||
|
||||
export function useProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent {
|
||||
const svg = useStore(providerSnapshotIcons, (state) =>
|
||||
serverId ? state.get(serverId)?.get(provider) : undefined,
|
||||
);
|
||||
return resolveProviderIcon(provider, svg);
|
||||
}
|
||||
|
||||
/** Subscribe once for consumers that render a collection of providers. */
|
||||
export function useProviderIcons(serverId?: string | null) {
|
||||
const icons = useStore(providerSnapshotIcons, (state) =>
|
||||
serverId ? state.get(serverId) : undefined,
|
||||
);
|
||||
return useCallback(
|
||||
(provider: string) => resolveProviderIcon(provider, icons?.get(provider)),
|
||||
[icons],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
||||
import { createControlGeometry, type FieldControlSize } from "@/components/ui/control-geometry";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { CadenceEditor } from "@/components/schedules/cadence-editor";
|
||||
import {
|
||||
SelectField,
|
||||
@@ -1026,10 +1026,10 @@ function ProviderGlyph({
|
||||
provider: string | null;
|
||||
serverId: string | null;
|
||||
}): ReactElement | null {
|
||||
const Icon = useProviderIcon(provider ?? "", serverId);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const Icon = getProviderIcon(provider, serverId);
|
||||
return <Icon size={16} color={styles.providerIcon.color} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -144,7 +144,7 @@ function ScheduleMeta({
|
||||
}
|
||||
|
||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
||||
* dynamic component (useProviderIcon) stays compliant without useUnistyles. */
|
||||
function ProviderGlyph({
|
||||
provider,
|
||||
serverId,
|
||||
@@ -152,10 +152,10 @@ function ProviderGlyph({
|
||||
provider: string | null;
|
||||
serverId: string;
|
||||
}): ReactElement | null {
|
||||
const Icon = useProviderIcon(provider ?? "", serverId);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const Icon = getProviderIcon(provider, serverId);
|
||||
return <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { SquareTerminal } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -17,7 +17,7 @@ function ProviderProfileIcon({
|
||||
size: number;
|
||||
color?: string;
|
||||
}) {
|
||||
const Icon = getProviderIcon(iconKey);
|
||||
const Icon = useProviderIcon(iconKey);
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
|
||||
import { resolveModelBrowserScrolling } from "@/components/model-browser-view";
|
||||
import { AgentControlTrigger } from "@/composer/agent-controls/control";
|
||||
@@ -130,9 +129,8 @@ export function CompactModelSheet({
|
||||
profiles,
|
||||
serverId,
|
||||
});
|
||||
const ProviderIcon =
|
||||
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider, serverId) : null;
|
||||
const ModelIcon = ProviderIcon ?? Bot;
|
||||
const ModelIcon = useProviderIcon(selectedProvider, serverId);
|
||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||
const rootHeader = useMemo(
|
||||
() => ({
|
||||
...rootBrowser.header,
|
||||
@@ -277,9 +275,9 @@ export function CompactModelSheet({
|
||||
testID="combined-model-selector"
|
||||
chevron={null}
|
||||
>
|
||||
{ProviderIcon ? (
|
||||
{hasSelectedProvider ? (
|
||||
<ComposerToolbarGlyph size={glyphSize}>
|
||||
<ProviderIcon size={glyphSize} color={styles.providerIcon.color} />
|
||||
<ModelIcon size={glyphSize} color={styles.providerIcon.color} />
|
||||
</ComposerToolbarGlyph>
|
||||
) : null}
|
||||
<View style={styles.triggerLabels}>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export const providerSnapshotIcons = createStore<ReadonlyMap<string, ReadonlyMap<string, string>>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
export function replaceProviderSnapshotIcons(
|
||||
serverId: string,
|
||||
entries: readonly Pick<ProviderSnapshotEntry, "provider" | "iconSvg">[],
|
||||
): void {
|
||||
const icons = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.iconSvg) icons.set(entry.provider, entry.iconSvg);
|
||||
}
|
||||
providerSnapshotIcons.setState((previous) => new Map(previous).set(serverId, icons), true);
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/mes
|
||||
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
import { applyProvidersSnapshotUpdate } from "./push-router";
|
||||
import { createProviderSnapshotCache } from "./provider-snapshot-cache";
|
||||
import {
|
||||
resolveProviderIconName,
|
||||
replaceProviderSnapshotIcons,
|
||||
} from "@/components/provider-icon-name";
|
||||
import { providerSnapshotIcons, replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fetchProvidersSnapshot,
|
||||
@@ -143,10 +140,9 @@ describe("provider fetch with runtime abort signals", () => {
|
||||
expect(result.entries).toEqual(entries);
|
||||
expect(queryClient.getQueryData(queryKey)).toEqual(result);
|
||||
expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(entries);
|
||||
expect(resolveProviderIconName("test-provider", serverId)).toEqual({
|
||||
kind: "svg",
|
||||
svg: entries[0].iconSvg,
|
||||
});
|
||||
expect(providerSnapshotIcons.getState().get(serverId)?.get("test-provider")).toEqual(
|
||||
entries[0].iconSvg,
|
||||
);
|
||||
} finally {
|
||||
queryClient.clear();
|
||||
replaceProviderSnapshotIcons(serverId, []);
|
||||
@@ -203,10 +199,9 @@ describe.each([
|
||||
).rejects.toBeInstanceOf(CancelledError);
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(queryClient.getQueryData(queryKey)).toEqual(previous);
|
||||
expect(resolveProviderIconName("test-provider", serverId)).toEqual({
|
||||
kind: "svg",
|
||||
svg: "previous-icon",
|
||||
});
|
||||
expect(providerSnapshotIcons.getState().get(serverId)?.get("test-provider")).toEqual(
|
||||
"previous-icon",
|
||||
);
|
||||
expect((await cache.readHash(serverId, "test-hash"))?.entries ?? null).toEqual(
|
||||
hasBody ? entries : null,
|
||||
);
|
||||
@@ -287,10 +282,9 @@ it("keeps a replacement query when the cancelled native fetch finishes late", as
|
||||
expect(queryClient.getQueryData(queryKey)).toEqual(replacement);
|
||||
expect(replacement.entries).toEqual(replacementEntries);
|
||||
expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(replacementEntries);
|
||||
expect(resolveProviderIconName("test-provider", serverId)).toEqual({
|
||||
kind: "svg",
|
||||
svg: "replacement-icon",
|
||||
});
|
||||
expect(providerSnapshotIcons.getState().get(serverId)?.get("test-provider")).toEqual(
|
||||
"replacement-icon",
|
||||
);
|
||||
} finally {
|
||||
finishResponse();
|
||||
queryClient.clear();
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type ProviderSnapshotCache,
|
||||
} from "./provider-snapshot-cache";
|
||||
import { queryClient as singletonQueryClient } from "./query-client";
|
||||
import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name";
|
||||
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import { resolveProviderIconName } from "@/components/provider-icon-name";
|
||||
import { providerSnapshotIcons } from "@/data/provider-icons";
|
||||
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
|
||||
import {
|
||||
fetchProvidersSnapshot,
|
||||
@@ -141,10 +141,9 @@ describe("fetchProvidersSnapshot", () => {
|
||||
|
||||
await fetchProvidersSnapshot({ client, serverId, cwd: null, cache: createCache() });
|
||||
|
||||
expect(resolveProviderIconName("snapshot-icon-provider", serverId)).toEqual({
|
||||
kind: "svg",
|
||||
expect(providerSnapshotIcons.getState().get(serverId)?.get("snapshot-icon-provider")).toEqual(
|
||||
svg,
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no cwd for the home scope", async () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from "@/composer/pill-styles";
|
||||
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
@@ -343,7 +343,7 @@ function useAgentPanelDescriptor(
|
||||
);
|
||||
const provider = descriptorState.provider;
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||
const icon = getProviderIcon(provider, context.serverId);
|
||||
const icon = useProviderIcon(provider, context.serverId);
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
resolveComposerTrackControlClearance,
|
||||
resolveComposerTrackTailClearance,
|
||||
@@ -97,7 +97,7 @@ function useProviderSubagentDescriptor(
|
||||
subagentType && subagentType !== label ? `${subagentType} · ${providerLabel}` : providerLabel,
|
||||
tooltip: label,
|
||||
titleState: descriptor ? "ready" : "loading",
|
||||
icon: getProviderIcon(provider, context.serverId),
|
||||
icon: useProviderIcon(provider, context.serverId),
|
||||
statusBucket: descriptor
|
||||
? deriveSidebarStateBucket({
|
||||
status: providerSubagentLifecycleStatus(descriptor.status),
|
||||
|
||||
@@ -63,7 +63,7 @@ import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons
|
||||
import { formatLatency } from "@/utils/latency";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
||||
|
||||
@@ -90,7 +90,7 @@ interface DynamicProviderIconProps {
|
||||
}
|
||||
|
||||
function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) {
|
||||
const Icon = getProviderIcon(iconKey);
|
||||
const Icon = useProviderIcon(iconKey);
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: (provider: string) => () =>
|
||||
useProviderIcon: (provider: string) => () =>
|
||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type AcpProviderCatalogItem,
|
||||
} from "@/hooks/use-acp-provider-catalog";
|
||||
import { ProviderCatalogList } from "@/components/provider-catalog-list";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -183,7 +183,7 @@ function ProviderRow({
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const ProviderIcon = getProviderIcon(def.id, serverId);
|
||||
const ProviderIcon = useProviderIcon(def.id, serverId);
|
||||
const providerError =
|
||||
enabled &&
|
||||
entry.status === "error" &&
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useCallback, type ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, Unlink } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { ComposerTrackActions, ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -45,13 +45,14 @@ const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
|
||||
/** Leading and action glyphs share one size so rows keep a single icon column. */
|
||||
const ROW_ICON_SIZE = 14;
|
||||
|
||||
function buildRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation {
|
||||
function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation {
|
||||
const icon = useProviderIcon(row.provider, serverId);
|
||||
const data = buildSubagentRowPresentationData(row);
|
||||
return {
|
||||
...data,
|
||||
tooltip: data.label,
|
||||
modified: false,
|
||||
icon: getProviderIcon(row.provider, serverId),
|
||||
icon,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,7 +186,7 @@ function SubagentsTrackRow({
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const presentation = useMemo(() => buildRowPresentation(row, serverId), [row, serverId]);
|
||||
const presentation = useRowPresentation(row, serverId);
|
||||
const displayLabel =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
const handlePress = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user