diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index f1d8a119..470a55bf 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2936,9 +2936,11 @@ export class TeamDataService { } const leadName = transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead'; - const sessionIds = Array.from( - new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds]) - ); + const knownLeadSessionIds = this.getRecentLeadSessionIds(config); + if (knownLeadSessionIds.length === 0) { + return []; + } + const sessionIds = knownLeadSessionIds; if (sessionIds.length === 0) { return []; } diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 31b5510b..d803c2de 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -48,12 +48,6 @@ interface ActivityTimelineProps { expandOverrides?: Set; /** Called when user toggles expand/collapse override on a specific message. */ onToggleExpandOverride?: (key: string) => void; - /** - * All session IDs belonging to this team (current + history). - * Used together with currentLeadSessionId to suppress only the reconnect boundary - * from the current live session back into the team's previous session history. - */ - teamSessionIds?: Set; /** Current lead session ID for the active team, if known. */ currentLeadSessionId?: string; /** Whether the current team is alive. */ @@ -281,7 +275,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ allCollapsed, expandOverrides, onToggleExpandOverride, - teamSessionIds, currentLeadSessionId, isTeamAlive, leadActivity, @@ -425,10 +418,13 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ setVisibleCount(Infinity); }; - const getItemSessionId = (item: TimelineItem): string | undefined => - item.type === 'lead-thoughts' - ? item.group.thoughts[0].leadSessionId - : item.message.leadSessionId; + const getItemSessionAnchorId = (item: TimelineItem): string | undefined => { + if (item.type === 'lead-thoughts') { + return item.group.thoughts[0]?.leadSessionId; + } + + return undefined; + }; // Pin the newest thought group (if first) so it stays at the top and doesn't jump. const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; @@ -535,32 +531,29 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ // Session boundary separator (messages sorted desc — new on top) let sessionSeparator: React.JSX.Element | null = null; if (realIndex > 0) { - const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]); - const currSessionId = getItemSessionId(item); - if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { - // Suppress only the boundary between the current live session and the team's - // older session history. Older historical session boundaries should still render. - const isReconnectBoundary = - !!currentLeadSessionId && - teamSessionIds && - teamSessionIds.has(prevSessionId) && - teamSessionIds.has(currSessionId) && - (prevSessionId === currentLeadSessionId || currSessionId === currentLeadSessionId); - if (!isReconnectBoundary) { - sessionSeparator = ( -
-
- - New session - -
-
- ); + const currSessionId = getItemSessionAnchorId(item); + let prevSessionId: string | undefined; + for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) { + const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]); + if (candidateSessionId) { + prevSessionId = candidateSessionId; + break; } } + if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { + sessionSeparator = ( +
+
+ + New session + +
+
+ ); + } } if (item.type === 'lead-thoughts') { diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 2b61f147..7bd3ad22 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -80,8 +80,6 @@ interface MessagesPanelProps { leadContextUpdatedAt?: string; /** Time window for filtering. */ timeWindow: TimeWindow | null; - /** Team session IDs for timeline. */ - teamSessionIds: Set; /** Current lead session ID. */ currentLeadSessionId?: string; /** Pending replies tracker (shared with parent for MemberList). */ @@ -114,7 +112,6 @@ export const MessagesPanel = memo(function MessagesPanel({ leadActivity, leadContextUpdatedAt, timeWindow, - teamSessionIds, currentLeadSessionId, pendingRepliesByMember, onPendingReplyChange, @@ -658,7 +655,6 @@ export const MessagesPanel = memo(function MessagesPanel({ allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} - teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} isTeamAlive={isTeamAlive} leadActivity={leadActivity} @@ -844,7 +840,6 @@ export const MessagesPanel = memo(function MessagesPanel({ allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} - teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} isTeamAlive={isTeamAlive} leadActivity={leadActivity} @@ -1131,7 +1126,6 @@ export const MessagesPanel = memo(function MessagesPanel({ allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} - teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} isTeamAlive={isTeamAlive} leadActivity={leadActivity} diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index daa5f693..5d184a7d 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -3404,7 +3404,7 @@ describe('TeamDataService', () => { expect(persistedConfig.projectPath).toBe(fixture.staleProjectPath); }); - it('uses resolver-discovered session ids when config has no leadSessionId or sessionHistory', async () => { + it('does not guess lead_session messages from resolver-discovered session ids when config has no leadSessionId or sessionHistory', async () => { const fixture = await createResolverBackedLeadFixture({ leadSessionId: undefined, sessionFileId: 'lead-discovered', @@ -3413,13 +3413,48 @@ describe('TeamDataService', () => { const page = await service.getMessagesPage(fixture.teamName, { limit: 10 }); + expect(page.messages.some((message) => message.source === 'lead_session')).toBe(false); + }); + + it('does not mix resolver-discovered non-lead session ids into durable lead_session messages when config already knows the lead session', async () => { + const fixture = await createResolverBackedLeadFixture(); + await fs.writeFile( + path.join(fixture.actualProjectDir, 'member-1.jsonl'), + `${JSON.stringify({ + teamName: fixture.teamName, + type: 'assistant', + timestamp: '2026-04-18T10:05:00.000Z', + cwd: fixture.actualProjectPath, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Member bootstrap noise that should never appear as a lead_session thought in the team activity timeline.', + }, + ], + }, + })}\n`, + 'utf8' + ); + const service = createResolverBackedService(); + + const page = await service.getMessagesPage(fixture.teamName, { limit: 20 }); + const leadSessionMessages = page.messages.filter((message) => message.source === 'lead_session'); + expect( - page.messages.find( - (message) => - message.source === 'lead_session' && - message.text.includes('recovered through the transcript resolver') + leadSessionMessages.some((message) => + message.text.includes('recovered through the transcript resolver') ) - ).toBeTruthy(); + ).toBe(true); + expect( + leadSessionMessages.some((message) => + message.text.includes('Member bootstrap noise that should never appear') + ) + ).toBe(false); + expect(new Set(leadSessionMessages.map((message) => message.leadSessionId))).toEqual( + new Set(['lead-1']) + ); }); it('fails fast when config is missing before any read-phase step starts', async () => { diff --git a/test/renderer/components/team/activity/ActivityTimeline.test.ts b/test/renderer/components/team/activity/ActivityTimeline.test.ts new file mode 100644 index 00000000..fac0ca87 --- /dev/null +++ b/test/renderer/components/team/activity/ActivityTimeline.test.ts @@ -0,0 +1,156 @@ +import React from 'react'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { InboxMessage } from '@shared/types'; + +vi.mock('@renderer/components/team/activity/ActivityItem', () => ({ + ActivityItem: ({ message }: { message: InboxMessage }) => + React.createElement('div', { 'data-testid': 'activity-item' }, message.text), + isNoiseMessage: () => false, +})); + +vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({ + ENTRY_REVEAL_ANIMATION_MS: 220, + AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({ + useNewItemKeys: () => new Set(), +})); + +import { ActivityTimeline } from '@renderer/components/team/activity/ActivityTimeline'; + +function makeMessage(overrides: Partial = {}): InboxMessage { + return { + from: 'team-lead', + text: 'message', + timestamp: '2026-04-18T13:00:00.000Z', + read: true, + source: 'inbox', + messageId: 'message-id', + leadSessionId: 'lead-session-1', + ...overrides, + }; +} + +describe('ActivityTimeline session separators', () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('does not render New session for regular message rows even when their session ids differ', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'member-newest', + text: 'member newest', + leadSessionId: 'member-session-2', + from: 'alice', + source: 'inbox', + }), + makeMessage({ + messageId: 'member-older', + text: 'member older', + leadSessionId: 'member-session-1', + from: 'alice', + source: 'inbox', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).not.toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); + + it('renders New session between lead thought groups from different sessions', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-newest', + text: 'lead thought newest', + leadSessionId: 'lead-session-2', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'regular-between', + text: 'regular message between sessions', + leadSessionId: 'member-session-1', + from: 'alice', + source: 'inbox', + }), + makeMessage({ + messageId: 'thought-older', + text: 'lead thought older', + leadSessionId: 'lead-session-1', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); + + it('still renders New session when the newest thought belongs to currentLeadSessionId', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-current', + text: 'current lead thought', + leadSessionId: 'lead-session-current', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-history', + text: 'historical lead thought', + leadSessionId: 'lead-session-history', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render( + React.createElement(ActivityTimeline, { + messages, + teamName: 'demo-team', + currentLeadSessionId: 'lead-session-current', + }) + ); + }); + + expect(container.textContent).toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index c75c4b35..9ccf72c3 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -184,7 +184,6 @@ describe('MessagesPanel idle summary invariants', () => { tasks: [], messages, timeWindow: null, - teamSessionIds: new Set(), pendingRepliesByMember: {}, onPendingReplyChange: vi.fn(), }) @@ -235,7 +234,6 @@ describe('MessagesPanel idle summary invariants', () => { tasks: [], messages, timeWindow: null, - teamSessionIds: new Set(), pendingRepliesByMember: { alice: pendingSentAtMs }, onPendingReplyChange, }) @@ -280,7 +278,6 @@ describe('MessagesPanel idle summary invariants', () => { tasks: [], messages, timeWindow: null, - teamSessionIds: new Set(), pendingRepliesByMember: { alice: pendingSentAtMs }, onPendingReplyChange, }) @@ -319,7 +316,6 @@ describe('MessagesPanel idle summary invariants', () => { tasks: [], messages: [makeMessage()], timeWindow: null, - teamSessionIds: new Set(), pendingRepliesByMember: {}, onPendingReplyChange: vi.fn(), })