perf(renderer): defer launch dialog heavy work

This commit is contained in:
777genius 2026-05-31 12:11:58 +03:00
parent 174ad83b47
commit 53a4c0e9e6
5 changed files with 169 additions and 105 deletions

View file

@ -107,7 +107,7 @@ import {
isDeletedProjectPathSelection, isDeletedProjectPathSelection,
isSelectableProjectPathProject, isSelectableProjectPathProject,
} from './projectPathOptions'; } from './projectPathOptions';
import { loadProjectPathProjects, type ProjectPathProject } from './projectPathProjects'; import { loadProjectPathProjects, syntheticProjectFromPath } from './projectPathProjects';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import { import {
@ -157,6 +157,7 @@ import {
} from './WorktreeGitReadinessBanner'; } from './WorktreeGitReadinessBanner';
import type { ActiveTeamRef } from './CreateTeamDialog'; import type { ActiveTeamRef } from './CreateTeamDialog';
import type { ProjectPathProject } from './projectPathProjects';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { import type {
@ -433,6 +434,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [projects, setProjects] = useState<ProjectPathProject[]>([]); const [projects, setProjects] = useState<ProjectPathProject[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false); const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null); const [projectsError, setProjectsError] = useState<string | null>(null);
const [projectsLoadRequested, setProjectsLoadRequested] = useState(false);
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); 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 repositoryGroups = useStore(useShallow((s) => s.repositoryGroups));
const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined; const defaultProjectPath = isLaunchMode ? props.defaultProjectPath : undefined;
const shouldDeferProjectListLoad =
isLaunchMode &&
!projectsLoadRequested &&
typeof defaultProjectPath === 'string' &&
defaultProjectPath.length > 0 &&
!isEphemeralProjectPath(defaultProjectPath);
const requestProjectListLoad = useCallback(() => {
setProjectsLoadRequested(true);
}, []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) {
setProjectsLoadRequested(false);
return;
}
if (shouldDeferProjectListLoad && defaultProjectPath) {
setProjects([syntheticProjectFromPath(defaultProjectPath)]);
setProjectsLoading(false);
setProjectsError(null);
return;
}
setProjectsLoading(true); setProjectsLoading(true);
setProjectsError(null); setProjectsError(null);
@ -1878,7 +1899,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, repositoryGroups, defaultProjectPath, t]); }, [open, repositoryGroups, defaultProjectPath, shouldDeferProjectListLoad, t]);
// Pre-select defaultProjectPath (launch mode) or first project // Pre-select defaultProjectPath (launch mode) or first project
@ -2699,6 +2720,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
projects={projects} projects={projects}
projectsLoading={projectsLoading} projectsLoading={projectsLoading}
projectsError={projectsError} projectsError={projectsError}
onProjectsDropdownOpen={requestProjectListLoad}
/> />
{/* {/*

View file

@ -132,6 +132,7 @@ interface ProjectPathSelectorProps {
projectsLoading: boolean; projectsLoading: boolean;
projectsError: string | null; projectsError: string | null;
fieldError?: string | null; fieldError?: string | null;
onProjectsDropdownOpen?: () => void;
} }
export const ProjectPathSelector = ({ export const ProjectPathSelector = ({
@ -145,6 +146,7 @@ export const ProjectPathSelector = ({
projectsLoading, projectsLoading,
projectsError, projectsError,
fieldError, fieldError,
onProjectsDropdownOpen,
}: ProjectPathSelectorProps): React.JSX.Element => { }: ProjectPathSelectorProps): React.JSX.Element => {
const { t } = useAppTranslation('team'); const { t } = useAppTranslation('team');
const projectOptions = React.useMemo( const projectOptions = React.useMemo(
@ -202,6 +204,11 @@ export const ProjectPathSelector = ({
searchPlaceholder={t('projectPath.searchPlaceholder')} searchPlaceholder={t('projectPath.searchPlaceholder')}
emptyMessage={t('projectPath.empty')} emptyMessage={t('projectPath.empty')}
disabled={projectsLoading || projectOptions.length === 0} disabled={projectsLoading || projectOptions.length === 0}
onOpenChange={(isOpen) => {
if (isOpen) {
onProjectsDropdownOpen?.();
}
}}
renderTriggerLabel={(option) => ( renderTriggerLabel={(option) => (
<span className="flex min-w-0 items-center gap-1.5"> <span className="flex min-w-0 items-center gap-1.5">
<ProjectSourceBadge source={getOptionSource(option)} /> <ProjectSourceBadge source={getOptionSource(option)} />

View file

@ -99,7 +99,7 @@ function repositoryWorktreeToProject(
}; };
} }
function syntheticProjectFromPath(projectPath: string): Project { export function syntheticProjectFromPath(projectPath: string): Project {
return { return {
id: projectPath.replace(/[/\\]/g, '-'), id: projectPath.replace(/[/\\]/g, '-'),
path: projectPath, path: projectPath,

View file

@ -31,6 +31,7 @@ interface ComboboxProps {
resetLabel?: string; resetLabel?: string;
/** Called when the user clicks the reset item. */ /** Called when the user clicks the reset item. */
onReset?: () => void; onReset?: () => void;
onOpenChange?: (open: boolean) => void;
} }
export const Combobox = ({ export const Combobox = ({
@ -46,15 +47,23 @@ export const Combobox = ({
renderTriggerLabel, renderTriggerLabel,
resetLabel, resetLabel,
onReset, onReset,
onOpenChange,
}: ComboboxProps): React.JSX.Element => { }: ComboboxProps): React.JSX.Element => {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState(''); const [search, setSearch] = React.useState('');
const listboxId = React.useId(); const listboxId = React.useId();
const selectedOption = options.find((opt) => opt.value === value); const selectedOption = options.find((opt) => opt.value === value);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[onOpenChange]
);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
@ -75,103 +84,105 @@ export const Combobox = ({
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent {open ? (
className="w-[var(--radix-popover-trigger-width)] p-0" <PopoverContent
align="start" className="w-[var(--radix-popover-trigger-width)] p-0"
sideOffset={4} align="start"
collisionPadding={8} sideOffset={4}
avoidCollisions collisionPadding={8}
> avoidCollisions
<CommandPrimitive
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
shouldFilter={false}
> >
<div className="flex items-center border-b border-[var(--color-border)]"> <CommandPrimitive
<CommandPrimitive.Input className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
value={search} shouldFilter={false}
onValueChange={setSearch}
placeholder={searchPlaceholder}
className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
/>
</div>
<CommandPrimitive.List
id={listboxId}
className="max-h-72 overflow-y-auto overscroll-contain px-2 py-1"
onWheel={(e) => e.stopPropagation()}
> >
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]"> <div className="flex items-center border-b border-[var(--color-border)]">
{emptyMessage} <CommandPrimitive.Input
</CommandPrimitive.Empty> value={search}
{onReset && value && !search.trim() ? ( onValueChange={setSearch}
<CommandPrimitive.Item placeholder={searchPlaceholder}
value="__reset__" className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
onSelect={() => { />
onReset(); </div>
setOpen(false); <CommandPrimitive.List
setSearch(''); id={listboxId}
}} className="max-h-72 overflow-y-auto overscroll-contain px-2 py-1"
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)]" onWheel={(e) => e.stopPropagation()}
> >
<X className="mr-2 size-3.5 shrink-0 text-[var(--color-text-muted)]" /> <CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
<span className="text-[var(--color-text-muted)]"> {emptyMessage}
{resetLabel ?? 'Reset selection'} </CommandPrimitive.Empty>
</span> {onReset && value && !search.trim() ? (
</CommandPrimitive.Item> <CommandPrimitive.Item
) : null} value="__reset__"
{options onSelect={() => {
.filter((opt) => { onReset();
if (!search.trim()) return true; setOpen(false);
const q = search.toLowerCase(); setSearch('');
return ( }}
opt.label.toLowerCase().includes(q) || 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)]"
opt.value.toLowerCase().includes(q) || >
(opt.description?.toLowerCase().includes(q) ?? false) <X className="mr-2 size-3.5 shrink-0 text-[var(--color-text-muted)]" />
); <span className="text-[var(--color-text-muted)]">
}) {resetLabel ?? 'Reset selection'}
.map((option) => { </span>
const isSelected = option.value === value; </CommandPrimitive.Item>
return ( ) : null}
<CommandPrimitive.Item {options
key={option.value} .filter((opt) => {
value={option.value} if (!search.trim()) return true;
disabled={option.disabled} const q = search.toLowerCase();
aria-disabled={option.disabled === true} return (
onSelect={() => { opt.label.toLowerCase().includes(q) ||
if (option.disabled) { opt.value.toLowerCase().includes(q) ||
return; (opt.description?.toLowerCase().includes(q) ?? false)
} );
onValueChange(option.value); })
setOpen(false); .map((option) => {
setSearch(''); const isSelected = option.value === value;
}} return (
className={cn( <CommandPrimitive.Item
'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)]', key={option.value}
option.disabled && 'cursor-not-allowed opacity-60' value={option.value}
)} disabled={option.disabled}
> aria-disabled={option.disabled === true}
{renderOption ? ( onSelect={() => {
renderOption(option, isSelected, search) if (option.disabled) {
) : ( return;
<> }
{isSelected ? <Check className="mr-2 size-3.5 shrink-0" /> : null} onValueChange(option.value);
<div className="min-w-0 flex-1"> setOpen(false);
<p className="truncate font-medium text-[var(--color-text)]"> setSearch('');
{option.label} }}
</p> className={cn(
{option.description ? ( '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)]',
<p className="truncate text-[var(--color-text-muted)]"> option.disabled && 'cursor-not-allowed opacity-60'
{option.description} )}
>
{renderOption ? (
renderOption(option, isSelected, search)
) : (
<>
{isSelected ? <Check className="mr-2 size-3.5 shrink-0" /> : null}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{option.label}
</p> </p>
) : null} {option.description ? (
</div> <p className="truncate text-[var(--color-text-muted)]">
</> {option.description}
)} </p>
</CommandPrimitive.Item> ) : null}
); </div>
})} </>
</CommandPrimitive.List> )}
</CommandPrimitive> </CommandPrimitive.Item>
</PopoverContent> );
})}
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
) : null}
</Popover> </Popover>
); );
}; };

View file

@ -19,6 +19,8 @@ type RuntimeAwareProviderStatus = Pick<
CliProviderStatus, CliProviderStatus,
'providerId' | 'authMethod' | 'backend' | 'modelCatalog' 'providerId' | 'authMethod' | 'backend' | 'modelCatalog'
>; >;
type RuntimeModelCatalog = NonNullable<RuntimeAwareProviderStatus['modelCatalog']>;
type RuntimeCatalogModel = RuntimeModelCatalog['models'][number];
export interface TeamProviderModelOption { export interface TeamProviderModelOption {
value: string; value: string;
@ -290,21 +292,43 @@ export function getTeamModelLabel(model: string | undefined): string | undefined
return formatParsedClaudeModelLabel(labelTarget) ?? labelTarget; return formatParsedClaudeModelLabel(labelTarget) ?? labelTarget;
} }
const runtimeCatalogModelIndexCache = new WeakMap<
RuntimeModelCatalog,
Map<string, RuntimeCatalogModel>
>();
function getRuntimeCatalogModelIndex(
catalog: RuntimeModelCatalog
): Map<string, RuntimeCatalogModel> {
const cached = runtimeCatalogModelIndexCache.get(catalog);
if (cached) {
return cached;
}
const index = new Map<string, RuntimeCatalogModel>();
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( function getRuntimeCatalogModel(
providerId: SupportedProviderId | undefined, providerId: SupportedProviderId | undefined,
model: string | undefined, model: string | undefined,
providerStatus?: RuntimeAwareProviderStatus | null providerStatus?: RuntimeAwareProviderStatus | null
): NonNullable<RuntimeAwareProviderStatus['modelCatalog']>['models'][number] | null { ): RuntimeCatalogModel | null {
const trimmed = model?.trim(); const trimmed = model?.trim();
if (!providerId || !trimmed || providerStatus?.modelCatalog?.providerId !== providerId) { if (!providerId || !trimmed || providerStatus?.modelCatalog?.providerId !== providerId) {
return null; return null;
} }
return ( return getRuntimeCatalogModelIndex(providerStatus.modelCatalog).get(trimmed) ?? null;
providerStatus.modelCatalog.models.find(
(item) => item.launchModel === trimmed || item.id === trimmed
) ?? null
);
} }
export function getTeamModelBadgeLabel( export function getTeamModelBadgeLabel(