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:
parent
17775274a0
commit
a846c3949a
7 changed files with 92 additions and 89 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'] = [];
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue