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:
parent
218189b241
commit
1326c099fb
5 changed files with 428 additions and 139 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
204
src/renderer/components/team/activity/LeadThoughtsGroup.tsx
Normal file
204
src/renderer/components/team/activity/LeadThoughtsGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue