From 1326c099fb32f87409fb9602246bb425c5f2aff9 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 21:18:24 +0200 Subject: [PATCH] feat: enhance TeamProvisioningService and ActivityTimeline for improved message handling and UI updates - Updated ProvisioningRun interface to include leadTurnSeq and leadTurnMessageTimestamp for better tracking of lead messages. - Refactored pushLiveLeadProcessMessage to handle message updates and prevent duplicates. - Introduced removeLiveLeadProcessMessage for better management of live lead messages. - Enhanced ActivityTimeline to group lead thoughts into collapsible blocks and improve zebra striping logic. - Updated MemberLogsTab to support quick previews from lead sessions, enhancing task visibility. --- .../services/team/TeamProvisioningService.ts | 226 ++++++++++-------- .../team/activity/ActivityTimeline.tsx | 82 +++++-- .../team/activity/LeadThoughtsGroup.tsx | 204 ++++++++++++++++ .../team/dialogs/TaskDetailDialog.tsx | 3 + .../components/team/members/MemberLogsTab.tsx | 52 ++-- 5 files changed, 428 insertions(+), 139 deletions(-) create mode 100644 src/renderer/components/team/activity/LeadThoughtsGroup.tsx diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 03eab37d..a86b54ce 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -169,8 +169,10 @@ interface ProvisioningRun { * Flushed to liveLeadProcessMessages on result.success. */ directReplyParts: string[]; - /** Whether we already emitted live lead text during the current turn (before result). */ - leadTextPushedInCurrentTurn: boolean; + /** Monotonic counter for stream-json turns (incremented on result). */ + leadTurnSeq: number; + /** Stable timestamp used for the current aggregated lead turn message. */ + leadTurnMessageTimestamp: string | null; /** Throttle timestamp for emitting inbox refresh events for lead text. */ lastLeadTextEmitMs: number; /** @@ -1751,7 +1753,8 @@ export class TeamProvisioningService { fsPhase: 'waiting_config', leadRelayCapture: null, directReplyParts: [], - leadTextPushedInCurrentTurn: false, + leadTurnSeq: 0, + leadTurnMessageTimestamp: null, lastLeadTextEmitMs: 0, silentUserDmForward: null, silentUserDmForwardClearHandle: null, @@ -2051,7 +2054,8 @@ export class TeamProvisioningService { fsPhase: 'waiting_members', leadRelayCapture: null, directReplyParts: [], - leadTextPushedInCurrentTurn: false, + leadTurnSeq: 0, + leadTurnMessageTimestamp: null, lastLeadTextEmitMs: 0, silentUserDmForward: null, silentUserDmForwardClearHandle: null, @@ -2795,13 +2799,36 @@ export class TeamProvisioningService { pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void { const MAX = 100; const list = this.liveLeadProcessMessages.get(teamName) ?? []; - list.push(message); + const id = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (id) { + const existingIdx = list.findIndex((m) => (m.messageId ?? '').trim() === id); + if (existingIdx >= 0) { + list[existingIdx] = message; + } else { + list.push(message); + } + } else { + list.push(message); + } if (list.length > MAX) { list.splice(0, list.length - MAX); } this.liveLeadProcessMessages.set(teamName, list); } + private removeLiveLeadProcessMessage(teamName: string, messageId: string): void { + const id = messageId.trim(); + if (!id) return; + const list = this.liveLeadProcessMessages.get(teamName); + if (!list || list.length === 0) return; + const next = list.filter((m) => (m.messageId ?? '').trim() !== id); + if (next.length === 0) { + this.liveLeadProcessMessages.delete(teamName); + } else { + this.liveLeadProcessMessages.set(teamName, next); + } + } + /** * Stop the running process for a team. No-op if team is not running. */ @@ -2846,6 +2873,14 @@ export class TeamProvisioningService { return Array.isArray(inner) ? (inner as Record[]) : null; })(); + const hasSendMessageToUser = (content ?? []).some((part) => { + if (!part || typeof part !== 'object') return false; + if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false; + const input = (part as Record).input; + if (!input || typeof input !== 'object') return false; + return (input as Record).recipient === 'user'; + }); + const textParts = (content ?? []) .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text as string); @@ -2859,41 +2894,6 @@ export class TeamProvisioningService { return; } logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); - // After provisioning, surface lead assistant output in Messages immediately. - // Lead session JSONL changes are not watched, so without an explicit trigger - // the Messages tab may lag behind Claude Logs until another team-change event. - if (run.provisioningComplete && !run.leadRelayCapture && !run.silentUserDmForward) { - const cleanText = stripAgentBlocks(text).trim(); - if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) { - const leadName = - run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || - 'team-lead'; - const leadMsg: InboxMessage = { - from: leadName, - text: cleanText, - timestamp: nowIso(), - read: true, - summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, - messageId: `lead-text-${run.runId}-${Date.now()}`, - source: 'lead_process', - }; - this.pushLiveLeadProcessMessage(run.teamName, leadMsg); - run.leadTextPushedInCurrentTurn = true; - - const now = Date.now(); - if ( - now - run.lastLeadTextEmitMs >= - TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS - ) { - run.lastLeadTextEmitMs = now; - this.teamChangeEmitter?.({ - type: 'inbox', - teamName: run.teamName, - detail: 'lead-text', - }); - } - } - } // During provisioning (before provisioningComplete), accumulate for live UI preview. // Emission is handled by the throttled emitLogsProgress() in the stdout data handler. if (!run.provisioningComplete) { @@ -2913,9 +2913,52 @@ export class TeamProvisioningService { }, capture.idleMs); } } else if (run.provisioningComplete) { - // Accumulate assistant text for direct user→lead messages (no relay capture). - if (!run.silentUserDmForward) { + // Accumulate assistant text for a single "live lead turn" message in Messages. + // If the same assistant message includes SendMessage(to:"user"), prefer the captured + // SendMessage and avoid duplicating it as a separate lead text entry. + if (!run.silentUserDmForward && !hasSendMessageToUser) { run.directReplyParts.push(text); + const raw = run.directReplyParts.join(''); + const cleanText = stripAgentBlocks(raw).trim(); + if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) { + const leadName = + run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || + 'team-lead'; + if (!run.leadTurnMessageTimestamp) { + run.leadTurnMessageTimestamp = nowIso(); + } + const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`; + const leadMsg: InboxMessage = { + from: leadName, + text: cleanText, + timestamp: run.leadTurnMessageTimestamp, + read: true, + summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText, + messageId, + source: 'lead_process', + }; + this.pushLiveLeadProcessMessage(run.teamName, leadMsg); + + const now = Date.now(); + if ( + now - run.lastLeadTextEmitMs >= + TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS + ) { + run.lastLeadTextEmitMs = now; + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'lead-text', + }); + } + } + } else if (hasSendMessageToUser) { + run.directReplyParts = []; + run.leadTurnMessageTimestamp = null; + this.removeLiveLeadProcessMessage( + run.teamName, + `lead-turn-${run.runId}-${run.leadTurnSeq}` + ); } } } @@ -3058,66 +3101,47 @@ export class TeamProvisioningService { const combined = capture.textParts.join('').trim(); capture.resolveOnce(combined); } else if (run.provisioningComplete && run.directReplyParts.length > 0) { - // Flush accumulated assistant reply from direct user→lead message + // Finalize the current live lead turn message (single messageId per turn). const rawReply = run.directReplyParts.join('').trim(); run.directReplyParts = []; - if (!run.leadTextPushedInCurrentTurn) { - const leadName = - run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || - 'team-lead'; - // Strip agent-only blocks — lead may include coordination content not meant for the user - const replyText = stripAgentBlocks(rawReply); - if (replyText.length > 0) { - const replyMsg: InboxMessage = { - from: leadName, - to: 'user', - text: replyText, - timestamp: nowIso(), - read: true, - summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText, - messageId: `lead-direct-${run.runId}-${Date.now()}`, - source: 'lead_process', - }; - this.pushLiveLeadProcessMessage(run.teamName, replyMsg); - // Persist to disk so replies survive app restart - void this.sentMessagesStore - .appendMessage(run.teamName, replyMsg) - .catch((e: unknown) => - logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`) - ); - this.teamChangeEmitter?.({ - type: 'inbox', - teamName: run.teamName, - detail: 'lead-direct-reply', - }); - } else if (rawReply.length > 0) { - // Lead responded but only with agent-only content — send generic acknowledgment - const fallbackMsg: InboxMessage = { - from: leadName, - to: 'user', - text: '(Message received and processed)', - timestamp: nowIso(), - read: true, - summary: 'Message processed', - messageId: `lead-direct-${run.runId}-${Date.now()}`, - source: 'lead_process', - }; - this.pushLiveLeadProcessMessage(run.teamName, fallbackMsg); - void this.sentMessagesStore - .appendMessage(run.teamName, fallbackMsg) - .catch((e: unknown) => - logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`) - ); - this.teamChangeEmitter?.({ - type: 'inbox', - teamName: run.teamName, - detail: 'lead-direct-reply', - }); + const leadName = + run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || + 'team-lead'; + const replyText = stripAgentBlocks(rawReply).trim(); + const finalText = + replyText.length > 0 + ? replyText + : rawReply.length > 0 + ? '(Message received and processed)' + : ''; + if (finalText.length > 0) { + if (!run.leadTurnMessageTimestamp) { + run.leadTurnMessageTimestamp = nowIso(); } + const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`; + const replyMsg: InboxMessage = { + from: leadName, + text: finalText, + timestamp: run.leadTurnMessageTimestamp, + read: true, + summary: finalText.length > 60 ? finalText.slice(0, 57) + '...' : finalText, + messageId, + source: 'lead_process', + }; + this.pushLiveLeadProcessMessage(run.teamName, replyMsg); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'lead-turn-final', + }); } } - // Turn boundary: reset per-turn lead text tracking. - run.leadTextPushedInCurrentTurn = false; + // Turn boundary: advance lead turn sequence. + if (run.provisioningComplete) { + run.leadTurnSeq += 1; + run.leadTurnMessageTimestamp = null; + run.directReplyParts = []; + } // Clear silent relay flag after any successful turn. run.silentUserDmForward = null; if (run.silentUserDmForwardClearHandle) { @@ -3134,8 +3158,12 @@ export class TeamProvisioningService { if (run.leadRelayCapture) { run.leadRelayCapture.rejectOnce(errorMsg); } - // Turn boundary: reset per-turn lead text tracking. - run.leadTextPushedInCurrentTurn = false; + // Turn boundary: advance lead turn sequence. + if (run.provisioningComplete) { + run.leadTurnSeq += 1; + run.leadTurnMessageTimestamp = null; + run.directReplyParts = []; + } // Clear silent relay flag after any errored turn. run.silentUserDmForward = null; if (run.silentUserDmForwardClearHandle) { diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 46de0418..171b98c1 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { parseStructuredAgentMessage } from '@renderer/utils/agentMessageFormatting'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; +import { groupTimelineItems, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; +import type { TimelineItem } from './LeadThoughtsGroup'; interface ActivityTimelineProps { messages: InboxMessage[]; @@ -185,29 +186,49 @@ export const ActivityTimeline = ({ [messages, visibleCount, hiddenCount] ); - // Zebra striping: alternate shade on non-noise (full card) messages only. + // Group consecutive lead thoughts into collapsible blocks. + const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]); + + // Zebra striping: alternate shade on non-noise (full card) items only. const zebraShadeSet = useMemo(() => { const result = new Set(); let cardCount = 0; - for (let i = 0; i < visibleMessages.length; i++) { - if (isNoiseMessage(visibleMessages[i].text)) continue; - if (cardCount % 2 === 1) result.add(i); - cardCount++; + for (let i = 0; i < timelineItems.length; i++) { + const item = timelineItems[i]; + if (item.type === 'lead-thoughts') { + // Thought groups count as one card for striping + if (cardCount % 2 === 1) result.add(i); + cardCount++; + } else { + if (isNoiseMessage(item.message.text)) continue; + if (cardCount % 2 === 1) result.add(i); + cardCount++; + } } return result; - }, [visibleMessages]); + }, [timelineItems]); - // Determine which messages are "new" (should animate). + // Determine which items are "new" (should animate). - const newMessageKeys = useMemo(() => { - const getKey = (msg: InboxMessage, idx: number): string => - `${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`; + 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}`; + } + const msg = item.message; + return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`; + }; + + const allKeys: string[] = []; + for (const item of timelineItems) { + allKeys.push(getItemKey(item)); + } // First render: seed known keys, no animations if (!isInitializedRef.current) { isInitializedRef.current = true; - for (let i = 0; i < visibleMessages.length; i++) { - knownKeysRef.current.add(getKey(visibleMessages[i], i)); + for (const key of allKeys) { + knownKeysRef.current.add(key); } prevVisibleCountRef.current = visibleCount; return new Set(); @@ -218,23 +239,22 @@ export const ActivityTimeline = ({ prevVisibleCountRef.current = visibleCount; if (isPaginationExpansion) { - for (let i = 0; i < visibleMessages.length; i++) { - knownKeysRef.current.add(getKey(visibleMessages[i], i)); + for (const key of allKeys) { + knownKeysRef.current.add(key); } return new Set(); } - // Normal update: unknown keys are new messages + // Normal update: unknown keys are new items const newKeys = new Set(); - for (let i = 0; i < visibleMessages.length; i++) { - const key = getKey(visibleMessages[i], i); + for (const key of allKeys) { if (!knownKeysRef.current.has(key)) { newKeys.add(key); knownKeysRef.current.add(key); } } return newKeys; - }, [visibleMessages, visibleCount]); + }, [timelineItems, visibleCount]); /* eslint-enable react-hooks/refs -- end animation tracking block */ const handleShowMore = (): void => { @@ -256,13 +276,29 @@ export const ActivityTimeline = ({ return (
- {visibleMessages.map((message, index) => { + {timelineItems.map((item, index) => { + 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}`; + return ( + + ); + } + + const { message } = item; const info = memberInfo.get(message.from); const recipientInfo = message.to ? memberInfo.get(message.to) : undefined; const recipientColor = recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); - const globalIndex = index; - const messageKey = `${message.messageId ?? globalIndex}-${message.timestamp}-${message.from}`; + const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`; const isUnread = readState ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) : !message.read; @@ -275,7 +311,7 @@ export const ActivityTimeline = ({ memberColor={info?.color} recipientColor={recipientColor} isUnread={isUnread} - isNew={newMessageKeys.has(messageKey)} + isNew={newItemKeys.has(messageKey)} zebraShade={zebraShadeSet.has(index)} memberColorMap={colorMap} onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined} diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx new file mode 100644 index 00000000..9402c545 --- /dev/null +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -0,0 +1,204 @@ +import { useEffect, useRef, useState } from 'react'; + +import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { + CARD_BG, + CARD_BORDER_STYLE, + CARD_ICON_MUTED, + CARD_TEXT_LIGHT, +} from '@renderer/constants/cssVariables'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { ChevronRight } from 'lucide-react'; + +import type { InboxMessage } from '@shared/types'; + +export interface LeadThoughtGroup { + type: 'lead-thoughts'; + thoughts: InboxMessage[]; +} + +/** + * Check if a message is an intermediate lead "thought" (assistant text) rather than + * an official message (SendMessage, direct reply, inbox, etc.). + */ +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; + return false; +} + +export type TimelineItem = + | { type: 'message'; message: InboxMessage; originalIndex: number } + | { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] }; + +/** + * Group consecutive lead thoughts into collapsible blocks. + * Single thoughts remain as regular messages. + */ +export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { + const result: TimelineItem[] = []; + let pendingThoughts: InboxMessage[] = []; + let pendingIndices: number[] = []; + + 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, + }); + } + pendingThoughts = []; + pendingIndices = []; + }; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (isLeadThought(msg)) { + pendingThoughts.push(msg); + pendingIndices.push(i); + } else { + flushThoughts(); + result.push({ type: 'message', message: msg, originalIndex: i }); + } + } + flushThoughts(); + return result; +} + +const VIEWPORT_THRESHOLD = 0.15; + +interface LeadThoughtsGroupRowProps { + group: LeadThoughtGroup; + memberColor?: string; + isNew?: boolean; + onVisible?: (message: InboxMessage) => void; +} + +function formatTime(timestamp: string): string { + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return timestamp; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function formatTimeWithSec(timestamp: string): string { + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return timestamp; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +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 colors = getTeamColorSet(memberColor ?? ''); + const { thoughts } = group; + const first = thoughts[0]; + const last = thoughts[thoughts.length - 1]; + const leadName = first.from; + + // 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); + } + }, + { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } + ); + observer.observe(el); + 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; + + return ( +
+
+ {/* 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); + } + }} + > + + + + {thoughts.length} thoughts + + + {formatTime(last.timestamp)}–{formatTime(first.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} + +
+ ))} +
+ )} +
+
+ ); +}; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 7ab77011..c3dba7ab 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -295,6 +295,7 @@ export const TaskDetailDialog = ({ const isLeadOwnedTask = (currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() || (currentTask.owner ?? '').trim().toLowerCase() === 'team-lead'; + const allowLeadExecutionPreview = true; return ( !v && onClose()}> @@ -690,6 +691,8 @@ export const TaskDetailDialog = ({ // For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents), // so filtering to "just the member messages" is unreliable and easy to mislead. showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask} + // Temporary debug option: for lead-owned tasks, show quick preview from lead session. + showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask} onPreviewOnlineChange={setExecutionPreviewOnline} />
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 1265b682..42591e0c 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -37,6 +37,11 @@ interface MemberLogsTabProps { onRefreshingChange?: (isRefreshing: boolean) => void; /** Show last few subagent messages as a quick "where are we?" preview (task view only). */ showSubagentPreview?: boolean; + /** + * Optional: for lead-owned tasks, show a quick preview from the lead session. + * (This is lead activity, not "member-only" activity.) + */ + showLeadPreview?: boolean; /** Notifies parent when preview looks "online" (recent output). */ onPreviewOnlineChange?: (isOnline: boolean) => void; } @@ -50,6 +55,7 @@ export const MemberLogsTab = ({ taskWorkIntervals, onRefreshingChange, showSubagentPreview = false, + showLeadPreview = false, onPreviewOnlineChange, }: MemberLogsTabProps): React.JSX.Element => { const intervalsKey = useMemo( @@ -96,23 +102,35 @@ export const MemberLogsTab = ({ return withIndex.map((x) => x.log); }, [logs]); + const shouldShowPreview = useMemo(() => { + return taskId != null && (showSubagentPreview || showLeadPreview); + }, [showLeadPreview, showSubagentPreview, taskId]); + const previewLog = useMemo((): MemberLogSummary | null => { - if (!showSubagentPreview || taskId == null) return null; + if (!shouldShowPreview) return null; - const candidates = sortedLogs.filter((l) => l.kind === 'subagent'); - if (candidates.length === 0) return null; + if (showSubagentPreview) { + const candidates = sortedLogs.filter((l) => l.kind === 'subagent'); + if (candidates.length === 0) return null; - if (taskOwner) { - const target = taskOwner.trim().toLowerCase(); - const match = candidates.find((l) => (l.memberName ?? '').trim().toLowerCase() === target); - // When viewing task logs, this preview is intended to show the assigned owner's progress. - // If we can't confidently match a subagent log to the owner, don't show anything - // rather than risk showing a different member's activity (or a lead-attributed log). - return match ?? null; + if (taskOwner) { + const target = taskOwner.trim().toLowerCase(); + const match = candidates.find((l) => (l.memberName ?? '').trim().toLowerCase() === target); + // When viewing task logs, this preview is intended to show the assigned owner's progress. + // If we can't confidently match a subagent log to the owner, don't show anything + // rather than risk showing a different member's activity. + return match ?? null; + } + + return candidates[0] ?? null; } - return candidates[0] ?? null; - }, [showSubagentPreview, sortedLogs, taskId, taskOwner]); + if (showLeadPreview) { + return sortedLogs.find((l) => l.kind === 'lead_session') ?? null; + } + + return null; + }, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]); const previewMessages = useMemo((): SubagentPreviewMessage[] => { if (!previewChunks || previewChunks.length === 0) return []; @@ -216,7 +234,7 @@ export const MemberLogsTab = ({ ); useEffect(() => { - if (!showSubagentPreview || taskId == null) { + if (!shouldShowPreview) { setPreviewChunks(null); return; } @@ -240,10 +258,10 @@ export const MemberLogsTab = ({ return () => { cancelled = true; }; - }, [fetchDetailForLog, previewLog, showSubagentPreview, taskId]); + }, [fetchDetailForLog, previewLog, shouldShowPreview]); useEffect(() => { - if (!showSubagentPreview || taskId == null) return; + if (!shouldShowPreview) return; if (!previewLog) return; const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing; @@ -264,7 +282,7 @@ export const MemberLogsTab = ({ cancelled = true; clearInterval(interval); }; - }, [fetchDetailForLog, previewLog, showSubagentPreview, taskId, taskStatus]); + }, [fetchDetailForLog, previewLog, shouldShowPreview, taskStatus]); useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; @@ -350,7 +368,7 @@ export const MemberLogsTab = ({ return (
- {showSubagentPreview && previewLog && previewMessages.length > 0 ? ( + {shouldShowPreview && previewLog && previewMessages.length > 0 ? (