perf: defer message composer suggestion data
This commit is contained in:
parent
4b85433afb
commit
fa3f8ce85c
2 changed files with 127 additions and 27 deletions
|
|
@ -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<HTMLTextAreaElement>;
|
||||
onFocus?: React.FocusEventHandler<HTMLTextAreaElement>;
|
||||
}
|
||||
>(({ 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<string, unknown>) => 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
|
||||
const [aliveTeams, setAliveTeams] = useState<Set<string>>(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<string | null>(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<MentionSuggestion[]>(
|
||||
() =>
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue