From ad56659a89d00e3154d44ee64041e6df274d3a40 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 30 Sep 2026 13:46:43 +0100 Subject: [PATCH] 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. --- .../agent-control-registration.tsx | 7 +- packages/app/src/components/agent-list.tsx | 4 +- .../components/import-session-sheet.test.tsx | 3 +- .../src/components/import-session-sheet.tsx | 15 ++--- packages/app/src/components/model-browser.tsx | 4 +- .../src/components/provider-icon-name.test.ts | 40 +----------- .../app/src/components/provider-icon-name.ts | 24 +------ .../app/src/components/provider-icons.test.ts | 65 +++++++++++++++++-- packages/app/src/components/provider-icons.ts | 36 +++++++--- .../schedules/schedule-form-sheet.tsx | 4 +- .../src/components/schedules/schedule-row.tsx | 6 +- .../src/components/terminal-profile-icon.tsx | 4 +- .../composer/agent-controls/model-sheet.tsx | 12 ++-- packages/app/src/data/provider-icons.ts | 17 +++++ .../app/src/data/providers-snapshot.test.ts | 26 +++----- packages/app/src/data/providers-snapshot.ts | 2 +- .../src/hooks/use-providers-snapshot.test.ts | 7 +- packages/app/src/panels/agent-panel.tsx | 4 +- .../src/panels/provider-subagent-panel.tsx | 4 +- .../app/src/screens/settings/host-page.tsx | 4 +- .../settings/providers-section.test.tsx | 2 +- .../screens/settings/providers-section.tsx | 4 +- packages/app/src/subagents/track.tsx | 11 ++-- 23 files changed, 165 insertions(+), 140 deletions(-) create mode 100644 packages/app/src/data/provider-icons.ts diff --git a/packages/app/src/command-center/agent-control-registration.tsx b/packages/app/src/command-center/agent-control-registration.tsx index 50e61ed9fd..21ce492373 100644 --- a/packages/app/src/command-center/agent-control-registration.tsx +++ b/packages/app/src/command-center/agent-control-registration.tsx @@ -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, diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 406b498bfe..7da36742ae 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -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( () => ({ diff --git a/packages/app/src/components/import-session-sheet.test.tsx b/packages/app/src/components/import-session-sheet.test.tsx index 65e32d752c..35c475e824 100644 --- a/packages/app/src/components/import-session-sheet.test.tsx +++ b/packages/app/src/components/import-session-sheet.test.tsx @@ -63,7 +63,8 @@ vi.mock("@/constants/layout", () => ({ })); vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: () => () => null, + useProviderIcon: () => () => null, + useProviderIcons: () => () => () => null, })); vi.mock("lucide-react-native", () => { diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index c6d13d1a28..3be15c0892 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -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(); map.set(ALL_FILTER_VALUE, ); for (const provider of filterProviders) { - const ProviderIcon = getProviderIcon(provider, serverId); + const ProviderIcon = getProviderIcon(provider); map.set(provider, ); } 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 ? ( ) : ( - (() => { - const ProviderIcon = getProviderIcon(selectedProvider, serverId); - return ; - })() + )} {selectedProviderLabel} diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 9d0c8f8bc3..17924b233d 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -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 ; diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts index de2f8c3798..65c75166a5 100644 --- a/packages/app/src/components/provider-icon-name.test.ts +++ b/packages/app/src/components/provider-icon-name.test.ts @@ -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 = ''; - 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 = ""; - replaceProviderSnapshotIcons("server-1", [ - { provider: "removed-provider", iconSvg: "" }, - { provider: "snapshot-provider", iconSvg: "" }, - { provider: "claude", iconSvg: "" }, - ]); - replaceProviderSnapshotIcons("server-2", [ - { provider: "snapshot-provider", iconSvg: secondSvg }, - ]); - - expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ - kind: "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 }); }); }); diff --git a/packages/app/src/components/provider-icon-name.ts b/packages/app/src/components/provider-icon-name.ts index 699865fd01..38d4344cd1 100644 --- a/packages/app/src/components/provider-icon-name.ts +++ b/packages/app/src/components/provider-icon-name.ts @@ -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>(); - -export function replaceProviderSnapshotIcons( - serverId: string, - entries: readonly Pick[], -): void { - const icons = new Map(); - 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 }; } diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts index 5235218875..025ff14bef 100644 --- a/packages/app/src/components/provider-icons.test.ts +++ b/packages/app/src/components/provider-icons.test.ts @@ -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 = ''; 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 = ''; + 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 = ''; + const secondSvg = ''; + 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 = ''; + 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 } }); +}); diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index a66eaafa1d..fec4003705 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -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(); -const snapshotIconComponents = new Map(); +const snapshotIconComponents = new Map(); /** 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], + ); +} diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 2a3cd97c10..230a41e2e9 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -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 ; } diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index 96099a0ec7..2af4b33428 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -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 ; } diff --git a/packages/app/src/components/terminal-profile-icon.tsx b/packages/app/src/components/terminal-profile-icon.tsx index db3a216e7b..66cc00f69b 100644 --- a/packages/app/src/components/terminal-profile-icon.tsx +++ b/packages/app/src/components/terminal-profile-icon.tsx @@ -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 ; } diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index 1bebc8a5ac..bca5a250bf 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -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 ? ( - + ) : null} diff --git a/packages/app/src/data/provider-icons.ts b/packages/app/src/data/provider-icons.ts new file mode 100644 index 0000000000..9556be7ab2 --- /dev/null +++ b/packages/app/src/data/provider-icons.ts @@ -0,0 +1,17 @@ +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { createStore } from "zustand/vanilla"; + +export const providerSnapshotIcons = createStore>>( + () => new Map(), +); + +export function replaceProviderSnapshotIcons( + serverId: string, + entries: readonly Pick[], +): void { + const icons = new Map(); + for (const entry of entries) { + if (entry.iconSvg) icons.set(entry.provider, entry.iconSvg); + } + providerSnapshotIcons.setState((previous) => new Map(previous).set(serverId, icons), true); +} diff --git a/packages/app/src/data/providers-snapshot.test.ts b/packages/app/src/data/providers-snapshot.test.ts index 56cf80653a..c69eff9225 100644 --- a/packages/app/src/data/providers-snapshot.test.ts +++ b/packages/app/src/data/providers-snapshot.test.ts @@ -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(); diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index fadaf5ee77..68b389fe15 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -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"; diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 83f62b4812..2f3c90b340 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -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 () => { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index c0669972bf..2d8a605c3f 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 ?? "", diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 8fc18eb0e3..6c2d482403 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -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), diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index f7b27f820f..6b308547a1 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -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 ; } diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 8e8ef4a8a4..77aaa4d8ec 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -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}` }), })); diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 69e5caadac..4495acba83 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -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" && diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index e042322d9a..953c13e0c6 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -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(() => {