perf(renderer): isolate pending replies from team page root

This commit is contained in:
777genius 2026-05-31 04:46:22 +03:00
parent 7019567537
commit 583bb5f26a

View file

@ -5,11 +5,11 @@ import {
Suspense, Suspense,
useCallback, useCallback,
useEffect, useEffect,
useId,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from 'react'; } from 'react';
import { useAppTranslation } from '@features/localization/renderer'; import { useAppTranslation } from '@features/localization/renderer';
@ -895,20 +895,20 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
type LeadUpdatedKey = `lead${'Con'}${'text'}UpdatedAt`; type LeadUpdatedKey = `lead${'Con'}${'text'}UpdatedAt`;
type TeamMessagesPanelBridgeProps = Omit< type TeamMessagesPanelBridgeProps = Omit<
ComponentProps<typeof MessagesPanel>, ComponentProps<typeof MessagesPanel>,
'leadActivity' | LeadUpdatedKey 'leadActivity' | LeadUpdatedKey | 'pendingRepliesByMember' | 'onPendingReplyChange'
>; >;
type SendMessageDialogBridgeProps = Omit< type SendMessageDialogBridgeProps = Omit<
ComponentProps<typeof SendMessageDialog>, ComponentProps<typeof SendMessageDialog>,
'sending' | 'sendError' | 'sendWarning' | 'sendDebugDetails' | 'lastResult' | 'onSend' 'sending' | 'sendError' | 'sendWarning' | 'sendDebugDetails' | 'lastResult' | 'onSend'
> & { >;
onPendingReplyStart: (member: string, sentAtMs: number) => void;
onPendingReplySettled: (member: string, sentAtMs: number) => void;
};
type SendMessageDialogOnSend = ComponentProps<typeof SendMessageDialog>['onSend']; type SendMessageDialogOnSend = ComponentProps<typeof SendMessageDialog>['onSend'];
type PendingRepliesUpdater =
| Record<string, number>
| ((current: Record<string, number>) => Record<string, number>);
type SharedTeamMessagesPanelProps = Omit<TeamMessagesPanelBridgeProps, 'position'>; type SharedTeamMessagesPanelProps = Omit<TeamMessagesPanelBridgeProps, 'position'>;
type TeamMemberListBridgeProps = Omit< type TeamMemberListBridgeProps = Omit<
ComponentProps<typeof MemberList>, ComponentProps<typeof MemberList>,
'leadActivity' | 'memberSpawnStatuses' 'leadActivity' | 'memberSpawnStatuses' | 'pendingRepliesByMember'
> & { > & {
teamName: string; teamName: string;
}; };
@ -932,6 +932,54 @@ interface LeadLoadBridgeProps {
isThisTabActive: boolean; isThisTabActive: boolean;
} }
const pendingRepliesCacheByTeam = new Map<string, Record<string, number>>();
const pendingRepliesListenersByTeam = new Map<string, Set<() => void>>();
let pendingReplyRefreshSourceSequence = 0;
function getPendingRepliesSnapshot(teamName: string): Record<string, number> {
let snapshot = pendingRepliesCacheByTeam.get(teamName);
if (!snapshot) {
snapshot = getTeamPendingRepliesState(teamName);
pendingRepliesCacheByTeam.set(teamName, snapshot);
}
return snapshot;
}
function subscribePendingReplies(teamName: string, listener: () => void): () => void {
let listeners = pendingRepliesListenersByTeam.get(teamName);
if (!listeners) {
listeners = new Set();
pendingRepliesListenersByTeam.set(teamName, listeners);
}
listeners.add(listener);
return () => {
listeners?.delete(listener);
if (listeners?.size === 0) {
pendingRepliesListenersByTeam.delete(teamName);
}
};
}
function setPendingRepliesForTeam(teamName: string, updater: PendingRepliesUpdater): void {
const current = getPendingRepliesSnapshot(teamName);
const next = typeof updater === 'function' ? updater(current) : updater;
if (next === current) {
return;
}
pendingRepliesCacheByTeam.set(teamName, next);
setTeamPendingRepliesState(teamName, next);
pendingRepliesListenersByTeam.get(teamName)?.forEach((listener) => listener());
}
function useTeamPendingReplies(teamName: string): Record<string, number> {
const subscribe = useCallback(
(listener: () => void) => subscribePendingReplies(teamName, listener),
[teamName]
);
const getSnapshot = useCallback(() => getPendingRepliesSnapshot(teamName), [teamName]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
const EMPTY_MESSAGES_PANEL_TASKS: TeamTaskWithKanban[] = []; const EMPTY_MESSAGES_PANEL_TASKS: TeamTaskWithKanban[] = [];
function buildMessagesPanelTasksSignature(tasks: readonly TeamTaskWithKanban[]): string { function buildMessagesPanelTasksSignature(tasks: readonly TeamTaskWithKanban[]): string {
@ -1337,6 +1385,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
teamName, teamName,
...props ...props
}: TeamMemberListBridgeProps): React.JSX.Element { }: TeamMemberListBridgeProps): React.JSX.Element {
const pendingRepliesByMember = useTeamPendingReplies(teamName);
const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } = const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } =
useStore( useStore(
useShallow((s) => ({ useShallow((s) => ({
@ -1376,6 +1425,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
{...props} {...props}
teamName={teamName} teamName={teamName}
leadActivity={leadActivity} leadActivity={leadActivity}
pendingRepliesByMember={pendingRepliesByMember}
memberSpawnStatuses={memberSpawnStatusMap} memberSpawnStatuses={memberSpawnStatusMap}
memberRuntimeEntries={memberRuntimeMap} memberRuntimeEntries={memberRuntimeMap}
runtimeRunId={runtimeRunId} runtimeRunId={runtimeRunId}
@ -1386,21 +1436,53 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({ const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({
teamName, teamName,
isTeamAlive,
...props ...props
}: TeamMessagesPanelBridgeProps): React.JSX.Element { }: TeamMessagesPanelBridgeProps): React.JSX.Element {
const { leadActivity, leadContextUpdatedAt } = useStore( const pendingRepliesByMember = useTeamPendingReplies(teamName);
const pendingReplyRefreshSourceId = useRef<string | null>(null);
if (pendingReplyRefreshSourceId.current === null) {
pendingReplyRefreshSourceSequence += 1;
pendingReplyRefreshSourceId.current = `team-messages:${pendingReplyRefreshSourceSequence}`;
}
const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore(
useShallow((s) => ({ useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName], leadActivity: s.leadActivityByTeam[teamName],
leadContextUpdatedAt: s.leadContextByTeam[teamName]?.updatedAt, leadContextUpdatedAt: s.leadContextByTeam[teamName]?.updatedAt,
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
})) }))
); );
useEffect(() => {
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
syncTeamPendingReplyRefresh(
teamName,
pendingReplyRefreshSourceId.current!,
Boolean(isTeamAlive) && hasPendingReplies,
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
);
return () => {
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId.current!, false);
};
}, [isTeamAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]);
const handlePendingReplyChange = useCallback(
(updater: PendingRepliesUpdater) => {
setPendingRepliesForTeam(teamName, updater);
},
[teamName]
);
return ( return (
<MessagesPanel <MessagesPanel
{...props} {...props}
teamName={teamName} teamName={teamName}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity} leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt} leadContextUpdatedAt={leadContextUpdatedAt}
pendingRepliesByMember={pendingRepliesByMember}
onPendingReplyChange={handlePendingReplyChange}
/> />
); );
}); });
@ -1409,19 +1491,60 @@ const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({
messagesPanelProps, messagesPanelProps,
...props ...props
}: TeamSidebarRailBridgeProps): React.JSX.Element { }: TeamSidebarRailBridgeProps): React.JSX.Element {
const { leadActivity, leadContextUpdatedAt } = useStore( const teamName = messagesPanelProps.teamName;
const pendingRepliesByMember = useTeamPendingReplies(teamName);
const pendingReplyRefreshSourceId = useRef<string | null>(null);
if (pendingReplyRefreshSourceId.current === null) {
pendingReplyRefreshSourceSequence += 1;
pendingReplyRefreshSourceId.current = `team-sidebar:${pendingReplyRefreshSourceSequence}`;
}
const { leadActivity, leadContextUpdatedAt, syncTeamPendingReplyRefresh } = useStore(
useShallow((s) => ({ useShallow((s) => ({
leadActivity: s.leadActivityByTeam[messagesPanelProps.teamName], leadActivity: s.leadActivityByTeam[teamName],
leadContextUpdatedAt: s.leadContextByTeam[messagesPanelProps.teamName]?.updatedAt, leadContextUpdatedAt: s.leadContextByTeam[teamName]?.updatedAt,
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
})) }))
); );
useEffect(() => {
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
syncTeamPendingReplyRefresh(
teamName,
pendingReplyRefreshSourceId.current!,
Boolean(messagesPanelProps.isTeamAlive) && hasPendingReplies,
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
);
return () => {
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId.current!, false);
};
}, [
messagesPanelProps.isTeamAlive,
pendingRepliesByMember,
syncTeamPendingReplyRefresh,
teamName,
]);
const handlePendingReplyChange = useCallback(
(updater: PendingRepliesUpdater) => {
setPendingRepliesForTeam(teamName, updater);
},
[teamName]
);
const bridgedMessagesPanelProps = useMemo( const bridgedMessagesPanelProps = useMemo(
() => ({ () => ({
...messagesPanelProps, ...messagesPanelProps,
leadActivity, leadActivity,
leadContextUpdatedAt, leadContextUpdatedAt,
pendingRepliesByMember,
onPendingReplyChange: handlePendingReplyChange,
}), }),
[leadActivity, leadContextUpdatedAt, messagesPanelProps] [
handlePendingReplyChange,
leadActivity,
leadContextUpdatedAt,
messagesPanelProps,
pendingRepliesByMember,
]
); );
return <TeamSidebarRail {...props} messagesPanelProps={bridgedMessagesPanelProps} />; return <TeamSidebarRail {...props} messagesPanelProps={bridgedMessagesPanelProps} />;
@ -1429,8 +1552,6 @@ const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({
const SendMessageDialogBridge = memo(function SendMessageDialogBridge({ const SendMessageDialogBridge = memo(function SendMessageDialogBridge({
teamName, teamName,
onPendingReplyStart,
onPendingReplySettled,
...props ...props
}: SendMessageDialogBridgeProps): React.JSX.Element { }: SendMessageDialogBridgeProps): React.JSX.Element {
const { const {
@ -1454,7 +1575,7 @@ const SendMessageDialogBridge = memo(function SendMessageDialogBridge({
const handleSend = useCallback<SendMessageDialogOnSend>( const handleSend = useCallback<SendMessageDialogOnSend>(
async (member, text, summary, attachments, actionMode, taskRefs) => { async (member, text, summary, attachments, actionMode, taskRefs) => {
const sentAtMs = Date.now(); const sentAtMs = Date.now();
onPendingReplyStart(member, sentAtMs); setPendingRepliesForTeam(teamName, (prev) => ({ ...prev, [member]: sentAtMs }));
try { try {
const result = await sendTeamMessage(teamName, { const result = await sendTeamMessage(teamName, {
member, member,
@ -1465,15 +1586,25 @@ const SendMessageDialogBridge = memo(function SendMessageDialogBridge({
taskRefs, taskRefs,
}); });
if (shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery)) { if (shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery)) {
onPendingReplySettled(member, sentAtMs); setPendingRepliesForTeam(teamName, (prev) => {
if (prev[member] !== sentAtMs) return prev;
const next = { ...prev };
delete next[member];
return next;
});
} }
return result; return result;
} catch (error) { } catch (error) {
onPendingReplySettled(member, sentAtMs); setPendingRepliesForTeam(teamName, (prev) => {
if (prev[member] !== sentAtMs) return prev;
const next = { ...prev };
delete next[member];
return next;
});
throw error; throw error;
} }
}, },
[onPendingReplySettled, onPendingReplyStart, sendTeamMessage, teamName] [sendTeamMessage, teamName]
); );
return ( return (
@ -1564,9 +1695,6 @@ export const TeamDetailView = memo(function TeamDetailView({
initialTab?: MemberDetailTab; initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter; initialActivityFilter?: MemberActivityFilter;
} | null>(null); } | null>(null);
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>(() =>
getTeamPendingRepliesState(teamName)
);
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({ const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
open: false, open: false,
defaultSubject: '', defaultSubject: '',
@ -1725,7 +1853,6 @@ export const TeamDetailView = memo(function TeamDetailView({
refreshTeamData, refreshTeamData,
refreshTeamMessagesHead, refreshTeamMessagesHead,
refreshMemberActivityMeta, refreshMemberActivityMeta,
syncTeamPendingReplyRefresh,
kanbanFilterQuery, kanbanFilterQuery,
clearKanbanFilter, clearKanbanFilter,
softDeleteTask, softDeleteTask,
@ -1785,7 +1912,6 @@ export const TeamDetailView = memo(function TeamDetailView({
refreshTeamData: s.refreshTeamData, refreshTeamData: s.refreshTeamData,
refreshTeamMessagesHead: s.refreshTeamMessagesHead, refreshTeamMessagesHead: s.refreshTeamMessagesHead,
refreshMemberActivityMeta: s.refreshMemberActivityMeta, refreshMemberActivityMeta: s.refreshMemberActivityMeta,
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
kanbanFilterQuery: s.kanbanFilterQuery, kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter, clearKanbanFilter: s.clearKanbanFilter,
softDeleteTask: s.softDeleteTask, softDeleteTask: s.softDeleteTask,
@ -1848,14 +1974,6 @@ export const TeamDetailView = memo(function TeamDetailView({
} }
}, [tabId, initTabUIState]); }, [tabId, initTabUIState]);
useEffect(() => {
setPendingRepliesByMember(getTeamPendingRepliesState(teamName));
}, [teamName]);
useEffect(() => {
setTeamPendingRepliesState(teamName, pendingRepliesByMember);
}, [pendingRepliesByMember, teamName]);
useEffect(() => { useEffect(() => {
const wasProvisioning = wasProvisioningRef.current; const wasProvisioning = wasProvisioningRef.current;
wasProvisioningRef.current = isTeamProvisioning; wasProvisioningRef.current = isTeamProvisioning;
@ -1960,35 +2078,11 @@ export const TeamDetailView = memo(function TeamDetailView({
); );
const leadSessionId = data?.config.leadSessionId ?? null; const leadSessionId = data?.config.leadSessionId ?? null;
const pendingReplyRefreshSourceId = useId();
const sessionHistoryKey = useMemo( const sessionHistoryKey = useMemo(
() => (data?.config.sessionHistory ?? []).join('|'), () => (data?.config.sessionHistory ?? []).join('|'),
[data?.config.sessionHistory] [data?.config.sessionHistory]
); );
// Keep team message state fresh while we are explicitly waiting for a reply.
// This stays enabled even for hidden mounted tabs, because the waiting state
// is renderer-local and should keep its lightweight polling until resolved.
useEffect(() => {
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
syncTeamPendingReplyRefresh(
teamName,
pendingReplyRefreshSourceId,
Boolean(data?.isAlive) && hasPendingReplies,
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
);
return () => {
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false);
};
}, [
data?.isAlive,
pendingRepliesByMember,
pendingReplyRefreshSourceId,
syncTeamPendingReplyRefresh,
teamName,
]);
useEffect(() => { useEffect(() => {
if (!isThisTabActive || !projectId) return; if (!isThisTabActive || !projectId) return;
@ -2832,17 +2926,6 @@ export const TeamDetailView = memo(function TeamDetailView({
}; };
const messagesPanelTasks = useStableMessagesPanelTasks(data?.tasks); const messagesPanelTasks = useStableMessagesPanelTasks(data?.tasks);
const handlePendingReplyStart = useCallback((member: string, sentAtMs: number): void => {
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
}, []);
const handlePendingReplySettled = useCallback((member: string, sentAtMs: number): void => {
setPendingRepliesByMember((prev) => {
if (prev[member] !== sentAtMs) return prev;
const next = { ...prev };
delete next[member];
return next;
});
}, []);
const sharedMessagesPanelProps = useMemo<SharedTeamMessagesPanelProps>( const sharedMessagesPanelProps = useMemo<SharedTeamMessagesPanelProps>(
() => ({ () => ({
@ -2854,8 +2937,6 @@ export const TeamDetailView = memo(function TeamDetailView({
isTeamAlive: data?.isAlive, isTeamAlive: data?.isAlive,
timeWindow, timeWindow,
currentLeadSessionId: data?.config.leadSessionId, currentLeadSessionId: data?.config.leadSessionId,
pendingRepliesByMember,
onPendingReplyChange: setPendingRepliesByMember,
onMemberClick: handleSelectMember, onMemberClick: handleSelectMember,
onTaskClick: handleOpenMessagePanelTask, onTaskClick: handleOpenMessagePanelTask,
onCreateTaskFromMessage: handleCreateTaskFromMessage, onCreateTaskFromMessage: handleCreateTaskFromMessage,
@ -2878,7 +2959,6 @@ export const TeamDetailView = memo(function TeamDetailView({
handleFloatingComposerHeightChange, handleFloatingComposerHeightChange,
messagesPanelTasks, messagesPanelTasks,
messagesPanelMountPoint, messagesPanelMountPoint,
pendingRepliesByMember,
teamName, teamName,
timeWindow, timeWindow,
changeMessagesPanelMode, changeMessagesPanelMode,
@ -3326,7 +3406,6 @@ export const TeamDetailView = memo(function TeamDetailView({
expectedTeammateCount={activeTeammateCount} expectedTeammateCount={activeTeammateCount}
memberTaskCounts={memberTaskCounts} memberTaskCounts={memberTaskCounts}
taskMap={taskMap} taskMap={taskMap}
pendingRepliesByMember={pendingRepliesByMember}
isRosterLoading={loading} isRosterLoading={loading}
isTeamAlive={data.isAlive} isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
@ -3735,8 +3814,6 @@ export const TeamDetailView = memo(function TeamDetailView({
defaultChip={sendDialogDefaultChip} defaultChip={sendDialogDefaultChip}
quotedMessage={replyQuote} quotedMessage={replyQuote}
isTeamAlive={data.isAlive} isTeamAlive={data.isAlive}
onPendingReplyStart={handlePendingReplyStart}
onPendingReplySettled={handlePendingReplySettled}
onClose={() => { onClose={() => {
setSendDialogOpen(false); setSendDialogOpen(false);
setReplyQuote(undefined); setReplyQuote(undefined);