From 297780e3a8cd8d63139518e9b14592a27024c37f Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 01:24:09 +0200 Subject: [PATCH] feat: enhance message handling and UI components for improved user experience - Updated message deduplication logic in handleGetData to exclude lead_process messages with a 'to' field. - Added source field to message payloads in TeamAgentToolsInstaller for system notifications. - Enriched sendMessage function in TeamDataService with leadSessionId for better session tracking. - Improved UI components including MessageComposer placeholder text and scroll behavior in ActivityTimeline. - Enhanced LeadThoughtsGroupRow to support live indicators and auto-scroll functionality for new messages. --- src/main/ipc/teams.ts | 2 +- .../services/team/TeamAgentToolsInstaller.ts | 11 ++- src/main/services/team/TeamDataService.ts | 21 ++++- src/main/services/team/TeamInboxWriter.ts | 1 + .../services/team/TeamProvisioningService.ts | 12 ++- .../components/team/ClaudeLogsSection.tsx | 3 +- .../components/team/CliLogsRichView.tsx | 43 +++++++-- .../team/activity/ActivityTimeline.tsx | 38 +++++++- .../team/activity/LeadThoughtsGroup.tsx | 25 +++-- .../components/team/members/MemberLogsTab.tsx | 91 +++++++++++++++++-- .../team/messages/MessageComposer.tsx | 2 +- src/renderer/components/ui/dialog.tsx | 28 +++--- src/shared/types/team.ts | 2 + 13 files changed, 231 insertions(+), 48 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 188ffbc5..85f36da7 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -430,7 +430,7 @@ async function handleGetData( const merged: typeof data.messages = []; const seen = new Set(); for (const msg of [...data.messages, ...live]) { - if ((msg as { source?: unknown }).source === 'lead_process') { + if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) { const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`; // Skip if same text already exists from any source (inbox, lead_session, etc.) if (existingTextFingerprints.has(fp)) { diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 7868cc1c..a2f7fc04 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -865,6 +865,7 @@ function sendInboxMessage(paths, teamName, flags) { summary, messageId, }; + if (flags.source) payload.source = flags.source; var lastErr; for (var attempt = 0; attempt < 8; attempt++) { @@ -915,6 +916,7 @@ function reviewApprove(paths, teamName, taskId, flags) { text: inboxText, summary: 'Approved #' + String(taskId), from, + source: 'system_notification', }); } @@ -957,6 +959,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) { text: inboxText, summary: 'Fix request for #' + String(taskId), from, + source: 'system_notification', }); } @@ -1282,6 +1285,7 @@ async function main() { text: parts.join('\n'), summary: 'New task #' + String(task.id) + ' assigned', from, + source: 'system_notification', }); } } @@ -1319,6 +1323,7 @@ async function main() { text: 'Comment on task #' + String(result.taskId) + ' "' + String(result.subject) + '":\n\n' + (typeof args.flags.text === 'string' ? args.flags.text.trim() : ''), summary: 'Comment on #' + String(result.taskId), from: from, + source: 'system_notification', }); } catch (e) { /* best-effort */ } } @@ -1396,6 +1401,7 @@ async function main() { text: parts.join('\n'), summary: 'Task #' + String(task.id) + ' assigned', from, + source: 'system_notification', }); } return; @@ -1493,7 +1499,10 @@ async function main() { if (domain === 'message') { if (action === 'send') { - const result = sendInboxMessage(paths, teamName, args.flags); + // Strip source from agent flags — only internal callers may set it + var msgFlags = Object.assign({}, args.flags); + delete msgFlags.source; + const result = sendInboxMessage(paths, teamName, msgFlags); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 964e620c..b6edd939 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -283,6 +283,7 @@ export class TeamDataService { // 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. + // Exception: lead_process messages with `to` field are captured SendMessage — never dedup those. if (leadTexts.length > 0 && sentMessages.length > 0) { const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); const leadSessionFingerprints = new Set(); @@ -292,6 +293,8 @@ export class TeamDataService { } messages = messages.filter((m) => { if (m.source !== 'lead_process') return true; + // Captured SendMessage messages (with recipient) are real messages — never dedup + if (m.to) return true; const fp = `${m.from}\0${normalizeText(m.text ?? '')}`; return !leadSessionFingerprints.has(fp); }); @@ -311,8 +314,11 @@ export class TeamDataService { msg.leadSessionId = currentSessionId; } } - // Backward pass: fill messages before the first known session - currentSessionId = undefined; + // Backward pass: fill messages before the first known session. + // Seed with config.leadSessionId so that recent messages without an explicit + // session ID inherit the current (most recent) session — this ensures that + // session boundary separators work even when inbox entries lack the field. + currentSessionId = config.leadSessionId; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].leadSessionId) { currentSessionId = messages[i].leadSessionId; @@ -1117,6 +1123,17 @@ export class TeamDataService { } async sendMessage(teamName: string, request: SendMessageRequest): Promise { + // Enrich with leadSessionId so session boundary separators work + if (!request.leadSessionId) { + try { + const config = await this.configReader.getConfig(teamName); + if (config?.leadSessionId) { + request = { ...request, leadSessionId: config.leadSessionId }; + } + } catch { + // non-critical + } + } return this.inboxWriter.sendMessage(teamName, request); } diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index a0776db4..8ac414ec 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -30,6 +30,7 @@ export class TeamInboxWriter { messageId, attachments: attachmentMeta?.length ? attachmentMeta : undefined, ...(request.source && { source: request.source }), + ...(request.leadSessionId && { leadSessionId: request.leadSessionId }), }; await withInboxLock(inboxPath, async () => { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5b7e882c..1156b83a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -79,8 +79,6 @@ const PREFLIGHT_PING_ARGS = [ 'haiku', '--max-turns', '1', - '--max-budget-usd', - '0.05', '--no-session-persistence', ] as const; const PREFLIGHT_EXPECTED = 'PONG'; @@ -2797,6 +2795,16 @@ export class TeamProvisioningService { } pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void { + // Enrich with leadSessionId if missing — needed for session boundary separators + if (!message.leadSessionId) { + const runId = this.activeByTeam.get(teamName); + if (runId) { + const run = this.runs.get(runId); + if (run?.detectedSessionId) { + message.leadSessionId = run.detectedSessionId; + } + } + } const MAX = 100; const list = this.liveLeadProcessMessages.get(teamName) ?? []; const id = typeof message.messageId === 'string' ? message.messageId.trim() : ''; diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index d8cfa28e..e2a42c43 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -330,7 +330,8 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J badge={badge} headerExtra={headerExtra} defaultOpen - contentClassName="pt-0" + // Prevent scroll anchoring from "pulling" the parent container when logs update. + contentClassName="pt-0 [overflow-anchor:none]" >
diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 8371ee99..986a763b 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -17,9 +17,11 @@ import { Bot, ChevronRight } from 'lucide-react'; import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser'; +type CliLogsOrder = 'oldest-first' | 'newest-first'; + interface CliLogsRichViewProps { cliLogsTail: string; - order?: 'oldest-first' | 'newest-first'; + order?: CliLogsOrder; onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void; containerRefCallback?: (el: HTMLDivElement | null) => void; /** Optional local search query override for inline highlighting */ @@ -156,6 +158,8 @@ export const CliLogsRichView = ({ className, }: CliLogsRichViewProps): React.JSX.Element => { const scrollRef = useRef(null); + const stickToEdgeRef = useRef(true); + const lastOrderRef = useRef(order); // Tracks groups manually collapsed by user (default: all auto-expanded) const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); @@ -173,15 +177,38 @@ export const CliLogsRichView = ({ return expanded; }, [groups, collapsedGroupIds]); - // Auto-scroll to bottom on new content - useEffect(() => { - if (scrollRef.current) { + const computeShouldStickToEdge = useCallback( + (el: HTMLDivElement): boolean => { + // Small threshold makes it feel "sticky" but still allows reading slightly away from the edge + const thresholdPx = 16; if (order === 'newest-first') { - scrollRef.current.scrollTop = 0; - } else { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + return el.scrollTop <= thresholdPx; } + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + return distanceFromBottom <= thresholdPx; + }, + [order] + ); + + // Auto-scroll only when user is pinned to the edge. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + + // If the sort order changes, always snap once (expectation: show the "newest edge"). + if (lastOrderRef.current !== order) { + lastOrderRef.current = order; + stickToEdgeRef.current = true; } + + if (!stickToEdgeRef.current) return; + + if (order === 'newest-first') { + el.scrollTop = 0; + return; + } + + el.scrollTop = el.scrollHeight; }, [cliLogsTail, order]); const handleGroupToggle = useCallback((groupId: string) => { @@ -223,6 +250,7 @@ export const CliLogsRichView = ({ )} onScroll={(e) => { const el = e.currentTarget; + stickToEdgeRef.current = computeShouldStickToEdge(el); onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, @@ -254,6 +282,7 @@ export const CliLogsRichView = ({ className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)} onScroll={(e) => { const el = e.currentTarget; + stickToEdgeRef.current = computeShouldStickToEdge(el); onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 0f29d8ef..1a1a19a7 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -287,13 +287,40 @@ export const ActivityTimeline = ({ ? item.group.thoughts[0].leadSessionId : item.message.leadSessionId; + // Pin the newest thought group (if first) so it stays at the top and doesn't jump. + const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; + const startIndex = pinnedThoughtGroup ? 1 : 0; + return (
- {timelineItems.map((item, index) => { + {/* Pinned (newest) thought group — always at top */} + {pinnedThoughtGroup && + (() => { + const { group } = pinnedThoughtGroup; + const firstThought = group.thoughts[0]; + const info = memberInfo.get(firstThought.from); + const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`; + return ( + + ); + })()} + + {/* Remaining items */} + {timelineItems.slice(startIndex).map((item, index) => { + const realIndex = index + startIndex; + // 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]); + if (realIndex > 0) { + const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]); const currSessionId = getItemSessionId(item); if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { sessionSeparator = ( @@ -322,9 +349,10 @@ export const ActivityTimeline = ({ ); @@ -350,7 +378,7 @@ export const ActivityTimeline = ({ recipientColor={recipientColor} isUnread={isUnread} isNew={newItemKeys.has(messageKey)} - zebraShade={zebraShadeSet.has(index)} + zebraShade={zebraShadeSet.has(realIndex)} memberColorMap={colorMap} onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined} onCreateTask={onCreateTaskFromMessage} diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index fcf9b849..e114915f 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { CARD_BG, @@ -75,6 +76,8 @@ interface LeadThoughtsGroupRowProps { memberColor?: string; isNew?: boolean; onVisible?: (message: InboxMessage) => void; + /** When false, the live indicator is always off (for historical thought groups). */ + canBeLive?: boolean; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; } @@ -102,6 +105,7 @@ export const LeadThoughtsGroupRow = ({ memberColor, isNew, onVisible, + canBeLive, zebraShade, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); @@ -130,11 +134,12 @@ export const LeadThoughtsGroupRow = ({ // Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought) const computeIsLive = useCallback( () => + canBeLive !== false && isTeamAlive && (leadActivity === 'active' || (leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) || isRecentTimestamp(newest.timestamp)), - [isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp] + [canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp] ); const [isLive, setIsLive] = useState(computeIsLive); @@ -168,13 +173,16 @@ export const LeadThoughtsGroupRow = ({ return () => observer.disconnect(); }, [onVisible, thoughts]); - // Auto-scroll to bottom when new thoughts arrive + // Stable ref for auto-scroll trigger: track content changes (new thoughts + text growth) + const newestTextLength = newest.text.length; + + // Auto-scroll to bottom when new thoughts arrive or text grows useEffect(() => { if (isUserScrolledUpRef.current) return; const el = scrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; - }, [thoughts.length]); + }, [thoughts.length, newestTextLength]); const handleScroll = useCallback(() => { const el = scrollRef.current; @@ -195,7 +203,6 @@ export const LeadThoughtsGroupRow = ({ backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG, border: CARD_BORDER_STYLE, borderLeft: `3px solid ${colors.border}`, - opacity: isLive ? undefined : 0.75, }} > {/* Header */} @@ -236,9 +243,13 @@ export const LeadThoughtsGroupRow = ({ {formatTimeWithSec(thought.timestamp)} - - {thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text} - +
+ +
))}
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 62cc2f56..c55619e9 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -61,17 +61,40 @@ export const MemberLogsTab = ({ () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), [taskWorkIntervals] ); + const isMountedRef = useRef(true); const hasLoadedRef = useRef(false); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const refreshCountRef = useRef(0); const [error, setError] = useState(null); const [expandedId, setExpandedId] = useState(null); + const expandedIdRef = useRef(null); const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [previewChunks, setPreviewChunks] = useState(null); + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + expandedIdRef.current = expandedId; + }, [expandedId]); + + const beginRefreshing = useCallback((): void => { + refreshCountRef.current += 1; + if (isMountedRef.current) setRefreshing(true); + }, []); + + const endRefreshing = useCallback((): void => { + refreshCountRef.current = Math.max(0, refreshCountRef.current - 1); + if (isMountedRef.current) setRefreshing(refreshCountRef.current > 0); + }, []); + const getRowId = useCallback((log: MemberLogSummary): string => { return log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` @@ -173,6 +196,7 @@ export const MemberLogsTab = ({ const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; const load = async (): Promise => { + let didBeginRefreshing = false; try { if (taskId == null && !memberName) { if (!cancelled) setLogs([]); @@ -181,7 +205,8 @@ export const MemberLogsTab = ({ if (!hasLoadedRef.current) { setLoading(true); } else { - setRefreshing(true); + beginRefreshing(); + didBeginRefreshing = true; } setError(null); @@ -193,10 +218,22 @@ export const MemberLogsTab = ({ intervals: taskWorkIntervals, }) : await api.teams.getMemberLogs(teamName, memberName!); + const nextLogs = Array.isArray(result) ? [...result] : []; + if (!cancelled) { - setLogs(Array.isArray(result) ? [...result] : []); + setLogs(nextLogs); hasLoadedRef.current = true; } + + // Keep expanded session details in sync with the same refresh + // cadence as the summary (counts/titles) while "Updating..." is shown. + if (!cancelled && didBeginRefreshing) { + try { + await refreshExpandedDetailFromLogs(nextLogs); + } catch { + // Keep last successful detail view; avoid flicker on transient failures. + } + } } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : 'Unknown error'); @@ -204,7 +241,7 @@ export const MemberLogsTab = ({ } finally { if (!cancelled) { setLoading(false); - setRefreshing(false); + if (didBeginRefreshing) endRefreshing(); } } }; @@ -232,6 +269,26 @@ export const MemberLogsTab = ({ [] ); + const refreshExpandedDetailFromLogs = useCallback( + async (nextLogs: MemberLogSummary[]): Promise => { + const rowId = expandedIdRef.current; + if (!rowId) return; + if (!isMountedRef.current) return; + + const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId); + if (!nextExpanded) return; + + const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; + if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return; + + const next = await fetchDetailForLog(nextExpanded); + if (!isMountedRef.current) return; + // Ensure new reference so memoized transforms update. + setDetailChunks(next ? [...next] : null); + }, + [fetchDetailForLog, getRowId, taskId, taskStatus] + ); + useEffect(() => { if (!shouldShowPreview) { setPreviewChunks(null); @@ -268,12 +325,15 @@ export const MemberLogsTab = ({ let cancelled = false; const interval = setInterval(async () => { + beginRefreshing(); try { const next = await fetchDetailForLog(previewLog); if (cancelled) return; setPreviewChunks(next ? [...next] : null); } catch { // keep last successful preview + } finally { + endRefreshing(); } }, 5000); @@ -281,16 +341,27 @@ export const MemberLogsTab = ({ cancelled = true; clearInterval(interval); }; - }, [fetchDetailForLog, previewLog, shouldShowPreview, taskStatus]); + }, [ + beginRefreshing, + endRefreshing, + fetchDetailForLog, + previewLog, + shouldShowPreview, + taskStatus, + ]); useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; if (!expandedLogSummary) return; - if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; + // When task logs are auto-refreshing, the summary refresh loop also refreshes + // expanded details to keep everything in sync (and avoid duplicate requests). + if (shouldAutoRefreshSummary) return; + if (!expandedLogSummary.isOngoing) return; let cancelled = false; - const interval = setInterval(async () => { + const refreshDetail = async (): Promise => { + beginRefreshing(); try { const next = await fetchDetailForLog(expandedLogSummary); if (cancelled) return; @@ -298,14 +369,18 @@ export const MemberLogsTab = ({ setDetailChunks(next ? [...next] : null); } catch { // Keep last successful data; avoid flicker during transient errors. + } finally { + endRefreshing(); } - }, 5000); + }; + + const interval = setInterval(() => void refreshDetail(), 5000); return () => { cancelled = true; clearInterval(interval); }; - }, [expandedLogSummary, fetchDetailForLog, taskId, taskStatus]); + }, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]); const handleExpand = useCallback( async (log: MemberLogSummary) => { diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 5d918032..89563741 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -414,7 +414,7 @@ export const MessageComposer = ({ (({ className, children, ...props }, ref) => ( - -
- +
+
+ Close + + {children} +
- {children} - +
)); DialogContent.displayName = DialogPrimitive.Content.displayName; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 06220230..7a330125 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -208,6 +208,8 @@ export interface SendMessageRequest { from?: string; attachments?: AttachmentPayload[]; source?: InboxMessage['source']; + /** Lead session ID for session boundary detection. */ + leadSessionId?: string; } export interface SendMessageResult {