perf(renderer): gate timeline live lead props

This commit is contained in:
777genius 2026-05-31 05:58:56 +03:00
parent a4ad5f6297
commit ad2f602cba
2 changed files with 156 additions and 27 deletions

View file

@ -53,7 +53,11 @@ import {
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { ActivityTimeline, type TimelineViewport } from '../activity/ActivityTimeline'; import { ActivityTimeline, type TimelineViewport } from '../activity/ActivityTimeline';
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; import {
getThoughtGroupKey,
groupTimelineItems,
isLeadThought,
} from '../activity/LeadThoughtsGroup';
import { MessageExpandDialog } from '../activity/MessageExpandDialog'; import { MessageExpandDialog } from '../activity/MessageExpandDialog';
import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
import { import {
@ -691,6 +695,15 @@ export const MessagesPanel = memo(function MessagesPanel({
searchQuery: messagesSearchQuery, searchQuery: messagesSearchQuery,
}); });
}, [effectiveMessages, leadNames, messagesFilter, messagesSearchQuery, timeWindow]); }, [effectiveMessages, leadNames, messagesFilter, messagesSearchQuery, timeWindow]);
const firstTimelineMessage = activityTimelineMessages[0];
const hasVisibleCurrentLeadThought =
firstTimelineMessage != null &&
isLeadThought(firstTimelineMessage) &&
(currentLeadSessionId ? firstTimelineMessage.leadSessionId === currentLeadSessionId : true);
const timelineLeadActivity = hasVisibleCurrentLeadThought ? leadActivity : undefined;
const timelineLeadContextUpdatedAt = hasVisibleCurrentLeadThought
? leadContextUpdatedAt
: undefined;
const hasTrackedPendingReplies = useMemo( const hasTrackedPendingReplies = useMemo(
() => Object.keys(pendingRepliesByMember).length > 0, () => Object.keys(pendingRepliesByMember).length > 0,
@ -1175,8 +1188,8 @@ export const MessagesPanel = memo(function MessagesPanel({
onToggleExpandOverride={toggleExpandOverride} onToggleExpandOverride={toggleExpandOverride}
currentLeadSessionId={currentLeadSessionId} currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive} isTeamAlive={isTeamAlive}
leadActivity={leadActivity} leadActivity={timelineLeadActivity}
leadContextUpdatedAt={leadContextUpdatedAt} leadContextUpdatedAt={timelineLeadContextUpdatedAt}
teamNames={teamNames} teamNames={teamNames}
teamColorByName={teamColorByName} teamColorByName={teamColorByName}
onTeamClick={openTeamTab} onTeamClick={openTeamTab}

View file

@ -54,6 +54,7 @@ const readHookState = {
markRead: vi.fn(), markRead: vi.fn(),
markAllRead: vi.fn(), markAllRead: vi.fn(),
}; };
const activityTimelineRenderSpy = vi.hoisted(() => vi.fn());
const expandedHookState = { const expandedHookState = {
expandedSet: new Set<string>(), expandedSet: new Set<string>(),
@ -161,37 +162,44 @@ vi.mock('@renderer/components/team/activity/ActivityTimeline', () => ({
loading, loading,
revisionMessageId, revisionMessageId,
onReviseMessage, onReviseMessage,
leadActivity,
leadContextUpdatedAt,
}: { }: {
messages: InboxMessage[]; messages: InboxMessage[];
loading?: boolean; loading?: boolean;
revisionMessageId?: string | null; revisionMessageId?: string | null;
onReviseMessage?: (message: InboxMessage) => void; onReviseMessage?: (message: InboxMessage) => void;
leadActivity?: string;
leadContextUpdatedAt?: string;
}) => }) =>
React.createElement( (() => {
'div', activityTimelineRenderSpy({ leadActivity, leadContextUpdatedAt, messages });
{ 'data-testid': 'activity-timeline' }, return React.createElement(
loading ? React.createElement('div', null, 'timeline-loading') : null, 'div',
messages.map((message) => { 'data-testid': 'activity-timeline' },
React.createElement( loading ? React.createElement('div', null, 'timeline-loading') : null,
'div', messages.map((message) =>
{ React.createElement(
key: message.messageId ?? `${message.from}-${message.timestamp}`, 'div',
'data-message-id': message.messageId ?? '', {
}, key: message.messageId ?? `${message.from}-${message.timestamp}`,
`${message.messageId ?? 'no-id'}:${message.text}`, 'data-message-id': message.messageId ?? '',
message.messageId === revisionMessageId },
? React.createElement( `${message.messageId ?? 'no-id'}:${message.text}`,
'button', message.messageId === revisionMessageId
{ ? React.createElement(
type: 'button', 'button',
onClick: () => onReviseMessage?.(message), {
}, type: 'button',
'Edit message' onClick: () => onReviseMessage?.(message),
) },
: null 'Edit message'
)
: null
)
) )
) );
), })(),
})); }));
vi.mock('@renderer/components/team/activity/MessageExpandDialog', () => ({ vi.mock('@renderer/components/team/activity/MessageExpandDialog', () => ({
@ -238,6 +246,7 @@ describe('MessagesPanel idle summary invariants', () => {
readHookState.readSet = new Set<string>(); readHookState.readSet = new Set<string>();
readHookState.markRead.mockReset(); readHookState.markRead.mockReset();
readHookState.markAllRead.mockReset(); readHookState.markAllRead.mockReset();
activityTimelineRenderSpy.mockClear();
expandedHookState.expandedSet = new Set<string>(); expandedHookState.expandedSet = new Set<string>();
expandedHookState.toggle.mockReset(); expandedHookState.toggle.mockReset();
storeState.sendTeamMessage.mockClear(); storeState.sendTeamMessage.mockClear();
@ -335,6 +344,113 @@ describe('MessagesPanel idle summary invariants', () => {
}); });
}); });
it('does not pass live lead status to timeline when newest visible item is not a current lead thought', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
storeState.teamMessagesByName['atlas-hq'] = {
canonicalMessages: [makeMessage({ messageId: 'm-1', text: 'ordinary message' })],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
};
root.render(
React.createElement(MessagesPanel, {
teamName: 'atlas-hq',
position: 'sidebar',
onPositionChange: vi.fn(),
members: [],
tasks: [],
isTeamAlive: true,
leadActivity: 'active',
leadContextUpdatedAt: '2026-05-31T10:00:00.000Z',
currentLeadSessionId: 'lead-session-current',
timeWindow: null,
pendingRepliesByMember: {},
onPendingReplyChange: vi.fn(),
})
);
await Promise.resolve();
});
expect(activityTimelineRenderSpy).toHaveBeenCalled();
expect(activityTimelineRenderSpy.mock.lastCall?.[0]).toMatchObject({
leadActivity: undefined,
leadContextUpdatedAt: undefined,
});
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes live lead status when newest visible item is the current lead thought', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
storeState.teamMessagesByName['atlas-hq'] = {
canonicalMessages: [
makeMessage({
from: 'lead',
to: undefined,
source: 'lead_session',
leadSessionId: 'lead-session-current',
messageId: 'lead-thought-1',
text: 'thinking',
}),
],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
};
root.render(
React.createElement(MessagesPanel, {
teamName: 'atlas-hq',
position: 'sidebar',
onPositionChange: vi.fn(),
members: [],
tasks: [],
isTeamAlive: true,
leadActivity: 'active',
leadContextUpdatedAt: '2026-05-31T10:00:00.000Z',
currentLeadSessionId: 'lead-session-current',
timeWindow: null,
pendingRepliesByMember: {},
onPendingReplyChange: vi.fn(),
})
);
await Promise.resolve();
});
expect(activityTimelineRenderSpy).toHaveBeenCalled();
expect(activityTimelineRenderSpy.mock.lastCall?.[0]).toMatchObject({
leadActivity: 'active',
leadContextUpdatedAt: '2026-05-31T10:00:00.000Z',
});
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('persists sidebar scroll position after scroll settles', async () => { it('persists sidebar scroll position after scroll settles', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);