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 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;
|
||||||
|
|
|
||||||
|
|
@ -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'] = [];
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue