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,
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<ProjectPathProject[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
const [projectsLoadRequested, setProjectsLoadRequested] = useState(false);
const [localError, setLocalError] = useState<string | null>(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}
/>
{/*

View file

@ -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) => (
<span className="flex min-w-0 items-center gap-1.5">
<ProjectSourceBadge source={getOptionSource(option)} />

View file

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

View file

@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
@ -75,103 +84,105 @@ export const Combobox = ({
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
sideOffset={4}
collisionPadding={8}
avoidCollisions
>
<CommandPrimitive
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
shouldFilter={false}
{open ? (
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
sideOffset={4}
collisionPadding={8}
avoidCollisions
>
<div className="flex items-center border-b border-[var(--color-border)]">
<CommandPrimitive.Input
value={search}
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
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
shouldFilter={false}
>
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
{emptyMessage}
</CommandPrimitive.Empty>
{onReset && value && !search.trim() ? (
<CommandPrimitive.Item
value="__reset__"
onSelect={() => {
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)]"
>
<X className="mr-2 size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[var(--color-text-muted)]">
{resetLabel ?? 'Reset selection'}
</span>
</CommandPrimitive.Item>
) : 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 (
<CommandPrimitive.Item
key={option.value}
value={option.value}
disabled={option.disabled}
aria-disabled={option.disabled === true}
onSelect={() => {
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 ? <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>
{option.description ? (
<p className="truncate text-[var(--color-text-muted)]">
{option.description}
<div className="flex items-center border-b border-[var(--color-border)]">
<CommandPrimitive.Input
value={search}
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)]">
{emptyMessage}
</CommandPrimitive.Empty>
{onReset && value && !search.trim() ? (
<CommandPrimitive.Item
value="__reset__"
onSelect={() => {
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)]"
>
<X className="mr-2 size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[var(--color-text-muted)]">
{resetLabel ?? 'Reset selection'}
</span>
</CommandPrimitive.Item>
) : 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 (
<CommandPrimitive.Item
key={option.value}
value={option.value}
disabled={option.disabled}
aria-disabled={option.disabled === true}
onSelect={() => {
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 ? <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>
) : null}
</div>
</>
)}
</CommandPrimitive.Item>
);
})}
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
{option.description ? (
<p className="truncate text-[var(--color-text-muted)]">
{option.description}
</p>
) : null}
</div>
</>
)}
</CommandPrimitive.Item>
);
})}
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
) : null}
</Popover>
);
};

View file

@ -19,6 +19,8 @@ type RuntimeAwareProviderStatus = Pick<
CliProviderStatus,
'providerId' | 'authMethod' | 'backend' | 'modelCatalog'
>;
type RuntimeModelCatalog = NonNullable<RuntimeAwareProviderStatus['modelCatalog']>;
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<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(
providerId: SupportedProviderId | undefined,
model: string | undefined,
providerStatus?: RuntimeAwareProviderStatus | null
): NonNullable<RuntimeAwareProviderStatus['modelCatalog']>['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(