diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index edc4da79..188ffbc5 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -402,11 +402,13 @@ async function handleGetData( } const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const leadSessionTextFingerprints = new Set(); + + // Collect text fingerprints from ALL non-live messages (inbox, lead_session, sentMessages) + // so we can dedup lead_process live messages against them. + const existingTextFingerprints = new Set(); for (const msg of data.messages) { - if ((msg as { source?: unknown }).source !== 'lead_session') continue; if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; - leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); + existingTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); } const keyFor = (m: { @@ -421,14 +423,24 @@ async function handleGetData( return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; }; + // Text-based fingerprints for lead_process messages to catch duplicates + // with different messageIds (e.g. lead-turn-* vs lead-sendmsg-* with same text) + const leadProcessTextFingerprints = new Set(); + const merged: typeof data.messages = []; const seen = new Set(); for (const msg of [...data.messages, ...live]) { if ((msg as { source?: unknown }).source === 'lead_process') { const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`; - if (leadSessionTextFingerprints.has(fp)) { + // Skip if same text already exists from any source (inbox, lead_session, etc.) + if (existingTextFingerprints.has(fp)) { continue; } + // Dedup lead_process messages with same text but different messageIds + if (leadProcessTextFingerprints.has(fp)) { + continue; + } + leadProcessTextFingerprints.add(fp); } const key = keyFor(msg); if (seen.has(key)) continue; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ec3c8a43..83e3f8ca 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -297,17 +297,6 @@ export class TeamDataService { }); } - // Enrich messages without leadSessionId: assign current session for lead_process/user_sent. - // lead_process messages surviving dedup are from the current session; - // user_sent messages written before this feature lack the field. - if (config.leadSessionId) { - for (const msg of messages) { - if (!msg.leadSessionId && (msg.source === 'lead_process' || msg.source === 'user_sent')) { - msg.leadSessionId = config.leadSessionId; - } - } - } - messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); let metaMembers: TeamConfig['members'] = []; diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 583ba823..5b513f4d 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -19,6 +19,8 @@ interface CollapsibleTeamSectionProps { badge?: string | number; /** Secondary badge (e.g. unread count). Shown next to main badge when defined. */ secondaryBadge?: number; + /** Element rendered immediately after secondary badge (e.g. mark-all-read button). */ + afterBadge?: React.ReactNode; /** Extra element rendered inline after badges (e.g. notification icon). */ headerExtra?: React.ReactNode; defaultOpen?: boolean; @@ -40,6 +42,7 @@ export const CollapsibleTeamSection = ({ icon, badge, secondaryBadge, + afterBadge, headerExtra, defaultOpen = true, forceOpen, @@ -109,6 +112,7 @@ export const CollapsibleTeamSection = ({ {secondaryBadge} new )} + {afterBadge} {headerExtra} {action &&
{action}
} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 7b6cb95d..b64f145a 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1415,44 +1415,44 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele ? messagesUnreadCount : undefined } - headerExtra={ - <> + afterBadge={ + messagesUnreadCount > 0 ? ( - + + - Desktop notifications plugin + Mark all as read - {messagesUnreadCount > 0 && ( - - - - - Mark all as read - - )} - + ) : undefined + } + headerExtra={ + + + + + Desktop notifications plugin + } defaultOpen action={ diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index cb8a7331..63e0864f 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; -import { groupTimelineItems, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; +import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; import type { TimelineItem } from './LeadThoughtsGroup'; @@ -134,11 +134,6 @@ export const ActivityTimeline = ({ const isInitializedRef = useRef(false); const prevVisibleCountRef = useRef(visibleCount); - // Track whether the user was seeing ALL messages (no hidden ones). - // If so, auto-expand when new messages push count past the limit, - // so previously visible messages don't silently disappear. - const wasShowingAllRef = useRef(messages.length <= MESSAGES_PAGE_SIZE); - const colorMap = members ? buildMemberColorMap(members) : new Map(); const memberInfo = new Map(); if (members) { @@ -169,22 +164,33 @@ export const ActivityTimeline = ({ if (member) onMemberClick?.(member); }; - const hiddenCount = Math.max(0, messages.length - visibleCount); + // Pagination counts only significant (non-thought) messages so that lead thoughts + // don't consume the page limit — they collapse into a single visual group anyway. + const { visibleMessages, hiddenCount } = useMemo(() => { + const total = messages.length; + if (total === 0) return { visibleMessages: messages, hiddenCount: 0 }; - // Auto-expand when user was seeing all and new messages arrive — derived state sync. - // Reading/updating ref during render is intentional (React docs: derived state sync). - /* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */ + let significantSeen = 0; + let cutoff = total; + for (let i = 0; i < total; i++) { + if (!isLeadThought(messages[i])) { + significantSeen++; + if (significantSeen > visibleCount) { + cutoff = i; + break; + } + } + } - const wasShowingAll = wasShowingAllRef.current; - if (wasShowingAll && hiddenCount > 0) { - setVisibleCount(messages.length); - } - wasShowingAllRef.current = hiddenCount === 0; - - const visibleMessages = useMemo( - () => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages), - [messages, visibleCount, hiddenCount] - ); + const significantTotal = + significantSeen + + (cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0); + const hidden = Math.max(0, significantTotal - visibleCount); + return { + visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages, + hiddenCount: hidden, + }; + }, [messages, visibleCount]); // Group consecutive lead thoughts into collapsible blocks. const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]); @@ -209,6 +215,7 @@ export const ActivityTimeline = ({ }, [timelineItems]); // Determine which items are "new" (should animate). + /* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */ const newItemKeys = useMemo(() => { const getItemKey = (item: TimelineItem): string => { diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 0d85a943..5228cb88 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -22,7 +22,7 @@ export interface LeadThoughtGroup { */ export function isLeadThought(msg: InboxMessage): boolean { if (msg.source === 'lead_session') return true; - if (msg.source === 'lead_process' && msg.messageId?.startsWith('lead-text-')) return true; + if (msg.source === 'lead_process') return true; return false; } @@ -31,8 +31,8 @@ export type TimelineItem = | { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] }; /** - * Group consecutive lead thoughts into collapsible blocks. - * Single thoughts remain as regular messages. + * Group consecutive lead thoughts into compact blocks. + * Even a single thought gets its own group (rendered as LeadThoughtsGroupRow). */ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { const result: TimelineItem[] = []; @@ -41,19 +41,11 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { const flushThoughts = (): void => { if (pendingThoughts.length === 0) return; - if (pendingThoughts.length === 1) { - result.push({ - type: 'message', - message: pendingThoughts[0], - originalIndex: pendingIndices[0], - }); - } else { - result.push({ - type: 'lead-thoughts', - group: { type: 'lead-thoughts', thoughts: pendingThoughts }, - originalIndices: pendingIndices, - }); - } + result.push({ + type: 'lead-thoughts', + group: { type: 'lead-thoughts', thoughts: pendingThoughts }, + originalIndices: pendingIndices, + }); pendingThoughts = []; pendingIndices = []; }; @@ -121,11 +113,8 @@ export const LeadThoughtsGroupRow = ({ // Chronological order for rendering (oldest at top, newest at bottom) const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]); - // Live indicator: newest thought is from lead_process and recent - const computeIsLive = useCallback( - () => newest.source === 'lead_process' && isRecentTimestamp(newest.timestamp), - [newest.source, newest.timestamp] - ); + // Live indicator: newest thought is recent (actively streaming) + const computeIsLive = useCallback(() => isRecentTimestamp(newest.timestamp), [newest.timestamp]); const [isLive, setIsLive] = useState(computeIsLive); useEffect(() => { @@ -173,7 +162,11 @@ export const LeadThoughtsGroupRow = ({ }, []); return ( -
+
{ const raw = await draftStorage.loadDraft(persistenceKey);