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 =
|
const leadName =
|
||||||
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||||
const sessionIds = Array.from(
|
const knownLeadSessionIds = this.getRecentLeadSessionIds(config);
|
||||||
new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds])
|
if (knownLeadSessionIds.length === 0) {
|
||||||
);
|
return [];
|
||||||
|
}
|
||||||
|
const sessionIds = knownLeadSessionIds;
|
||||||
if (sessionIds.length === 0) {
|
if (sessionIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,6 @@ interface ActivityTimelineProps {
|
||||||
expandOverrides?: Set<string>;
|
expandOverrides?: Set<string>;
|
||||||
/** Called when user toggles expand/collapse override on a specific message. */
|
/** Called when user toggles expand/collapse override on a specific message. */
|
||||||
onToggleExpandOverride?: (key: string) => void;
|
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. */
|
/** Current lead session ID for the active team, if known. */
|
||||||
currentLeadSessionId?: string;
|
currentLeadSessionId?: string;
|
||||||
/** Whether the current team is alive. */
|
/** Whether the current team is alive. */
|
||||||
|
|
@ -281,7 +275,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
allCollapsed,
|
allCollapsed,
|
||||||
expandOverrides,
|
expandOverrides,
|
||||||
onToggleExpandOverride,
|
onToggleExpandOverride,
|
||||||
teamSessionIds,
|
|
||||||
currentLeadSessionId,
|
currentLeadSessionId,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
|
@ -425,10 +418,13 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
setVisibleCount(Infinity);
|
setVisibleCount(Infinity);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getItemSessionId = (item: TimelineItem): string | undefined =>
|
const getItemSessionAnchorId = (item: TimelineItem): string | undefined => {
|
||||||
item.type === 'lead-thoughts'
|
if (item.type === 'lead-thoughts') {
|
||||||
? item.group.thoughts[0].leadSessionId
|
return item.group.thoughts[0]?.leadSessionId;
|
||||||
: item.message.leadSessionId;
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
// Pin the newest thought group (if first) so it stays at the top and doesn't jump.
|
// 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;
|
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
|
||||||
|
|
@ -535,18 +531,16 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
// Session boundary separator (messages sorted desc — new on top)
|
// Session boundary separator (messages sorted desc — new on top)
|
||||||
let sessionSeparator: React.JSX.Element | null = null;
|
let sessionSeparator: React.JSX.Element | null = null;
|
||||||
if (realIndex > 0) {
|
if (realIndex > 0) {
|
||||||
const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
|
const currSessionId = getItemSessionAnchorId(item);
|
||||||
const currSessionId = getItemSessionId(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) {
|
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 = (
|
sessionSeparator = (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3"
|
className="flex items-center gap-3"
|
||||||
|
|
@ -561,7 +555,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === 'lead-thoughts') {
|
if (item.type === 'lead-thoughts') {
|
||||||
const { group } = item;
|
const { group } = item;
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,6 @@ interface MessagesPanelProps {
|
||||||
leadContextUpdatedAt?: string;
|
leadContextUpdatedAt?: string;
|
||||||
/** Time window for filtering. */
|
/** Time window for filtering. */
|
||||||
timeWindow: TimeWindow | null;
|
timeWindow: TimeWindow | null;
|
||||||
/** Team session IDs for timeline. */
|
|
||||||
teamSessionIds: Set<string>;
|
|
||||||
/** Current lead session ID. */
|
/** Current lead session ID. */
|
||||||
currentLeadSessionId?: string;
|
currentLeadSessionId?: string;
|
||||||
/** Pending replies tracker (shared with parent for MemberList). */
|
/** Pending replies tracker (shared with parent for MemberList). */
|
||||||
|
|
@ -114,7 +112,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
leadActivity,
|
leadActivity,
|
||||||
leadContextUpdatedAt,
|
leadContextUpdatedAt,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
teamSessionIds,
|
|
||||||
currentLeadSessionId,
|
currentLeadSessionId,
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
onPendingReplyChange,
|
onPendingReplyChange,
|
||||||
|
|
@ -658,7 +655,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
allCollapsed={messagesCollapsed}
|
allCollapsed={messagesCollapsed}
|
||||||
expandOverrides={expandedSet}
|
expandOverrides={expandedSet}
|
||||||
onToggleExpandOverride={toggleExpandOverride}
|
onToggleExpandOverride={toggleExpandOverride}
|
||||||
teamSessionIds={teamSessionIds}
|
|
||||||
currentLeadSessionId={currentLeadSessionId}
|
currentLeadSessionId={currentLeadSessionId}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
|
|
@ -844,7 +840,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
allCollapsed={messagesCollapsed}
|
allCollapsed={messagesCollapsed}
|
||||||
expandOverrides={expandedSet}
|
expandOverrides={expandedSet}
|
||||||
onToggleExpandOverride={toggleExpandOverride}
|
onToggleExpandOverride={toggleExpandOverride}
|
||||||
teamSessionIds={teamSessionIds}
|
|
||||||
currentLeadSessionId={currentLeadSessionId}
|
currentLeadSessionId={currentLeadSessionId}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
|
|
@ -1131,7 +1126,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
allCollapsed={messagesCollapsed}
|
allCollapsed={messagesCollapsed}
|
||||||
expandOverrides={expandedSet}
|
expandOverrides={expandedSet}
|
||||||
onToggleExpandOverride={toggleExpandOverride}
|
onToggleExpandOverride={toggleExpandOverride}
|
||||||
teamSessionIds={teamSessionIds}
|
|
||||||
currentLeadSessionId={currentLeadSessionId}
|
currentLeadSessionId={currentLeadSessionId}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
|
|
|
||||||
|
|
@ -3404,7 +3404,7 @@ describe('TeamDataService', () => {
|
||||||
expect(persistedConfig.projectPath).toBe(fixture.staleProjectPath);
|
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({
|
const fixture = await createResolverBackedLeadFixture({
|
||||||
leadSessionId: undefined,
|
leadSessionId: undefined,
|
||||||
sessionFileId: 'lead-discovered',
|
sessionFileId: 'lead-discovered',
|
||||||
|
|
@ -3413,13 +3413,48 @@ describe('TeamDataService', () => {
|
||||||
|
|
||||||
const page = await service.getMessagesPage(fixture.teamName, { limit: 10 });
|
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(
|
expect(
|
||||||
page.messages.find(
|
leadSessionMessages.some((message) =>
|
||||||
(message) =>
|
|
||||||
message.source === 'lead_session' &&
|
|
||||||
message.text.includes('recovered through the transcript resolver')
|
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 () => {
|
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: [],
|
tasks: [],
|
||||||
messages,
|
messages,
|
||||||
timeWindow: null,
|
timeWindow: null,
|
||||||
teamSessionIds: new Set<string>(),
|
|
||||||
pendingRepliesByMember: {},
|
pendingRepliesByMember: {},
|
||||||
onPendingReplyChange: vi.fn(),
|
onPendingReplyChange: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -235,7 +234,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
tasks: [],
|
tasks: [],
|
||||||
messages,
|
messages,
|
||||||
timeWindow: null,
|
timeWindow: null,
|
||||||
teamSessionIds: new Set<string>(),
|
|
||||||
pendingRepliesByMember: { alice: pendingSentAtMs },
|
pendingRepliesByMember: { alice: pendingSentAtMs },
|
||||||
onPendingReplyChange,
|
onPendingReplyChange,
|
||||||
})
|
})
|
||||||
|
|
@ -280,7 +278,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
tasks: [],
|
tasks: [],
|
||||||
messages,
|
messages,
|
||||||
timeWindow: null,
|
timeWindow: null,
|
||||||
teamSessionIds: new Set<string>(),
|
|
||||||
pendingRepliesByMember: { alice: pendingSentAtMs },
|
pendingRepliesByMember: { alice: pendingSentAtMs },
|
||||||
onPendingReplyChange,
|
onPendingReplyChange,
|
||||||
})
|
})
|
||||||
|
|
@ -319,7 +316,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
tasks: [],
|
tasks: [],
|
||||||
messages: [makeMessage()],
|
messages: [makeMessage()],
|
||||||
timeWindow: null,
|
timeWindow: null,
|
||||||
teamSessionIds: new Set<string>(),
|
|
||||||
pendingRepliesByMember: {},
|
pendingRepliesByMember: {},
|
||||||
onPendingReplyChange: vi.fn(),
|
onPendingReplyChange: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue