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 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) {
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<string>();
const merged: typeof data.messages = [];
const seen = new Set<string>();
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;

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));
let metaMembers: TeamConfig['members'] = [];

View file

@ -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
</Badge>
)}
{afterBadge}
{headerExtra}
</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
: undefined
}
headerExtra={
<>
afterBadge={
messagesUnreadCount > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
<button
type="button"
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"
onClick={(e) => {
e.stopPropagation();
void window.electronAPI.openExternal(
'https://github.com/777genius/claude-notifications-go'
);
handleMarkAllRead();
}}
>
<Bell size={12} />
</Button>
<CheckCheck size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
<TooltipContent side="bottom">Mark all as read</TooltipContent>
</Tooltip>
{messagesUnreadCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
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"
onClick={(e) => {
e.stopPropagation();
handleMarkAllRead();
}}
>
<CheckCheck size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Mark all as read</TooltipContent>
</Tooltip>
)}
</>
) : undefined
}
headerExtra={
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
void window.electronAPI.openExternal(
'https://github.com/777genius/claude-notifications-go'
);
}}
>
<Bell size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
</Tooltip>
}
defaultOpen
action={

View file

@ -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<string, string>();
const memberInfo = new Map<string, { role?: string; color?: string }>();
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 => {

View file

@ -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 (
<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
className="group rounded-md [overflow:clip]"
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
flushPending();
attachmentsRef.current = [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset on key transition
setAttachments([]);
return;
}
@ -120,7 +119,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
flushPending();
// Clear stale attachments from previous persistenceKey before loading
attachmentsRef.current = [];
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset before async load
setAttachments([]);
void (async () => {
const raw = await draftStorage.loadDraft(persistenceKey);