fix(activity): make new session boundaries exact

This commit is contained in:
777genius 2026-04-18 16:58:44 +03:00
parent 0a1831bc5e
commit cb603aaf37
6 changed files with 230 additions and 54 deletions

View file

@ -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 [];
}

View file

@ -48,12 +48,6 @@ interface ActivityTimelineProps {
expandOverrides?: Set<string>;
/** 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<string>;
/** 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 = (
<div
className="flex items-center gap-3"
style={{ paddingTop: 45, paddingBottom: 45 }}
>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
New session
</span>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
</div>
);
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 = (
<div
className="flex items-center gap-3"
style={{ paddingTop: 45, paddingBottom: 45 }}
>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
New session
</span>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
</div>
);
}
}
if (item.type === 'lead-thoughts') {

View file

@ -80,8 +80,6 @@ interface MessagesPanelProps {
leadContextUpdatedAt?: string;
/** Time window for filtering. */
timeWindow: TimeWindow | null;
/** Team session IDs for timeline. */
teamSessionIds: Set<string>;
/** 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}

View file

@ -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 () => {

View file

@ -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<string>(),
}));
import { ActivityTimeline } from '@renderer/components/team/activity/ActivityTimeline';
function makeMessage(overrides: Partial<InboxMessage> = {}): 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();
});
});
});

View file

@ -184,7 +184,6 @@ describe('MessagesPanel idle summary invariants', () => {
tasks: [],
messages,
timeWindow: null,
teamSessionIds: new Set<string>(),
pendingRepliesByMember: {},
onPendingReplyChange: vi.fn(),
})
@ -235,7 +234,6 @@ describe('MessagesPanel idle summary invariants', () => {
tasks: [],
messages,
timeWindow: null,
teamSessionIds: new Set<string>(),
pendingRepliesByMember: { alice: pendingSentAtMs },
onPendingReplyChange,
})
@ -280,7 +278,6 @@ describe('MessagesPanel idle summary invariants', () => {
tasks: [],
messages,
timeWindow: null,
teamSessionIds: new Set<string>(),
pendingRepliesByMember: { alice: pendingSentAtMs },
onPendingReplyChange,
})
@ -319,7 +316,6 @@ describe('MessagesPanel idle summary invariants', () => {
tasks: [],
messages: [makeMessage()],
timeWindow: null,
teamSessionIds: new Set<string>(),
pendingRepliesByMember: {},
onPendingReplyChange: vi.fn(),
})