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.
This commit is contained in:
iliya 2026-03-05 21:18:24 +02:00
parent 218189b241
commit 1326c099fb
5 changed files with 428 additions and 139 deletions

View file

@ -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<string, unknown>[]) : 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<string, unknown>).input;
if (!input || typeof input !== 'object') return false;
return (input as Record<string, unknown>).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) {

View file

@ -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<number>();
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<string>();
@ -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<string>();
}
// Normal update: unknown keys are new messages
// Normal update: unknown keys are new items
const newKeys = new Set<string>();
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 (
<div className="space-y-1">
{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 (
<LeadThoughtsGroupRow
key={itemKey}
group={group}
memberColor={info?.color}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
/>
);
}
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}

View file

@ -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<HTMLDivElement>(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 (
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
<article
className="group rounded-md [overflow:clip]"
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
opacity: 0.75,
}}
>
{/* Header — click to expand/collapse */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below */}
<div
role="button"
tabIndex={0}
className="flex cursor-pointer select-none items-center gap-2 px-3 py-1.5 hover:bg-[rgba(255,255,255,0.02)]"
onClick={() => setExpanded((v) => !v)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
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 />
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{thoughts.length} thoughts
</span>
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formatTime(last.timestamp)}{formatTime(first.timestamp)}
</span>
{!expanded && (
<span className="flex-1 truncate text-[11px]" style={{ color: CARD_TEXT_LIGHT }}>
{previewTruncated}
</span>
)}
</div>
{/* Expanded: all thoughts as compact timestamped lines */}
{expanded && (
<div
className="space-y-px border-t px-3 py-1.5"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
{thoughts.map((thought, idx) => (
<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 }}>
{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>
</div>
);
};

View file

@ -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 (
<Dialog open={open} onOpenChange={(v) => !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}
/>
</div>

View file

@ -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 (
<div className="w-full min-w-0 space-y-1.5">
{showSubagentPreview && previewLog && previewMessages.length > 0 ? (
{shouldShowPreview && previewLog && previewMessages.length > 0 ? (
<SubagentRecentMessagesPreview
messages={previewMessages}
memberName={previewLog.memberName ?? undefined}