perf(renderer): defer launch dialog heavy work
This commit is contained in:
parent
174ad83b47
commit
53a4c0e9e6
5 changed files with 169 additions and 105 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ function repositoryWorktreeToProject(
|
|||
};
|
||||
}
|
||||
|
||||
function syntheticProjectFromPath(projectPath: string): Project {
|
||||
export function syntheticProjectFromPath(projectPath: string): Project {
|
||||
return {
|
||||
id: projectPath.replace(/[/\\]/g, '-'),
|
||||
path: projectPath,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue