feat: enhance TeamDataService and UI components for lead session management and message deduplication

- Refactored TeamDataService to improve lead session message handling, including deduplication logic to prevent double-rendering of messages.
- Added leadSessionId to InboxMessage type for better session tracking.
- Updated ActivityTimeline to visually separate messages by session, enhancing user experience.
- Improved LeadThoughtsGroupRow to support live indicators and auto-scroll functionality for new messages.
- Enhanced AddMemberDialog, LaunchTeamDialog, and MemberDraftRow with updated placeholder text for clarity.
This commit is contained in:
iliya 2026-03-05 21:52:55 +02:00
parent 1326c099fb
commit 65eb788097
8 changed files with 203 additions and 98 deletions

View file

@ -258,8 +258,9 @@ export class TeamDataService {
} }
mark('messages'); mark('messages');
let leadTexts: InboxMessage[] = [];
try { try {
const leadTexts = await this.extractLeadSessionTexts(config); leadTexts = await this.extractLeadSessionTexts(config);
if (leadTexts.length > 0) { if (leadTexts.length > 0) {
messages = [...messages, ...leadTexts]; messages = [...messages, ...leadTexts];
} }
@ -268,8 +269,9 @@ export class TeamDataService {
} }
mark('leadTexts'); mark('leadTexts');
let sentMessages: InboxMessage[] = [];
try { try {
const sentMessages = await this.sentMessagesStore.readMessages(teamName); sentMessages = await this.sentMessagesStore.readMessages(teamName);
if (sentMessages.length > 0) { if (sentMessages.length > 0) {
messages = [...messages, ...sentMessages]; messages = [...messages, ...sentMessages];
} }
@ -278,6 +280,33 @@ export class TeamDataService {
} }
mark('sentMessages'); mark('sentMessages');
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
if (leadTexts.length > 0 && sentMessages.length > 0) {
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const leadSessionFingerprints = new Set<string>();
for (const msg of leadTexts) {
if (msg.source !== 'lead_session') continue;
leadSessionFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
}
messages = messages.filter((m) => {
if (m.source !== 'lead_process') return true;
const fp = `${m.from}\0${normalizeText(m.text ?? '')}`;
return !leadSessionFingerprints.has(fp);
});
}
// 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'] = [];
@ -1102,6 +1131,15 @@ export class TeamDataService {
attachments?: AttachmentMeta[] attachments?: AttachmentMeta[]
): Promise<SendMessageResult> { ): Promise<SendMessageResult> {
const messageId = randomUUID(); const messageId = randomUUID();
let leadSessionId: string | undefined;
try {
const config = await this.configReader.getConfig(teamName);
leadSessionId = config?.leadSessionId;
} catch {
// non-critical — proceed without sessionId
}
const msg: InboxMessage = { const msg: InboxMessage = {
from: 'user', from: 'user',
to: leadName, to: leadName,
@ -1112,6 +1150,7 @@ export class TeamDataService {
messageId, messageId,
source: 'user_sent', source: 'user_sent',
attachments: attachments?.length ? attachments : undefined, attachments: attachments?.length ? attachments : undefined,
leadSessionId,
}; };
await this.sentMessagesStore.appendMessage(teamName, msg); await this.sentMessagesStore.appendMessage(teamName, msg);
return { deliveredToInbox: false, deliveredViaStdin: true, messageId }; return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
@ -1214,7 +1253,8 @@ export class TeamDataService {
name: (() => { name: (() => {
const name = member.name.trim(); const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty'); if (!name) throw new Error('Member name cannot be empty');
if (name.toLowerCase() === 'team-lead') throw new Error('Member name "team-lead" is reserved'); if (name.toLowerCase() === 'team-lead')
throw new Error('Member name "team-lead" is reserved');
const suffixInfo = parseNumericSuffixName(name); const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) { if (suffixInfo && suffixInfo.suffix >= 2) {
throw new Error( throw new Error(
@ -1374,6 +1414,7 @@ export class TeamDataService {
timestamp, timestamp,
read: true, read: true,
source: 'lead_session', source: 'lead_session',
leadSessionId: config.leadSessionId,
}); });
if (textsReversed.length >= MAX_LEAD_TEXTS) break; if (textsReversed.length >= MAX_LEAD_TEXTS) break;
} }

View file

@ -75,6 +75,7 @@ export class TeamSentMessagesStore {
color: typeof row.color === 'string' ? row.color : undefined, color: typeof row.color === 'string' ? row.color : undefined,
attachments: Array.isArray(row.attachments) ? row.attachments : undefined, attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
}); });
} }

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -213,7 +213,8 @@ export const ActivityTimeline = ({
const newItemKeys = useMemo(() => { const newItemKeys = useMemo(() => {
const getItemKey = (item: TimelineItem): string => { const getItemKey = (item: TimelineItem): string => {
if (item.type === 'lead-thoughts') { if (item.type === 'lead-thoughts') {
return `thoughts-${item.group.thoughts[0].messageId ?? item.originalIndices[0]}-${item.group.thoughts.length}`; // Stable key: identify group by its first thought, not by count (which changes)
return `thoughts-${item.group.thoughts[0].messageId ?? item.originalIndices[0]}`;
} }
const msg = item.message; const msg = item.message;
return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`; return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`;
@ -274,22 +275,47 @@ export const ActivityTimeline = ({
); );
} }
const getItemSessionId = (item: TimelineItem): string | undefined =>
item.type === 'lead-thoughts'
? item.group.thoughts[0].leadSessionId
: item.message.leadSessionId;
return ( return (
<div className="space-y-1"> <div className="space-y-1">
{timelineItems.map((item, index) => { {timelineItems.map((item, index) => {
// Session boundary separator (messages sorted desc — new on top)
let sessionSeparator: React.JSX.Element | null = null;
if (index > 0) {
const prevSessionId = getItemSessionId(timelineItems[index - 1]);
const currSessionId = getItemSessionId(item);
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
sessionSeparator = (
<div className="flex items-center gap-3 py-4">
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
<span className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
Новая сессия
</span>
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
</div>
);
}
}
if (item.type === 'lead-thoughts') { if (item.type === 'lead-thoughts') {
const { group } = item; const { group } = item;
const firstThought = group.thoughts[0]; const firstThought = group.thoughts[0];
const info = memberInfo.get(firstThought.from); const info = memberInfo.get(firstThought.from);
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}-${group.thoughts.length}`; const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
return ( return (
<LeadThoughtsGroupRow <React.Fragment key={itemKey}>
key={itemKey} {sessionSeparator}
group={group} <LeadThoughtsGroupRow
memberColor={info?.color} group={group}
isNew={newItemKeys.has(itemKey)} memberColor={info?.color}
onVisible={onMessageVisible} isNew={newItemKeys.has(itemKey)}
/> onVisible={onMessageVisible}
/>
</React.Fragment>
); );
} }
@ -303,24 +329,26 @@ export const ActivityTimeline = ({
? !message.read && !readState.readSet.has(readState.getMessageKey(message)) ? !message.read && !readState.readSet.has(readState.getMessageKey(message))
: !message.read; : !message.read;
return ( return (
<MessageRowWithObserver <React.Fragment key={messageKey}>
key={messageKey} {sessionSeparator}
message={message} <MessageRowWithObserver
teamName={teamName} message={message}
memberRole={info?.role} teamName={teamName}
memberColor={info?.color} memberRole={info?.role}
recipientColor={recipientColor} memberColor={info?.color}
isUnread={isUnread} recipientColor={recipientColor}
isNew={newItemKeys.has(messageKey)} isUnread={isUnread}
zebraShade={zebraShadeSet.has(index)} isNew={newItemKeys.has(messageKey)}
memberColorMap={colorMap} zebraShade={zebraShadeSet.has(index)}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined} memberColorMap={colorMap}
onCreateTask={onCreateTaskFromMessage} onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onReply={onReplyToMessage} onCreateTask={onCreateTaskFromMessage}
onVisible={onMessageVisible} onReply={onReplyToMessage}
onTaskIdClick={onTaskIdClick} onVisible={onMessageVisible}
onRestartTeam={onRestartTeam} onTaskIdClick={onTaskIdClick}
/> onRestartTeam={onRestartTeam}
/>
</React.Fragment>
); );
})} })}
{hiddenCount > 0 && ( {hiddenCount > 0 && (

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { import {
@ -8,7 +8,6 @@ import {
CARD_TEXT_LIGHT, CARD_TEXT_LIGHT,
} from '@renderer/constants/cssVariables'; } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { ChevronRight } from 'lucide-react';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
@ -74,6 +73,8 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
} }
const VIEWPORT_THRESHOLD = 0.15; const VIEWPORT_THRESHOLD = 0.15;
const LIVE_WINDOW_MS = 10_000;
const AUTO_SCROLL_THRESHOLD = 30;
interface LeadThoughtsGroupRowProps { interface LeadThoughtsGroupRowProps {
group: LeadThoughtGroup; group: LeadThoughtGroup;
@ -94,34 +95,61 @@ function formatTimeWithSec(timestamp: string): string {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} }
function isRecentTimestamp(timestamp: string): boolean {
const t = Date.parse(timestamp);
if (Number.isNaN(t)) return false;
return Date.now() - t <= LIVE_WINDOW_MS;
}
export const LeadThoughtsGroupRow = ({ export const LeadThoughtsGroupRow = ({
group, group,
memberColor, memberColor,
isNew, isNew,
onVisible, onVisible,
}: LeadThoughtsGroupRowProps): React.JSX.Element => { }: LeadThoughtsGroupRowProps): React.JSX.Element => {
const [expanded, setExpanded] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false); const scrollRef = useRef<HTMLDivElement>(null);
const isUserScrolledUpRef = useRef(false);
const colors = getTeamColorSet(memberColor ?? ''); const colors = getTeamColorSet(memberColor ?? '');
const { thoughts } = group; const { thoughts } = group;
const first = thoughts[0]; // thoughts is newest-first; first=newest, last=oldest
const last = thoughts[thoughts.length - 1]; const newest = thoughts[0];
const leadName = first.from; const oldest = thoughts[thoughts.length - 1];
const leadName = newest.from;
// 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]
);
const [isLive, setIsLive] = useState(computeIsLive);
useEffect(() => {
setIsLive(computeIsLive());
const id = window.setInterval(() => setIsLive(computeIsLive()), 1000);
return () => window.clearInterval(id);
}, [computeIsLive]);
// Track how many thoughts have been reported as visible so far.
const reportedCountRef = useRef(0);
// Mark all thoughts as visible when the group enters the viewport
useEffect(() => { useEffect(() => {
if (!onVisible) return; if (!onVisible) return;
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (!entry?.isIntersecting || reportedRef.current) return; if (!entry?.isIntersecting) return;
reportedRef.current = true; const alreadyReported = reportedCountRef.current;
for (const thought of thoughts) { if (alreadyReported >= thoughts.length) return;
onVisible(thought); for (let i = alreadyReported; i < thoughts.length; i++) {
onVisible(thoughts[i]);
} }
reportedCountRef.current = thoughts.length;
}, },
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
); );
@ -129,10 +157,20 @@ export const LeadThoughtsGroupRow = ({
return () => observer.disconnect(); return () => observer.disconnect();
}, [onVisible, thoughts]); }, [onVisible, thoughts]);
// Preview: summary of newest thought (first in array since newest-first) // Auto-scroll to bottom when new thoughts arrive
const previewText = first.summary || first.text.split('\n')[0]; useEffect(() => {
const previewTruncated = if (isUserScrolledUpRef.current) return;
previewText.length > 120 ? previewText.slice(0, 117) + '...' : previewText; const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [thoughts.length]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
}, []);
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'}>
@ -142,62 +180,53 @@ export const LeadThoughtsGroupRow = ({
backgroundColor: CARD_BG, backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE, border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`, borderLeft: `3px solid ${colors.border}`,
opacity: 0.75, opacity: isLive ? undefined : 0.75,
}} }}
> >
{/* Header — click to expand/collapse */} {/* Header */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below */} <div className="flex select-none items-center gap-2 px-3 py-1.5">
<div {/* Live / offline indicator */}
role="button" {isLive ? (
tabIndex={0} <span className="pointer-events-none relative inline-flex size-2 shrink-0">
className="flex cursor-pointer select-none items-center gap-2 px-3 py-1.5 hover:bg-[rgba(255,255,255,0.02)]" <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
onClick={() => setExpanded((v) => !v)} <span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
onKeyDown={(e) => { </span>
if (e.key === 'Enter' || e.key === ' ') { ) : (
e.preventDefault(); <span className="inline-flex size-2 shrink-0 rounded-full bg-zinc-500" />
setExpanded((v) => !v); )}
}
}}
>
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
color: CARD_ICON_MUTED,
transform: expanded ? 'rotate(90deg)' : undefined,
}}
/>
<MemberBadge name={leadName} color={memberColor} hideAvatar /> <MemberBadge name={leadName} color={memberColor} hideAvatar />
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{thoughts.length} thoughts {thoughts.length} thoughts
</span> </span>
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formatTime(last.timestamp)}{formatTime(first.timestamp)} {formatTime(oldest.timestamp)}{formatTime(newest.timestamp)}
</span> </span>
{!expanded && (
<span className="flex-1 truncate text-[11px]" style={{ color: CARD_TEXT_LIGHT }}>
{previewTruncated}
</span>
)}
</div> </div>
{/* Expanded: all thoughts as compact timestamped lines */} {/* Scrollable body — fixed height, always visible */}
{expanded && ( <div
<div ref={scrollRef}
className="space-y-px border-t px-3 py-1.5" className="space-y-px border-t px-3 py-1.5"
style={{ borderColor: 'var(--color-border-subtle)' }} style={{
> borderColor: 'var(--color-border-subtle)',
{thoughts.map((thought, idx) => ( maxHeight: '200px',
<div key={thought.messageId ?? idx} className="flex gap-2 py-0.5 text-[11px]"> overflowY: 'auto',
<span className="shrink-0 font-mono" style={{ color: CARD_ICON_MUTED }}> scrollbarWidth: 'thin',
{formatTimeWithSec(thought.timestamp)} scrollbarColor: 'var(--scrollbar-thumb) transparent',
</span> }}
<span className="flex-1 leading-relaxed" style={{ color: CARD_TEXT_LIGHT }}> onScroll={handleScroll}
{thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text} >
</span> {chronologicalThoughts.map((thought, idx) => (
</div> <div key={thought.messageId ?? idx} className="flex gap-2 py-0.5 text-[11px]">
))} <span className="shrink-0 font-mono" style={{ color: CARD_ICON_MUTED }}>
</div> {formatTimeWithSec(thought.timestamp)}
)} </span>
<span className="flex-1 leading-relaxed" style={{ color: CARD_TEXT_LIGHT }}>
{thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text}
</span>
</div>
))}
</div>
</article> </article>
</div> </div>
); );

View file

@ -170,7 +170,7 @@ export const AddMemberDialog = ({
onValueChange={handleWorkflowChange} onValueChange={handleWorkflowChange}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
projectPath={projectPath ?? undefined} projectPath={projectPath ?? undefined}
placeholder="How this agent should behave, what tasks it handles. Use @ to mention teammates or add files." placeholder="How this agent should behave, what tasks it handles..."
footerRight={ footerRight={
workflowDraft.isSaved ? ( workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>

View file

@ -362,7 +362,11 @@ export const LaunchTeamDialog = ({
{prepareWarnings.length > 0 ? ( {prepareWarnings.length > 0 ? (
<div className="space-y-0.5"> <div className="space-y-0.5">
{prepareWarnings.map((warning) => ( {prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px]" style={{ color: 'var(--warning-text)' }}> <p
key={warning}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
{warning} {warning}
</p> </p>
))} ))}
@ -406,7 +410,7 @@ export const LaunchTeamDialog = ({
chips={chipDraft.chips} chips={chipDraft.chips}
onChipRemove={chipDraft.removeChip} onChipRemove={chipDraft.removeChip}
onFileChipInsert={chipDraft.addChip} onFileChipInsert={chipDraft.addChip}
placeholder="Instructions for team lead... Use @ to mention team members." placeholder="Instructions for team lead..."
footerRight={ footerRight={
promptDraft.isSaved ? ( promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>

View file

@ -144,7 +144,7 @@ export const MemberDraftRow = ({
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)} onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
triggerClassName="h-8 text-xs" triggerClassName="h-8 text-xs"
inputClassName="h-8 text-xs" inputClassName="h-8 text-xs"
/> />
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
{showWorkflow && onWorkflowChange ? ( {showWorkflow && onWorkflowChange ? (
@ -191,7 +191,7 @@ export const MemberDraftRow = ({
onChipRemove={handleChipRemove} onChipRemove={handleChipRemove}
projectPath={projectPath ?? undefined} projectPath={projectPath ?? undefined}
onFileChipInsert={handleFileChipInsert} onFileChipInsert={handleFileChipInsert}
placeholder="How this agent should behave, interact with others. Use @ to mention teammates or add files." placeholder="How this agent should behave, interact with others..."
footerRight={ footerRight={
workflowDraft.isSaved ? ( workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>

View file

@ -196,6 +196,8 @@ export interface InboxMessage {
messageId?: string; messageId?: string;
source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent'; source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent';
attachments?: AttachmentMeta[]; attachments?: AttachmentMeta[];
/** Lead session ID that produced this message (for session boundary detection). */
leadSessionId?: string;
} }
export interface SendMessageRequest { export interface SendMessageRequest {