fix(activity): make new session boundaries exact
This commit is contained in:
parent
0a1831bc5e
commit
cb603aaf37
6 changed files with 230 additions and 54 deletions
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
156
test/renderer/components/team/activity/ActivityTimeline.test.ts
Normal file
156
test/renderer/components/team/activity/ActivityTimeline.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue