diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index e707825b..ad1e49d2 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -17,6 +17,7 @@ import { import { getMemberColorByName } from '@shared/constants/memberColors'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; +import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -94,6 +95,7 @@ const LEAD_SESSION_PARSE_CACHE_SCHEMA_VERSION = 'combined-v1'; const PROCESS_HEALTH_INTERVAL_MS = 2_000; const TASK_MAP_YIELD_EVERY = 250; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; +const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; interface EligibleTaskCommentNotification { key: string; @@ -119,6 +121,31 @@ interface FileWatchReconcileDiagnostics { lastPressureLogAt: number; } +function normalizePassiveUserReplyLinkText(value: string | undefined): string { + if (typeof value !== 'string') return ''; + return value + .trim() + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/[.!?…]+$/g, '') + .trim(); +} + +function extractPassiveUserPeerSummaryBody(text: string): string | null { + const classified = classifyIdleNotificationText(text); + if (!classified || classified.primaryKind !== 'heartbeat' || !classified.peerSummary) { + return null; + } + + const match = classified.peerSummary.match(/^\[to\s+user\]\s*(.*)$/i); + if (!match) { + return null; + } + + const body = match[1]?.trim() ?? ''; + return body.length > 0 ? body : null; +} + interface FileWatchReconcileTrigger { source: 'inbox' | 'task'; detail?: string; @@ -322,6 +349,88 @@ export class TeamDataService { } } + private linkPassiveUserReplySummaries(messages: InboxMessage[]): InboxMessage[] { + const canonicalReplies = messages + .map((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!messageId || message.to !== 'user') { + return null; + } + if (classifyIdleNotificationText(message.text)) { + return null; + } + + const time = Date.parse(message.timestamp); + if (!Number.isFinite(time)) { + return null; + } + + return { + messageId, + from: message.from, + time, + normalizedSummary: normalizePassiveUserReplyLinkText(message.summary), + normalizedText: normalizePassiveUserReplyLinkText(message.text), + }; + }) + .filter((value): value is NonNullable => value !== null); + + if (canonicalReplies.length === 0) { + return messages; + } + + let didLink = false; + const linkedMessages = messages.map((message) => { + if ( + typeof message.relayOfMessageId === 'string' && + message.relayOfMessageId.trim().length > 0 + ) { + return message; + } + + const body = extractPassiveUserPeerSummaryBody(message.text); + if (!body) { + return message; + } + + const passiveTime = Date.parse(message.timestamp); + if (!Number.isFinite(passiveTime)) { + return message; + } + + const normalizedBody = normalizePassiveUserReplyLinkText(body); + if (!normalizedBody) { + return message; + } + + const matches = canonicalReplies.filter((candidate) => { + if (candidate.from !== message.from) { + return false; + } + const deltaMs = passiveTime - candidate.time; + if (deltaMs < 0 || deltaMs > PASSIVE_USER_REPLY_LINK_WINDOW_MS) { + return false; + } + if (candidate.normalizedSummary === normalizedBody) { + return true; + } + return normalizedBody.length >= 6 && candidate.normalizedText.includes(normalizedBody); + }); + + if (matches.length !== 1) { + return message; + } + + didLink = true; + return { + ...message, + relayOfMessageId: matches[0].messageId, + }; + }); + + return didLink ? linkedMessages : messages; + } + async getTaskChangePresence(teamName: string): Promise> { const config = await this.configReader.getConfig(teamName); if (!config) { @@ -802,6 +911,9 @@ export class TeamDataService { } mark('dedupMessageIds'); + messages = this.linkPassiveUserReplySummaries(messages); + mark('linkPassiveUserReplySummaries'); + // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's // session ID (by timestamp). This avoids the old forward-only propagation bug. if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 1d6948a0..7b24ded6 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -108,6 +108,18 @@ function getCommandOutputSummary(text: string): string { return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; } +function parseIdlePeerSummaryRoute(summary: string): { recipient: string | null; body: string } { + const trimmed = summary.trim(); + const match = trimmed.match(/^\[to\s+([^\]]+)\]\s*(.*)$/i); + if (!match) { + return { recipient: null, body: trimmed }; + } + + const recipient = match[1]?.trim() || null; + const body = match[2]?.trim() || trimmed; + return { recipient, body }; +} + export function isQualifiedExternalRecipient( value: string | undefined, teamName: string, @@ -305,6 +317,59 @@ const NoiseRow = ({ ); +const PassiveIdlePeerSummaryRow = ({ + teamName, + senderName, + senderColor, + summary, + timestamp, + onMemberNameClick, +}: { + teamName: string; + senderName: string; + senderColor?: string; + summary: string; + timestamp: string; + onMemberNameClick?: (memberName: string) => void; +}): React.JSX.Element => { + const { recipient, body } = parseIdlePeerSummaryRoute(summary); + + return ( +
+ + update + + + {recipient ? ( + <> + + + {recipient} + + + ) : null} + + {body} + + + {timestamp} + +
+ ); +}; + const BootstrapSystemRow = ({ teamName, senderName, @@ -751,6 +816,19 @@ export const ActivityItem = memo( ); } + if (idleSemantic?.uiPresentation === 'peer_summary' && idleSemantic.peerSummary) { + return ( + + ); + } + if (bootstrapDisplay) { return ( { }); }); + function createPassiveUserSummaryLinkService(options: { + inboxMessages?: InboxMessage[]; + sentMessages?: InboxMessage[]; + }): TeamDataService { + const { inboxMessages = [], sentMessages = [] } = options; + return new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [{ name: 'team-lead', role: 'Lead' }], + leadSessionId: 'lead-1', + })), + } as never, + { + getTasks: vi.fn(async () => []), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => inboxMessages), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never, + {} as never, + {} as never, + { + readMessages: vi.fn(async () => sentMessages), + } as never + ); + } + + it('links passive [to user] acknowledgement summaries to the canonical user reply transiently', async () => { + const passiveSummaryRow: InboxMessage = { + from: 'alice', + text: JSON.stringify({ + type: 'idle_notification', + idleReason: 'available', + summary: '[to user] acknowledgement', + }), + timestamp: '2026-04-08T10:00:05.000Z', + read: true, + messageId: 'passive-user-summary-1', + }; + const userReplyRow: InboxMessage = { + from: 'alice', + to: 'user', + text: 'Да, я здесь. Готова к работе и жду задач для ревью.', + timestamp: '2026-04-08T10:00:00.000Z', + read: true, + summary: 'acknowledgement', + messageId: 'user-reply-1', + source: 'user_sent', + }; + const service = createPassiveUserSummaryLinkService({ + inboxMessages: [passiveSummaryRow], + sentMessages: [userReplyRow], + }); + + const data = await service.getTeamData('my-team'); + const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1'); + + expect(linked?.relayOfMessageId).toBe('user-reply-1'); + expect(passiveSummaryRow.relayOfMessageId).toBeUndefined(); + }); + + it('links passive [to user] summaries when the summary body is contained in the user reply text', async () => { + const service = createPassiveUserSummaryLinkService({ + inboxMessages: [ + { + from: 'alice', + text: JSON.stringify({ + type: 'idle_notification', + idleReason: 'available', + summary: '[to user] Я здесь.', + }), + timestamp: '2026-04-08T10:00:05.000Z', + read: true, + messageId: 'passive-user-summary-contains-1', + }, + ], + sentMessages: [ + { + from: 'alice', + to: 'user', + text: 'Да, я здесь. Готова к работе и жду задач для ревью.', + timestamp: '2026-04-08T10:00:00.000Z', + read: true, + summary: 'presence ack', + messageId: 'user-reply-contains-1', + source: 'user_sent', + }, + ], + }); + + const data = await service.getTeamData('my-team'); + const linked = data.messages.find( + (message) => message.messageId === 'passive-user-summary-contains-1' + ); + + expect(linked?.relayOfMessageId).toBe('user-reply-contains-1'); + }); + + it('does not link passive [to user] summaries outside the 15s correlation window', async () => { + const service = createPassiveUserSummaryLinkService({ + inboxMessages: [ + { + from: 'alice', + text: JSON.stringify({ + type: 'idle_notification', + idleReason: 'available', + summary: '[to user] acknowledgement', + }), + timestamp: '2026-04-08T10:00:16.000Z', + read: true, + messageId: 'passive-user-summary-old-1', + }, + ], + sentMessages: [ + { + from: 'alice', + to: 'user', + text: 'Да, я здесь. Готова к работе и жду задач для ревью.', + timestamp: '2026-04-08T10:00:00.000Z', + read: true, + summary: 'acknowledgement', + messageId: 'user-reply-old-1', + source: 'user_sent', + }, + ], + }); + + const data = await service.getTeamData('my-team'); + const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1'); + + expect(linked?.relayOfMessageId).toBeUndefined(); + }); + + it('does not link passive peer summaries for recipients other than user', async () => { + const service = createPassiveUserSummaryLinkService({ + inboxMessages: [ + { + from: 'alice', + text: JSON.stringify({ + type: 'idle_notification', + idleReason: 'available', + summary: '[to bob] aligned on rollout order', + }), + timestamp: '2026-04-08T10:00:05.000Z', + read: true, + messageId: 'passive-bob-summary-1', + }, + ], + sentMessages: [ + { + from: 'alice', + to: 'user', + text: 'aligned on rollout order', + timestamp: '2026-04-08T10:00:00.000Z', + read: true, + summary: 'aligned on rollout order', + messageId: 'user-reply-bob-summary-1', + source: 'user_sent', + }, + ], + }); + + const data = await service.getTeamData('my-team'); + const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1'); + + expect(linked?.relayOfMessageId).toBeUndefined(); + }); + + it('does not link passive [to user] summaries when the sender differs', async () => { + const service = createPassiveUserSummaryLinkService({ + inboxMessages: [ + { + from: 'alice', + text: JSON.stringify({ + type: 'idle_notification', + idleReason: 'available', + summary: '[to user] acknowledgement', + }), + timestamp: '2026-04-08T10:00:05.000Z', + read: true, + messageId: 'passive-user-summary-sender-1', + }, + ], + sentMessages: [ + { + from: 'bob', + to: 'user', + text: 'Да, я здесь.', + timestamp: '2026-04-08T10:00:00.000Z', + read: true, + summary: 'acknowledgement', + messageId: 'user-reply-sender-1', + source: 'user_sent', + }, + ], + }); + + const data = await service.getTeamData('my-team'); + const linked = data.messages.find( + (message) => message.messageId === 'passive-user-summary-sender-1' + ); + + expect(linked?.relayOfMessageId).toBeUndefined(); + }); + + it('does not link passive [to user] summaries when multiple plausible user replies exist', async () => { + const service = createPassiveUserSummaryLinkService({ + inboxMessages: [ + { + from: 'alice', + text: JSON.stringify({ + type: 'idle_notification', + idleReason: 'available', + summary: '[to user] acknowledgement', + }), + timestamp: '2026-04-08T10:00:05.000Z', + read: true, + messageId: 'passive-user-summary-ambiguous-1', + }, + ], + sentMessages: [ + { + from: 'alice', + to: 'user', + text: 'Да, я здесь.', + timestamp: '2026-04-08T10:00:00.000Z', + read: true, + summary: 'acknowledgement', + messageId: 'user-reply-ambiguous-1', + source: 'user_sent', + }, + { + from: 'alice', + to: 'user', + text: 'Да, на месте.', + timestamp: '2026-04-08T10:00:01.000Z', + read: true, + summary: 'acknowledgement', + messageId: 'user-reply-ambiguous-2', + source: 'user_sent', + }, + ], + }); + + const data = await service.getTeamData('my-team'); + const linked = data.messages.find( + (message) => message.messageId === 'passive-user-summary-ambiguous-1' + ); + + expect(linked?.relayOfMessageId).toBeUndefined(); + }); + it('caches unchanged lead-session extraction results and returns defensive clones', async () => { const service = createLeadSessionCachingService(); const jsonlPath = await createTempJsonl([ diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index e19f909e..e88a274a 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -193,8 +193,52 @@ describe('ActivityItem legacy system message fallback', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('[to bob] aligned on rollout order'); + expect(host.textContent).toContain('update'); + expect(host.textContent).toContain('alice'); + expect(host.textContent).toContain('bob'); + expect(host.textContent).toContain('aligned on rollout order'); + expect(host.textContent).not.toContain('[to bob]'); + expect(host.textContent).not.toContain('idle'); expect(host.textContent).not.toContain('Idle (available)'); + expect(host.textContent).not.toContain('Raw JSON'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('renders user-directed peer-summary rows as passive updates instead of pseudo messages', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const message: InboxMessage = { + from: 'alice', + text: JSON.stringify({ + type: 'idle_notification', + from: 'alice', + timestamp: '2026-04-08T12:02:00.000Z', + idleReason: 'available', + summary: '[to user] Я здесь.', + }), + timestamp: new Date('2026-04-08T12:02:00.000Z').toISOString(), + read: true, + source: 'inbox', + }; + + await act(async () => { + root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('update'); + expect(host.textContent).toContain('alice'); + expect(host.textContent).toContain('user'); + expect(host.textContent).toContain('Я здесь.'); + expect(host.textContent).not.toContain('[to user]'); + expect(host.textContent).not.toContain('idle'); await act(async () => { root.unmount();