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,
|
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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════
|
{/* ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue