perf(team): lazy load create team suggestions

This commit is contained in:
777genius 2026-05-24 15:36:34 +03:00
parent f6b2bc4cec
commit bc8d47aaa2
8 changed files with 106 additions and 33 deletions

View file

@ -46,7 +46,6 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useCreateTeamDraft } from '@renderer/hooks/useCreateTeamDraft'; import { useCreateTeamDraft } from '@renderer/hooks/useCreateTeamDraft';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
@ -473,6 +472,7 @@ export const CreateTeamDialog = ({
>({}); >({});
const [providerSettingsProviderId, setProviderSettingsProviderId] = const [providerSettingsProviderId, setProviderSettingsProviderId] =
useState<TeamProviderId | null>(null); useState<TeamProviderId | null>(null);
const [workflowMentionSuggestionsEnabled, setWorkflowMentionSuggestionsEnabled] = useState(false);
const prepareRequestSeqRef = useRef(0); const prepareRequestSeqRef = useRef(0);
const prepareIdleHandlesRef = useRef(new Set<ScheduledIdleHandle>()); const prepareIdleHandlesRef = useRef(new Set<ScheduledIdleHandle>());
const prepareUnmountGenerationRef = useRef(0); const prepareUnmountGenerationRef = useRef(0);
@ -561,6 +561,9 @@ export const CreateTeamDialog = ({
setSelectedFastModeRaw(value); setSelectedFastModeRaw(value);
setStoredCreateTeamFastMode(value); setStoredCreateTeamFastMode(value);
}, []); }, []);
const enableWorkflowMentionSuggestions = useCallback((): void => {
setWorkflowMentionSuggestionsEnabled(true);
}, []);
const setWorktreeEnabled = (value: boolean): void => { const setWorktreeEnabled = (value: boolean): void => {
setWorktreeEnabledRaw(value); setWorktreeEnabledRaw(value);
@ -1235,6 +1238,7 @@ export const CreateTeamDialog = ({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setWorkflowMentionSuggestionsEnabled(false);
return; return;
} }
@ -1430,10 +1434,12 @@ export const CreateTeamDialog = ({
setSelectedProjectPath(''); setSelectedProjectPath('');
}, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]); }, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]);
useFileListCacheWarmer(effectiveCwd || null); const { suggestions: taskSuggestions } = useTaskSuggestions(null, {
enabled: workflowMentionSuggestionsEnabled,
const { suggestions: taskSuggestions } = useTaskSuggestions(null); });
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null, {
enabled: workflowMentionSuggestionsEnabled,
});
const description = descriptionDraft.value; const description = descriptionDraft.value;
const prompt = promptDraft.value; const prompt = promptDraft.value;
@ -2206,6 +2212,7 @@ export const CreateTeamDialog = ({
projectPath={effectiveCwd || null} projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions} taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions} teamSuggestions={teamMentionSuggestions}
onWorkflowSuggestionsNeeded={enableWorkflowMentionSuggestions}
defaultProviderId={selectedProviderId} defaultProviderId={selectedProviderId}
inheritedProviderId={selectedProviderId} inheritedProviderId={selectedProviderId}
inheritedModel={selectedModel} inheritedModel={selectedModel}
@ -2297,6 +2304,11 @@ export const CreateTeamDialog = ({
title="Optional launch settings" title="Optional launch settings"
description="Prompt, safety, and CLI overrides live here when you need them." description="Prompt, safety, and CLI overrides live here when you need them."
summary={launchOptionalSummary} summary={launchOptionalSummary}
onOpenChange={(isOpen) => {
if (isOpen) {
enableWorkflowMentionSuggestions();
}
}}
> >
<div className="space-y-4"> <div className="space-y-4">
{selectedProviderId === 'anthropic' ? ( {selectedProviderId === 'anthropic' ? (

View file

@ -10,6 +10,7 @@ interface OptionalSettingsSectionProps {
summary?: string[]; summary?: string[];
defaultOpen?: boolean; defaultOpen?: boolean;
className?: string; className?: string;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode; children: React.ReactNode;
} }
@ -61,6 +62,7 @@ export const OptionalSettingsSection = ({
summary = [], summary = [],
defaultOpen = false, defaultOpen = false,
className, className,
onOpenChange,
children, children,
}: OptionalSettingsSectionProps): React.JSX.Element => { }: OptionalSettingsSectionProps): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(defaultOpen); const [isOpen, setIsOpen] = useState(defaultOpen);
@ -106,6 +108,12 @@ export const OptionalSettingsSection = ({
? 'color-mix(in srgb, var(--color-text-muted) 64%, var(--color-text) 36%)' ? 'color-mix(in srgb, var(--color-text-muted) 64%, var(--color-text) 36%)'
: 'color-mix(in srgb, var(--color-text-muted) 54%, white 46%)'; : 'color-mix(in srgb, var(--color-text-muted) 54%, white 46%)';
const handleToggleOpen = (): void => {
const nextOpen = !isOpen;
setIsOpen(nextOpen);
onOpenChange?.(nextOpen);
};
return ( return (
<div <div
className={cn( className={cn(
@ -119,7 +127,7 @@ export const OptionalSettingsSection = ({
<button <button
type="button" type="button"
className="flex w-full items-center gap-3 p-2.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]" className="flex w-full items-center gap-3 p-2.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={() => setIsOpen((prev) => !prev)} onClick={handleToggleOpen}
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<div <div

View file

@ -83,6 +83,7 @@ interface MemberDraftRowProps {
mentionSuggestions?: MentionSuggestion[]; mentionSuggestions?: MentionSuggestion[];
taskSuggestions?: MentionSuggestion[]; taskSuggestions?: MentionSuggestion[];
teamSuggestions?: MentionSuggestion[]; teamSuggestions?: MentionSuggestion[];
onWorkflowSuggestionsNeeded?: () => void;
lockProviderModel?: boolean; lockProviderModel?: boolean;
lockRole?: boolean; lockRole?: boolean;
lockedRoleLabel?: string; lockedRoleLabel?: string;
@ -144,6 +145,7 @@ export const MemberDraftRow = ({
mentionSuggestions = [], mentionSuggestions = [],
taskSuggestions, taskSuggestions,
teamSuggestions, teamSuggestions,
onWorkflowSuggestionsNeeded,
lockProviderModel = false, lockProviderModel = false,
lockRole = false, lockRole = false,
lockedRoleLabel, lockedRoleLabel,
@ -428,6 +430,15 @@ export const MemberDraftRow = ({
effectiveModel?.trim() ?? '', effectiveModel?.trim() ?? '',
effectiveEffort effectiveEffort
); );
const toggleWorkflowExpanded = useCallback(() => {
setWorkflowExpanded((prev) => {
const next = !prev;
if (next) {
onWorkflowSuggestionsNeeded?.();
}
return next;
});
}, [onWorkflowSuggestionsNeeded]);
return ( return (
<div <div
@ -604,7 +615,7 @@ export const MemberDraftRow = ({
aria-label={workflowTooltipText} aria-label={workflowTooltipText}
aria-expanded={workflowExpanded} aria-expanded={workflowExpanded}
disabled={isRemoved} disabled={isRemoved}
onClick={() => setWorkflowExpanded((prev) => !prev)} onClick={toggleWorkflowExpanded}
> >
<WorkflowIcon className="size-3.5" /> <WorkflowIcon className="size-3.5" />
{!workflowExpanded && workflowDraft.value.trim() ? ( {!workflowExpanded && workflowDraft.value.trim() ? (

View file

@ -111,6 +111,8 @@ export interface MembersEditorSectionProps {
taskSuggestions?: MentionSuggestion[]; taskSuggestions?: MentionSuggestion[];
/** Team suggestions for @@team mentions in workflow */ /** Team suggestions for @@team mentions in workflow */
teamSuggestions?: MentionSuggestion[]; teamSuggestions?: MentionSuggestion[];
/** Called before workflow mention suggestions are needed. */
onWorkflowSuggestionsNeeded?: () => void;
/** Extra content rendered right below the "Members" label row */ /** Extra content rendered right below the "Members" label row */
headerExtra?: React.ReactNode; headerExtra?: React.ReactNode;
/** When true, hides member rows and action buttons (label + headerExtra still visible) */ /** When true, hides member rows and action buttons (label + headerExtra still visible) */
@ -166,6 +168,7 @@ export const MembersEditorSection = ({
projectPath, projectPath,
taskSuggestions, taskSuggestions,
teamSuggestions, teamSuggestions,
onWorkflowSuggestionsNeeded,
headerExtra, headerExtra,
hideContent = false, hideContent = false,
existingMembers, existingMembers,
@ -552,6 +555,7 @@ export const MembersEditorSection = ({
mentionSuggestions={mentionSuggestions} mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions} taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions} teamSuggestions={teamSuggestions}
onWorkflowSuggestionsNeeded={onWorkflowSuggestionsNeeded}
lockProviderModel={lockProviderModel} lockProviderModel={lockProviderModel}
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())} lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
identityLockReason={identityLockReason} identityLockReason={identityLockReason}
@ -604,6 +608,7 @@ export const MembersEditorSection = ({
mentionSuggestions={mentionSuggestions} mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions} taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions} teamSuggestions={teamSuggestions}
onWorkflowSuggestionsNeeded={onWorkflowSuggestionsNeeded}
lockProviderModel lockProviderModel
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings." modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
isRemoved isRemoved

View file

@ -20,6 +20,7 @@ interface TeamRosterEditorSectionProps {
projectPath?: string | null; projectPath?: string | null;
taskSuggestions?: MentionSuggestion[]; taskSuggestions?: MentionSuggestion[];
teamSuggestions?: MentionSuggestion[]; teamSuggestions?: MentionSuggestion[];
onWorkflowSuggestionsNeeded?: () => void;
hideMembersContent?: boolean; hideMembersContent?: boolean;
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[]; existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
defaultProviderId?: TeamProviderId; defaultProviderId?: TeamProviderId;
@ -76,6 +77,7 @@ const TeamRosterEditorSectionImpl = ({
projectPath, projectPath,
taskSuggestions, taskSuggestions,
teamSuggestions, teamSuggestions,
onWorkflowSuggestionsNeeded,
hideMembersContent = false, hideMembersContent = false,
existingMembers, existingMembers,
defaultProviderId = 'anthropic', defaultProviderId = 'anthropic',
@ -153,6 +155,7 @@ const TeamRosterEditorSectionImpl = ({
projectPath={projectPath} projectPath={projectPath}
taskSuggestions={taskSuggestions} taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions} teamSuggestions={teamSuggestions}
onWorkflowSuggestionsNeeded={onWorkflowSuggestionsNeeded}
hideContent={hideMembersContent} hideContent={hideMembersContent}
existingMembers={existingMembers} existingMembers={existingMembers}
defaultProviderId={defaultProviderId} defaultProviderId={defaultProviderId}

View file

@ -6,7 +6,7 @@
* Folders are derived from file paths (no extra IPC call needed). * Folders are derived from file paths (no extra IPC call needed).
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
getQuickOpenCache, getQuickOpenCache,
@ -190,16 +190,6 @@ export function useFileSuggestions(
return onQuickOpenCacheInvalidated(() => setFetchTrigger((n) => n + 1)); return onQuickOpenCacheInvalidated(() => setFetchTrigger((n) => n + 1));
}, []); }, []);
// Lazy refetch: when dropdown opens and cache is stale, trigger a reload
const prevEnabledRef = useRef(enabled);
useEffect(() => {
if (enabled && !prevEnabledRef.current && projectPath && !getQuickOpenCache(projectPath)) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional trigger on state transition
setFetchTrigger((n) => n + 1);
}
prevEnabledRef.current = enabled;
}, [enabled, projectPath]);
// Load files from API when cache is empty. // Load files from API when cache is empty.
// Uses project:listFiles (not editor:listFiles) — works without editor being open. // Uses project:listFiles (not editor:listFiles) — works without editor being open.
const fetchFiles = useCallback( const fetchFiles = useCallback(
@ -231,13 +221,14 @@ export function useFileSuggestions(
// - effect (projectPath change) // - effect (projectPath change)
useEffect(() => { useEffect(() => {
if (!projectPath) return; if (!projectPath) return;
if (!enabled) return;
const cached = getQuickOpenCache(projectPath); const cached = getQuickOpenCache(projectPath);
if (cached) return; if (cached) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- setLoading before async fetch is intentional // eslint-disable-next-line react-hooks/set-state-in-effect -- setLoading before async fetch is intentional
return fetchFiles(projectPath); return fetchFiles(projectPath);
}, [projectPath, fetchTrigger, fetchFiles]); }, [projectPath, enabled, fetchTrigger, fetchFiles]);
// Derive folders from file list (memoized) // Derive folders from file list (memoized)
const allFolders = useMemo( const allFolders = useMemo(

View file

@ -10,12 +10,22 @@ import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { GlobalTask, TeamTaskWithKanban } from '@shared/types'; import type { GlobalTask, TeamSummary, TeamTaskWithKanban } from '@shared/types';
const EMPTY_GLOBAL_TASKS: GlobalTask[] = [];
const EMPTY_TEAM_TASKS: TeamTaskWithKanban[] = [];
const EMPTY_TEAM_MEMBERS: NonNullable<TeamSummary['members']> = [];
const EMPTY_TEAM_BY_NAME: Record<string, TeamSummary> = {};
const EMPTY_TASK_SUGGESTIONS: MentionSuggestion[] = [];
export interface UseTaskSuggestionsResult { export interface UseTaskSuggestionsResult {
suggestions: MentionSuggestion[]; suggestions: MentionSuggestion[];
} }
interface UseTaskSuggestionsOptions {
enabled?: boolean;
}
interface TaskWithTeamContext { interface TaskWithTeamContext {
task: TeamTaskWithKanban | GlobalTask; task: TeamTaskWithKanban | GlobalTask;
teamName: string; teamName: string;
@ -60,19 +70,29 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean {
return task.status !== 'deleted' && !task.deletedAt; return task.status !== 'deleted' && !task.deletedAt;
} }
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult { export function useTaskSuggestions(
currentTeamName: string | null,
options: UseTaskSuggestionsOptions = {}
): UseTaskSuggestionsResult {
const enabled = options.enabled ?? true;
const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore( const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore(
useShallow((s) => ({ useShallow((s) => ({
globalTasks: s.globalTasks, globalTasks: enabled ? s.globalTasks : EMPTY_GLOBAL_TASKS,
currentTeamData: currentTeamName ? selectTeamDataForName(s, currentTeamName) : null, currentTeamData:
currentTeamMembers: currentTeamName enabled && currentTeamName ? selectTeamDataForName(s, currentTeamName) : null,
? selectResolvedMembersForTeamName(s, currentTeamName) currentTeamMembers:
: [], enabled && currentTeamName
teamByName: s.teamByName, ? selectResolvedMembersForTeamName(s, currentTeamName)
: EMPTY_TEAM_MEMBERS,
teamByName: enabled ? s.teamByName : EMPTY_TEAM_BY_NAME,
})) }))
); );
const suggestions = useMemo<MentionSuggestion[]>(() => { const suggestions = useMemo<MentionSuggestion[]>(() => {
if (!enabled) {
return EMPTY_TASK_SUGGESTIONS;
}
const tasks: TaskWithTeamContext[] = []; const tasks: TaskWithTeamContext[] = [];
const seenTaskIds = new Set<string>(); const seenTaskIds = new Set<string>();
@ -80,7 +100,10 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
const currentTeamSummary = teamByName[currentTeamName]; const currentTeamSummary = teamByName[currentTeamName];
const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName; const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName;
const currentTeamTasks = const currentTeamTasks =
currentTeamData?.tasks ?? globalTasks.filter((task) => task.teamName === currentTeamName); currentTeamData?.tasks ??
(currentTeamName
? globalTasks.filter((task) => task.teamName === currentTeamName)
: EMPTY_TEAM_TASKS);
const currentTeamMemberColors = const currentTeamMemberColors =
currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []); currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []);
@ -125,7 +148,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
}); });
return tasks.map(buildTaskSuggestion); return tasks.map(buildTaskSuggestion);
}, [currentTeamData, currentTeamMembers, currentTeamName, globalTasks, teamByName]); }, [currentTeamData, currentTeamMembers, currentTeamName, enabled, globalTasks, teamByName]);
return { suggestions }; return { suggestions };
} }

View file

@ -15,19 +15,31 @@ import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { TeamSummary } from '@shared/types';
export interface UseTeamSuggestionsResult { export interface UseTeamSuggestionsResult {
suggestions: MentionSuggestion[]; suggestions: MentionSuggestion[];
loading: boolean; loading: boolean;
} }
interface UseTeamSuggestionsOptions {
enabled?: boolean;
}
const EMPTY_TEAMS: TeamSummary[] = [];
const EMPTY_TEAM_SUGGESTIONS: MentionSuggestion[] = [];
/** /**
* Returns team MentionSuggestion[] sorted by online status (online first). * Returns team MentionSuggestion[] sorted by online status (online first).
* *
* @param currentTeamName - The current team name to exclude from suggestions * @param currentTeamName - The current team name to exclude from suggestions
*/ */
export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult { export function useTeamSuggestions(
const teams = useStore(useShallow((s) => s.teams)); currentTeamName: string | null,
options: UseTeamSuggestionsOptions = {}
): UseTeamSuggestionsResult {
const enabled = options.enabled ?? true;
const teams = useStore(useShallow((s) => (enabled ? s.teams : EMPTY_TEAMS)));
const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set()); const [aliveTeams, setAliveTeams] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -45,11 +57,19 @@ export function useTeamSuggestions(currentTeamName: string | null): UseTeamSugge
// Fetch on mount and when teams list changes // Fetch on mount and when teams list changes
useEffect(() => { useEffect(() => {
if (!enabled) {
setLoading(false);
return;
}
void fetchAlive(); void fetchAlive();
}, [fetchAlive, teams]); }, [enabled, fetchAlive, teams]);
// Build suggestion list sorted: online first, then offline // Build suggestion list sorted: online first, then offline
const suggestions = useMemo<MentionSuggestion[]>(() => { const suggestions = useMemo<MentionSuggestion[]>(() => {
if (!enabled) {
return EMPTY_TEAM_SUGGESTIONS;
}
const nonDeleted = teams.filter((t) => !t.deletedAt && t.teamName !== currentTeamName); const nonDeleted = teams.filter((t) => !t.deletedAt && t.teamName !== currentTeamName);
const result: MentionSuggestion[] = nonDeleted.map((t) => { const result: MentionSuggestion[] = nonDeleted.map((t) => {
@ -72,7 +92,7 @@ export function useTeamSuggestions(currentTeamName: string | null): UseTeamSugge
}); });
return result; return result;
}, [teams, currentTeamName, aliveTeams]); }, [enabled, teams, currentTeamName, aliveTeams]);
return { suggestions, loading }; return { suggestions, loading };
} }