diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 2635505d..ec57667c 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -258,8 +258,9 @@ export class TeamDataService { } mark('messages'); + let leadTexts: InboxMessage[] = []; try { - const leadTexts = await this.extractLeadSessionTexts(config); + leadTexts = await this.extractLeadSessionTexts(config); if (leadTexts.length > 0) { messages = [...messages, ...leadTexts]; } @@ -268,8 +269,9 @@ export class TeamDataService { } mark('leadTexts'); + let sentMessages: InboxMessage[] = []; try { - const sentMessages = await this.sentMessagesStore.readMessages(teamName); + sentMessages = await this.sentMessagesStore.readMessages(teamName); if (sentMessages.length > 0) { messages = [...messages, ...sentMessages]; } @@ -278,6 +280,33 @@ export class TeamDataService { } 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(); + 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)); let metaMembers: TeamConfig['members'] = []; @@ -1102,6 +1131,15 @@ export class TeamDataService { attachments?: AttachmentMeta[] ): Promise { 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 = { from: 'user', to: leadName, @@ -1112,6 +1150,7 @@ export class TeamDataService { messageId, source: 'user_sent', attachments: attachments?.length ? attachments : undefined, + leadSessionId, }; await this.sentMessagesStore.appendMessage(teamName, msg); return { deliveredToInbox: false, deliveredViaStdin: true, messageId }; @@ -1214,7 +1253,8 @@ export class TeamDataService { name: (() => { const name = member.name.trim(); 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); if (suffixInfo && suffixInfo.suffix >= 2) { throw new Error( @@ -1374,6 +1414,7 @@ export class TeamDataService { timestamp, read: true, source: 'lead_session', + leadSessionId: config.leadSessionId, }); if (textsReversed.length >= MAX_LEAD_TEXTS) break; } diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index 267ca833..88ef5b9e 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -75,6 +75,7 @@ export class TeamSentMessagesStore { color: typeof row.color === 'string' ? row.color : undefined, attachments: Array.isArray(row.attachments) ? row.attachments : undefined, source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, + leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined, }); } diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 171b98c1..a97eb447 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -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'; @@ -213,7 +213,8 @@ export const ActivityTimeline = ({ const newItemKeys = useMemo(() => { const getItemKey = (item: TimelineItem): string => { 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; 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 (
{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 = ( +
+
+ + Новая сессия + +
+
+ ); + } + } + if (item.type === 'lead-thoughts') { const { group } = item; const firstThought = group.thoughts[0]; 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 ( - + + {sessionSeparator} + + ); } @@ -303,24 +329,26 @@ export const ActivityTimeline = ({ ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) : !message.read; return ( - + + {sessionSeparator} + + ); })} {hiddenCount > 0 && ( diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 9402c545..0d85a943 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -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 { @@ -8,7 +8,6 @@ import { CARD_TEXT_LIGHT, } from '@renderer/constants/cssVariables'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -import { ChevronRight } from 'lucide-react'; import type { InboxMessage } from '@shared/types'; @@ -74,6 +73,8 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { } const VIEWPORT_THRESHOLD = 0.15; +const LIVE_WINDOW_MS = 10_000; +const AUTO_SCROLL_THRESHOLD = 30; interface LeadThoughtsGroupRowProps { group: LeadThoughtGroup; @@ -94,34 +95,61 @@ function formatTimeWithSec(timestamp: string): string { 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 = ({ group, memberColor, isNew, onVisible, }: LeadThoughtsGroupRowProps): React.JSX.Element => { - const [expanded, setExpanded] = useState(false); const ref = useRef(null); - const reportedRef = useRef(false); + const scrollRef = useRef(null); + const isUserScrolledUpRef = useRef(false); const colors = getTeamColorSet(memberColor ?? ''); const { thoughts } = group; - const first = thoughts[0]; - const last = thoughts[thoughts.length - 1]; - const leadName = first.from; + // thoughts is newest-first; first=newest, last=oldest + const newest = thoughts[0]; + 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(() => { if (!onVisible) return; const el = ref.current; if (!el) return; const observer = new IntersectionObserver( ([entry]) => { - if (!entry?.isIntersecting || reportedRef.current) return; - reportedRef.current = true; - for (const thought of thoughts) { - onVisible(thought); + if (!entry?.isIntersecting) return; + const alreadyReported = reportedCountRef.current; + if (alreadyReported >= thoughts.length) return; + for (let i = alreadyReported; i < thoughts.length; i++) { + onVisible(thoughts[i]); } + reportedCountRef.current = thoughts.length; }, { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); @@ -129,10 +157,20 @@ export const LeadThoughtsGroupRow = ({ return () => observer.disconnect(); }, [onVisible, thoughts]); - // Preview: summary of newest thought (first in array since newest-first) - const previewText = first.summary || first.text.split('\n')[0]; - const previewTruncated = - previewText.length > 120 ? previewText.slice(0, 117) + '...' : previewText; + // Auto-scroll to bottom when new thoughts arrive + useEffect(() => { + if (isUserScrolledUpRef.current) return; + 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 (
@@ -142,62 +180,53 @@ export const LeadThoughtsGroupRow = ({ backgroundColor: CARD_BG, border: CARD_BORDER_STYLE, borderLeft: `3px solid ${colors.border}`, - opacity: 0.75, + opacity: isLive ? undefined : 0.75, }} > - {/* Header — click to expand/collapse */} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below */} -
setExpanded((v) => !v)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setExpanded((v) => !v); - } - }} - > - + {/* Header */} +
+ {/* Live / offline indicator */} + {isLive ? ( + + + + + ) : ( + + )} {thoughts.length} thoughts - {formatTime(last.timestamp)}–{formatTime(first.timestamp)} + {formatTime(oldest.timestamp)}–{formatTime(newest.timestamp)} - {!expanded && ( - - {previewTruncated} - - )}
- {/* Expanded: all thoughts as compact timestamped lines */} - {expanded && ( -
- {thoughts.map((thought, idx) => ( -
- - {formatTimeWithSec(thought.timestamp)} - - - {thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text} - -
- ))} -
- )} + {/* Scrollable body — fixed height, always visible */} +
+ {chronologicalThoughts.map((thought, idx) => ( +
+ + {formatTimeWithSec(thought.timestamp)} + + + {thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text} + +
+ ))} +
); diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 1549cd70..14a51489 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -170,7 +170,7 @@ export const AddMemberDialog = ({ onValueChange={handleWorkflowChange} suggestions={mentionSuggestions} 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={ workflowDraft.isSaved ? ( Draft saved diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 3a296265..adfadeec 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -362,7 +362,11 @@ export const LaunchTeamDialog = ({ {prepareWarnings.length > 0 ? (
{prepareWarnings.map((warning) => ( -

+

{warning}

))} @@ -406,7 +410,7 @@ export const LaunchTeamDialog = ({ chips={chipDraft.chips} onChipRemove={chipDraft.removeChip} onFileChipInsert={chipDraft.addChip} - placeholder="Instructions for team lead... Use @ to mention team members." + placeholder="Instructions for team lead..." footerRight={ promptDraft.isSaved ? ( Draft saved diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 469bd7c6..d235fc40 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -144,7 +144,7 @@ export const MemberDraftRow = ({ onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)} triggerClassName="h-8 text-xs" inputClassName="h-8 text-xs" -/> + />
{showWorkflow && onWorkflowChange ? ( @@ -191,7 +191,7 @@ export const MemberDraftRow = ({ onChipRemove={handleChipRemove} projectPath={projectPath ?? undefined} 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={ workflowDraft.isSaved ? ( Draft saved diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index a9cf4e19..ab0ee71f 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -196,6 +196,8 @@ export interface InboxMessage { messageId?: string; source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent'; attachments?: AttachmentMeta[]; + /** Lead session ID that produced this message (for session boundary detection). */ + leadSessionId?: string; } export interface SendMessageRequest {