From fa3f8ce85c40ebbbf409d120ff570048c40b103d Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 29 May 2026 12:25:53 +0300 Subject: [PATCH] perf: defer message composer suggestion data --- .../MessageComposer.pendingSend.test.tsx | 89 +++++++++++++++++-- .../team/messages/MessageComposer.tsx | 65 +++++++++----- 2 files changed, 127 insertions(+), 27 deletions(-) diff --git a/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx b/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx index 247dd78d..7f1818b2 100644 --- a/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx +++ b/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx @@ -71,6 +71,24 @@ const provisioningHarness = vi.hoisted(() => { }; }); +type SuggestionHookOptions = { + enabled?: boolean; +}; + +const suggestionHarness = vi.hoisted(() => { + const state = { + taskOptions: [] as SuggestionHookOptions[], + teamOptions: [] as SuggestionHookOptions[], + }; + return { + reset: () => { + state.taskOptions = []; + state.teamOptions = []; + }, + state, + }; +}); + const storeHarness = vi.hoisted(() => { const state = { crossTeamTargets: [] as { @@ -83,9 +101,16 @@ const storeHarness = vi.hoisted(() => { isOnline?: boolean; }[], }; + const methods = { + fetchCrossTeamTargets: vi.fn(), + fetchSkillsCatalog: vi.fn(), + }; return { + methods, reset: () => { state.crossTeamTargets = []; + methods.fetchCrossTeamTargets.mockClear(); + methods.fetchSkillsCatalog.mockClear(); }, state, }; @@ -129,14 +154,18 @@ vi.mock('@renderer/components/ui/MentionableTextarea', () => { cornerAction?: React.ReactNode; cornerActionLeft?: React.ReactNode; footerRight?: React.ReactNode; + onBlur?: React.FocusEventHandler; + onFocus?: React.FocusEventHandler; } - >(({ value, disabled, cornerAction, cornerActionLeft, footerRight }, ref) => + >(({ value, disabled, cornerAction, cornerActionLeft, footerRight, onBlur, onFocus }, ref) => React.createElement( 'div', null, React.createElement('textarea', { 'aria-label': 'Message', disabled, + onBlur, + onFocus, readOnly: true, ref, value, @@ -198,19 +227,25 @@ vi.mock('@renderer/hooks/useComposerDraft', () => ({ })); vi.mock('@renderer/hooks/useTaskSuggestions', () => ({ - useTaskSuggestions: () => ({ suggestions: [] }), + useTaskSuggestions: (_teamName: string | null, options: SuggestionHookOptions = {}) => { + suggestionHarness.state.taskOptions.push(options); + return { suggestions: [] }; + }, })); vi.mock('@renderer/hooks/useTeamSuggestions', () => ({ - useTeamSuggestions: () => ({ suggestions: [] }), + useTeamSuggestions: (_teamName: string | null, options: SuggestionHookOptions = {}) => { + suggestionHarness.state.teamOptions.push(options); + return { suggestions: [] }; + }, })); vi.mock('@renderer/store', () => ({ useStore: (selector: (state: Record) => unknown) => selector({ crossTeamTargets: storeHarness.state.crossTeamTargets, - fetchCrossTeamTargets: vi.fn(), - fetchSkillsCatalog: vi.fn(), + fetchCrossTeamTargets: storeHarness.methods.fetchCrossTeamTargets, + fetchSkillsCatalog: storeHarness.methods.fetchSkillsCatalog, selectedTeamData: null, selectedTeamName: null, skillsProjectCatalogByProjectPath: {}, @@ -312,6 +347,7 @@ describe('MessageComposer pending send lifecycle', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); draftHarness.reset(); provisioningHarness.reset(); + suggestionHarness.reset(); storeHarness.reset(); }); @@ -649,4 +685,47 @@ describe('MessageComposer pending send lifecycle', () => { root.unmount(); }); }); + + it('defers expensive mention data until the matching trigger is typed', () => { + draftHarness.state.text = ''; + const { host, render, root } = renderComposer(); + + expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false); + expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false); + expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled(); + expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled(); + + act(() => { + getTextarea(host).focus(); + }); + + expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false); + expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false); + expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled(); + expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled(); + + draftHarness.state.text = '#'; + render(); + + expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(true); + expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(false); + expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled(); + + draftHarness.state.text = '@'; + render(); + + expect(suggestionHarness.state.taskOptions.at(-1)?.enabled).toBe(false); + expect(suggestionHarness.state.teamOptions.at(-1)?.enabled).toBe(true); + expect(storeHarness.methods.fetchSkillsCatalog).not.toHaveBeenCalled(); + + draftHarness.state.text = '/'; + render(); + + expect(storeHarness.methods.fetchSkillsCatalog).toHaveBeenCalledTimes(1); + expect(storeHarness.methods.fetchCrossTeamTargets).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index f7d0b4a3..24b18594 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -112,6 +112,8 @@ let pendingSendIdCounter = 0; const FLOATING_COMPOSER_MIN_WIDTH = 350; const FLOATING_COMPOSER_MAX_WIDTH = 500; const FLOATING_COMPOSER_TEXT_BUFFER = 4; +const EMPTY_MENTION_SUGGESTIONS: MentionSuggestion[] = []; +const EMPTY_SKILL_CATALOG = [] as const; function createPendingSendId(): string { const randomId = globalThis.crypto?.randomUUID?.(); @@ -189,13 +191,10 @@ export const MessageComposer = ({ const [selectedTeam, setSelectedTeam] = useState(null); const [teamSelectorOpen, setTeamSelectorOpen] = useState(false); const [aliveTeams, setAliveTeams] = useState>(new Set()); + const crossTeamTargetsFetchedRef = useRef(false); const allCrossTeamTargets = useStore(useShallow((s) => s.crossTeamTargets)); const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets); - useEffect(() => { - void fetchCrossTeamTargets(); - }, [fetchCrossTeamTargets]); - const refreshAliveTeams = useCallback(async () => { try { const list = await api.teams.aliveList(); @@ -205,14 +204,14 @@ export const MessageComposer = ({ } }, []); - useEffect(() => { - void refreshAliveTeams(); - }, [refreshAliveTeams]); - useEffect(() => { if (!teamSelectorOpen) return; + if (!crossTeamTargetsFetchedRef.current) { + crossTeamTargetsFetchedRef.current = true; + void fetchCrossTeamTargets(); + } void refreshAliveTeams(); - }, [teamSelectorOpen, refreshAliveTeams]); + }, [fetchCrossTeamTargets, refreshAliveTeams, teamSelectorOpen]); // Always filter out current team on the UI side (store is global, shared across tabs) const crossTeamTargets = useMemo( @@ -273,6 +272,15 @@ export const MessageComposer = ({ const isProvisioning = useStore((s) => isTeamProvisioningActive(s, teamName)); const draft = useComposerDraft(teamName); const appliedRevisionRequestIdRef = useRef(null); + const textHasTeamMentionTrigger = draft.text.includes('@'); + const textHasTaskMentionTrigger = draft.text.includes('#'); + const textHasSlashCommandTrigger = stripEncodedTaskReferenceMetadata(draft.text) + .trimStart() + .startsWith('/'); + const taskSuggestionDataEnabled = + textHasTaskMentionTrigger || draft.chips.length > 0 || revisionRequest != null; + const teamSuggestionDataEnabled = textHasTeamMentionTrigger; + const slashCommandDataEnabled = textHasSlashCommandTrigger; const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -293,30 +301,43 @@ export const MessageComposer = ({ ); }, [members]); - const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); - const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); + const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName, { + enabled: teamSuggestionDataEnabled, + }); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName, { + enabled: taskSuggestionDataEnabled, + }); // Project skills as slash command suggestions const projectSkills = useStore( - useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [])) + useShallow((s) => + slashCommandDataEnabled && projectPath + ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? EMPTY_SKILL_CATALOG) + : EMPTY_SKILL_CATALOG + ) + ); + const userSkills = useStore( + useShallow((s) => (slashCommandDataEnabled ? s.skillsUserCatalog : EMPTY_SKILL_CATALOG)) ); - const userSkills = useStore(useShallow((s) => s.skillsUserCatalog)); const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); const isLaunchBlocking = isProvisioning && !isTeamAlive; - // Fetch skills catalog for the team's project on mount / project change + // Fetch the catalog only when slash suggestions are actually needed. useEffect(() => { + if (!slashCommandDataEnabled) return; void fetchSkillsCatalog(projectPath ?? undefined); - }, [fetchSkillsCatalog, projectPath]); + }, [fetchSkillsCatalog, projectPath, slashCommandDataEnabled]); const slashCommandSuggestions = useMemo( () => - buildSlashCommandSuggestions( - getSuggestedSlashCommandsForProvider(leadProviderId), - projectSkills, - userSkills, - leadProviderId - ), - [leadProviderId, projectSkills, userSkills] + slashCommandDataEnabled + ? buildSlashCommandSuggestions( + getSuggestedSlashCommandsForProvider(leadProviderId), + projectSkills, + userSkills, + leadProviderId + ) + : EMPTY_MENTION_SUGGESTIONS, + [leadProviderId, projectSkills, slashCommandDataEnabled, userSkills] ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();