refactor: improve message deduplication and UI components for better user experience

- Updated handleGetData to collect text fingerprints from all message sources for deduplication of lead_process messages.
- Removed outdated leadSessionId assignment logic in TeamDataService to streamline message processing.
- Enhanced CollapsibleTeamSection and TeamDetailView to support an afterBadge prop for additional UI elements.
- Refactored ActivityTimeline to optimize pagination by excluding lead thoughts from visible message counts.
- Improved LeadThoughtsGroup to ensure single thoughts are rendered as distinct groups, enhancing clarity in the timeline.
This commit is contained in:
iliya 2026-03-05 22:36:14 +02:00
parent 17775274a0
commit a846c3949a
7 changed files with 92 additions and 89 deletions

View file

@ -402,11 +402,13 @@ async function handleGetData(
} }
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const leadSessionTextFingerprints = new Set<string>();
// 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<string>();
for (const msg of data.messages) { 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; 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: { const keyFor = (m: {
@ -421,14 +423,24 @@ async function handleGetData(
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; 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<string>();
const merged: typeof data.messages = []; const merged: typeof data.messages = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const msg of [...data.messages, ...live]) { for (const msg of [...data.messages, ...live]) {
if ((msg as { source?: unknown }).source === 'lead_process') { if ((msg as { source?: unknown }).source === 'lead_process') {
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`; 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; continue;
} }
// Dedup lead_process messages with same text but different messageIds
if (leadProcessTextFingerprints.has(fp)) {
continue;
}
leadProcessTextFingerprints.add(fp);
} }
const key = keyFor(msg); const key = keyFor(msg);
if (seen.has(key)) continue; if (seen.has(key)) continue;

View file

@ -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)); messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
let metaMembers: TeamConfig['members'] = []; let metaMembers: TeamConfig['members'] = [];

View file

@ -19,6 +19,8 @@ interface CollapsibleTeamSectionProps {
badge?: string | number; badge?: string | number;
/** Secondary badge (e.g. unread count). Shown next to main badge when defined. */ /** Secondary badge (e.g. unread count). Shown next to main badge when defined. */
secondaryBadge?: number; 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). */ /** Extra element rendered inline after badges (e.g. notification icon). */
headerExtra?: React.ReactNode; headerExtra?: React.ReactNode;
defaultOpen?: boolean; defaultOpen?: boolean;
@ -40,6 +42,7 @@ export const CollapsibleTeamSection = ({
icon, icon,
badge, badge,
secondaryBadge, secondaryBadge,
afterBadge,
headerExtra, headerExtra,
defaultOpen = true, defaultOpen = true,
forceOpen, forceOpen,
@ -109,6 +112,7 @@ export const CollapsibleTeamSection = ({
{secondaryBadge} new {secondaryBadge} new
</Badge> </Badge>
)} )}
{afterBadge}
{headerExtra} {headerExtra}
</div> </div>
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>} {action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}

View file

@ -1415,44 +1415,44 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
? messagesUnreadCount ? messagesUnreadCount
: undefined : undefined
} }
headerExtra={ afterBadge={
<> messagesUnreadCount > 0 ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <button
variant="ghost" type="button"
size="sm" className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void window.electronAPI.openExternal( handleMarkAllRead();
'https://github.com/777genius/claude-notifications-go'
);
}} }}
> >
<Bell size={12} /> <CheckCheck size={12} />
</Button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent> <TooltipContent side="bottom">Mark all as read</TooltipContent>
</Tooltip> </Tooltip>
{messagesUnreadCount > 0 && ( ) : undefined
<Tooltip> }
<TooltipTrigger asChild> headerExtra={
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10" <Button
onClick={(e) => { variant="ghost"
e.stopPropagation(); size="sm"
handleMarkAllRead(); className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
}} onClick={(e) => {
> e.stopPropagation();
<CheckCheck size={12} /> void window.electronAPI.openExternal(
</button> 'https://github.com/777genius/claude-notifications-go'
</TooltipTrigger> );
<TooltipContent side="bottom">Mark all as read</TooltipContent> }}
</Tooltip> >
)} <Bell size={12} />
</> </Button>
</TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
</Tooltip>
} }
defaultOpen defaultOpen
action={ action={

View file

@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ActivityItem, isNoiseMessage } from './ActivityItem'; 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 { InboxMessage, ResolvedTeamMember } from '@shared/types';
import type { TimelineItem } from './LeadThoughtsGroup'; import type { TimelineItem } from './LeadThoughtsGroup';
@ -134,11 +134,6 @@ export const ActivityTimeline = ({
const isInitializedRef = useRef(false); const isInitializedRef = useRef(false);
const prevVisibleCountRef = useRef(visibleCount); 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<string, string>(); const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
const memberInfo = new Map<string, { role?: string; color?: string }>(); const memberInfo = new Map<string, { role?: string; color?: string }>();
if (members) { if (members) {
@ -169,22 +164,33 @@ export const ActivityTimeline = ({
if (member) onMemberClick?.(member); 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. let significantSeen = 0;
// Reading/updating ref during render is intentional (React docs: derived state sync). let cutoff = total;
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */ for (let i = 0; i < total; i++) {
if (!isLeadThought(messages[i])) {
significantSeen++;
if (significantSeen > visibleCount) {
cutoff = i;
break;
}
}
}
const wasShowingAll = wasShowingAllRef.current; const significantTotal =
if (wasShowingAll && hiddenCount > 0) { significantSeen +
setVisibleCount(messages.length); (cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0);
} const hidden = Math.max(0, significantTotal - visibleCount);
wasShowingAllRef.current = hiddenCount === 0; return {
visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages,
const visibleMessages = useMemo( hiddenCount: hidden,
() => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages), };
[messages, visibleCount, hiddenCount] }, [messages, visibleCount]);
);
// Group consecutive lead thoughts into collapsible blocks. // Group consecutive lead thoughts into collapsible blocks.
const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]); const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]);
@ -209,6 +215,7 @@ export const ActivityTimeline = ({
}, [timelineItems]); }, [timelineItems]);
// Determine which items are "new" (should animate). // Determine which items are "new" (should animate).
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
const newItemKeys = useMemo(() => { const newItemKeys = useMemo(() => {
const getItemKey = (item: TimelineItem): string => { const getItemKey = (item: TimelineItem): string => {

View file

@ -22,7 +22,7 @@ export interface LeadThoughtGroup {
*/ */
export function isLeadThought(msg: InboxMessage): boolean { export function isLeadThought(msg: InboxMessage): boolean {
if (msg.source === 'lead_session') return true; 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; return false;
} }
@ -31,8 +31,8 @@ export type TimelineItem =
| { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] }; | { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] };
/** /**
* Group consecutive lead thoughts into collapsible blocks. * Group consecutive lead thoughts into compact blocks.
* Single thoughts remain as regular messages. * Even a single thought gets its own group (rendered as LeadThoughtsGroupRow).
*/ */
export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
const result: TimelineItem[] = []; const result: TimelineItem[] = [];
@ -41,19 +41,11 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
const flushThoughts = (): void => { const flushThoughts = (): void => {
if (pendingThoughts.length === 0) return; if (pendingThoughts.length === 0) return;
if (pendingThoughts.length === 1) { result.push({
result.push({ type: 'lead-thoughts',
type: 'message', group: { type: 'lead-thoughts', thoughts: pendingThoughts },
message: pendingThoughts[0], originalIndices: pendingIndices,
originalIndex: pendingIndices[0], });
});
} else {
result.push({
type: 'lead-thoughts',
group: { type: 'lead-thoughts', thoughts: pendingThoughts },
originalIndices: pendingIndices,
});
}
pendingThoughts = []; pendingThoughts = [];
pendingIndices = []; pendingIndices = [];
}; };
@ -121,11 +113,8 @@ export const LeadThoughtsGroupRow = ({
// Chronological order for rendering (oldest at top, newest at bottom) // Chronological order for rendering (oldest at top, newest at bottom)
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]); const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
// Live indicator: newest thought is from lead_process and recent // Live indicator: newest thought is recent (actively streaming)
const computeIsLive = useCallback( const computeIsLive = useCallback(() => isRecentTimestamp(newest.timestamp), [newest.timestamp]);
() => newest.source === 'lead_process' && isRecentTimestamp(newest.timestamp),
[newest.source, newest.timestamp]
);
const [isLive, setIsLive] = useState(computeIsLive); const [isLive, setIsLive] = useState(computeIsLive);
useEffect(() => { useEffect(() => {
@ -173,7 +162,11 @@ export const LeadThoughtsGroupRow = ({
}, []); }, []);
return ( return (
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}> <div
ref={ref}
className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}
style={{ overflowAnchor: 'none' }}
>
<article <article
className="group rounded-md [overflow:clip]" className="group rounded-md [overflow:clip]"
style={{ style={{

View file

@ -110,7 +110,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
// Transitioning to non-persistent context: flush pending save and clear stale state // Transitioning to non-persistent context: flush pending save and clear stale state
flushPending(); flushPending();
attachmentsRef.current = []; attachmentsRef.current = [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset on key transition
setAttachments([]); setAttachments([]);
return; return;
} }
@ -120,7 +119,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
flushPending(); flushPending();
// Clear stale attachments from previous persistenceKey before loading // Clear stale attachments from previous persistenceKey before loading
attachmentsRef.current = []; attachmentsRef.current = [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset before async load
setAttachments([]); setAttachments([]);
void (async () => { void (async () => {
const raw = await draftStorage.loadDraft(persistenceKey); const raw = await draftStorage.loadDraft(persistenceKey);