From 53a4c0e9e68f8db44b05434a1294a7ca0fe14f3c Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 12:11:58 +0300 Subject: [PATCH] perf(renderer): defer launch dialog heavy work --- .../team/dialogs/LaunchTeamDialog.tsx | 28 ++- .../team/dialogs/ProjectPathSelector.tsx | 7 + .../team/dialogs/projectPathProjects.ts | 2 +- src/renderer/components/ui/combobox.tsx | 201 +++++++++--------- src/renderer/utils/teamModelCatalog.ts | 36 +++- 5 files changed, 169 insertions(+), 105 deletions(-) diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 2611df99..a267d132 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -107,7 +107,7 @@ import { isDeletedProjectPathSelection, isSelectableProjectPathProject, } from './projectPathOptions'; -import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; +import { loadProjectPathProjects, syntheticProjectFromPath } from './projectPathProjects'; import { ProjectPathSelector } from './ProjectPathSelector'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { @@ -157,6 +157,7 @@ import { } from './WorktreeGitReadinessBanner'; import type { ActiveTeamRef } from './CreateTeamDialog'; +import type { ProjectPathProject } from './projectPathProjects'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { @@ -433,6 +434,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); + const [projectsLoadRequested, setProjectsLoadRequested] = useState(false); const [localError, setLocalError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -1847,9 +1849,28 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const repositoryGroups = useStore(useShallow((s) => s.repositoryGroups)); const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined; + const shouldDeferProjectListLoad = + isLaunchMode && + !projectsLoadRequested && + typeof defaultProjectPath === 'string' && + defaultProjectPath.length > 0 && + !isEphemeralProjectPath(defaultProjectPath); + const requestProjectListLoad = useCallback(() => { + setProjectsLoadRequested(true); + }, []); useEffect(() => { - if (!open) return; + if (!open) { + setProjectsLoadRequested(false); + return; + } + + if (shouldDeferProjectListLoad && defaultProjectPath) { + setProjects([syntheticProjectFromPath(defaultProjectPath)]); + setProjectsLoading(false); + setProjectsError(null); + return; + } setProjectsLoading(true); setProjectsError(null); @@ -1878,7 +1899,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return () => { cancelled = true; }; - }, [open, repositoryGroups, defaultProjectPath, t]); + }, [open, repositoryGroups, defaultProjectPath, shouldDeferProjectListLoad, t]); // Pre-select defaultProjectPath (launch mode) or first project @@ -2699,6 +2720,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen projects={projects} projectsLoading={projectsLoading} projectsError={projectsError} + onProjectsDropdownOpen={requestProjectListLoad} /> {/* ═══════════════════════════════════════════════════════════════════ diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index c979e1db..6e495148 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -132,6 +132,7 @@ interface ProjectPathSelectorProps { projectsLoading: boolean; projectsError: string | null; fieldError?: string | null; + onProjectsDropdownOpen?: () => void; } export const ProjectPathSelector = ({ @@ -145,6 +146,7 @@ export const ProjectPathSelector = ({ projectsLoading, projectsError, fieldError, + onProjectsDropdownOpen, }: ProjectPathSelectorProps): React.JSX.Element => { const { t } = useAppTranslation('team'); const projectOptions = React.useMemo( @@ -202,6 +204,11 @@ export const ProjectPathSelector = ({ searchPlaceholder={t('projectPath.searchPlaceholder')} emptyMessage={t('projectPath.empty')} disabled={projectsLoading || projectOptions.length === 0} + onOpenChange={(isOpen) => { + if (isOpen) { + onProjectsDropdownOpen?.(); + } + }} renderTriggerLabel={(option) => ( diff --git a/src/renderer/components/team/dialogs/projectPathProjects.ts b/src/renderer/components/team/dialogs/projectPathProjects.ts index b180a5bc..2239175e 100644 --- a/src/renderer/components/team/dialogs/projectPathProjects.ts +++ b/src/renderer/components/team/dialogs/projectPathProjects.ts @@ -99,7 +99,7 @@ function repositoryWorktreeToProject( }; } -function syntheticProjectFromPath(projectPath: string): Project { +export function syntheticProjectFromPath(projectPath: string): Project { return { id: projectPath.replace(/[/\\]/g, '-'), path: projectPath, diff --git a/src/renderer/components/ui/combobox.tsx b/src/renderer/components/ui/combobox.tsx index 053a76df..e4c9f3ad 100644 --- a/src/renderer/components/ui/combobox.tsx +++ b/src/renderer/components/ui/combobox.tsx @@ -31,6 +31,7 @@ interface ComboboxProps { resetLabel?: string; /** Called when the user clicks the reset item. */ onReset?: () => void; + onOpenChange?: (open: boolean) => void; } export const Combobox = ({ @@ -46,15 +47,23 @@ export const Combobox = ({ renderTriggerLabel, resetLabel, onReset, + onOpenChange, }: ComboboxProps): React.JSX.Element => { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(''); const listboxId = React.useId(); const selectedOption = options.find((opt) => opt.value === value); + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + onOpenChange?.(nextOpen); + }, + [onOpenChange] + ); return ( - + - - -
- -
- e.stopPropagation()} + - - {emptyMessage} - - {onReset && value && !search.trim() ? ( - { - onReset(); - setOpen(false); - setSearch(''); - }} - className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" - > - - - {resetLabel ?? 'Reset selection'} - - - ) : null} - {options - .filter((opt) => { - if (!search.trim()) return true; - const q = search.toLowerCase(); - return ( - opt.label.toLowerCase().includes(q) || - opt.value.toLowerCase().includes(q) || - (opt.description?.toLowerCase().includes(q) ?? false) - ); - }) - .map((option) => { - const isSelected = option.value === value; - return ( - { - if (option.disabled) { - return; - } - onValueChange(option.value); - setOpen(false); - setSearch(''); - }} - className={cn( - 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]', - option.disabled && 'cursor-not-allowed opacity-60' - )} - > - {renderOption ? ( - renderOption(option, isSelected, search) - ) : ( - <> - {isSelected ? : null} -
-

- {option.label} -

- {option.description ? ( -

- {option.description} +

+ +
+ e.stopPropagation()} + > + + {emptyMessage} + + {onReset && value && !search.trim() ? ( + { + onReset(); + setOpen(false); + setSearch(''); + }} + className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" + > + + + {resetLabel ?? 'Reset selection'} + + + ) : null} + {options + .filter((opt) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + opt.label.toLowerCase().includes(q) || + opt.value.toLowerCase().includes(q) || + (opt.description?.toLowerCase().includes(q) ?? false) + ); + }) + .map((option) => { + const isSelected = option.value === value; + return ( + { + if (option.disabled) { + return; + } + onValueChange(option.value); + setOpen(false); + setSearch(''); + }} + className={cn( + 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]', + option.disabled && 'cursor-not-allowed opacity-60' + )} + > + {renderOption ? ( + renderOption(option, isSelected, search) + ) : ( + <> + {isSelected ? : null} +
+

+ {option.label}

- ) : null} -
- - )} -
- ); - })} -
- - + {option.description ? ( +

+ {option.description} +

+ ) : null} +
+ + )} +
+ ); + })} +
+
+
+ ) : null}
); }; diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 81218262..11af54a4 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -19,6 +19,8 @@ type RuntimeAwareProviderStatus = Pick< CliProviderStatus, 'providerId' | 'authMethod' | 'backend' | 'modelCatalog' >; +type RuntimeModelCatalog = NonNullable; +type RuntimeCatalogModel = RuntimeModelCatalog['models'][number]; export interface TeamProviderModelOption { value: string; @@ -290,21 +292,43 @@ export function getTeamModelLabel(model: string | undefined): string | undefined return formatParsedClaudeModelLabel(labelTarget) ?? labelTarget; } +const runtimeCatalogModelIndexCache = new WeakMap< + RuntimeModelCatalog, + Map +>(); + +function getRuntimeCatalogModelIndex( + catalog: RuntimeModelCatalog +): Map { + const cached = runtimeCatalogModelIndexCache.get(catalog); + if (cached) { + return cached; + } + + const index = new Map(); + for (const item of catalog.models) { + if (item.launchModel && !index.has(item.launchModel)) { + index.set(item.launchModel, item); + } + if (item.id && !index.has(item.id)) { + index.set(item.id, item); + } + } + runtimeCatalogModelIndexCache.set(catalog, index); + return index; +} + function getRuntimeCatalogModel( providerId: SupportedProviderId | undefined, model: string | undefined, providerStatus?: RuntimeAwareProviderStatus | null -): NonNullable['models'][number] | null { +): RuntimeCatalogModel | null { const trimmed = model?.trim(); if (!providerId || !trimmed || providerStatus?.modelCatalog?.providerId !== providerId) { return null; } - return ( - providerStatus.modelCatalog.models.find( - (item) => item.launchModel === trimmed || item.id === trimmed - ) ?? null - ); + return getRuntimeCatalogModelIndex(providerStatus.modelCatalog).get(trimmed) ?? null; } export function getTeamModelBadgeLabel(