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

View file

@ -10,6 +10,7 @@ interface OptionalSettingsSectionProps {
summary?: string[];
defaultOpen?: boolean;
className?: string;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
@ -61,6 +62,7 @@ export const OptionalSettingsSection = ({
summary = [],
defaultOpen = false,
className,
onOpenChange,
children,
}: OptionalSettingsSectionProps): React.JSX.Element => {
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) 54%, white 46%)';
const handleToggleOpen = (): void => {
const nextOpen = !isOpen;
setIsOpen(nextOpen);
onOpenChange?.(nextOpen);
};
return (
<div
className={cn(
@ -119,7 +127,7 @@ export const OptionalSettingsSection = ({
<button
type="button"
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}
>
<div

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
* 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 {
getQuickOpenCache,
@ -190,16 +190,6 @@ export function useFileSuggestions(
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.
// Uses project:listFiles (not editor:listFiles) — works without editor being open.
const fetchFiles = useCallback(
@ -231,13 +221,14 @@ export function useFileSuggestions(
// - effect (projectPath change)
useEffect(() => {
if (!projectPath) return;
if (!enabled) return;
const cached = getQuickOpenCache(projectPath);
if (cached) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- setLoading before async fetch is intentional
return fetchFiles(projectPath);
}, [projectPath, fetchTrigger, fetchFiles]);
}, [projectPath, enabled, fetchTrigger, fetchFiles]);
// Derive folders from file list (memoized)
const allFolders = useMemo(

View file

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

View file

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