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.
|
* Flushed to liveLeadProcessMessages on result.success.
|
||||||
*/
|
*/
|
||||||
directReplyParts: string[];
|
directReplyParts: string[];
|
||||||
/** Whether we already emitted live lead text during the current turn (before result). */
|
/** Monotonic counter for stream-json turns (incremented on result). */
|
||||||
leadTextPushedInCurrentTurn: boolean;
|
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. */
|
/** Throttle timestamp for emitting inbox refresh events for lead text. */
|
||||||
lastLeadTextEmitMs: number;
|
lastLeadTextEmitMs: number;
|
||||||
/**
|
/**
|
||||||
|
|
@ -1751,7 +1753,8 @@ export class TeamProvisioningService {
|
||||||
fsPhase: 'waiting_config',
|
fsPhase: 'waiting_config',
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
directReplyParts: [],
|
directReplyParts: [],
|
||||||
leadTextPushedInCurrentTurn: false,
|
leadTurnSeq: 0,
|
||||||
|
leadTurnMessageTimestamp: null,
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
silentUserDmForwardClearHandle: null,
|
silentUserDmForwardClearHandle: null,
|
||||||
|
|
@ -2051,7 +2054,8 @@ export class TeamProvisioningService {
|
||||||
fsPhase: 'waiting_members',
|
fsPhase: 'waiting_members',
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
directReplyParts: [],
|
directReplyParts: [],
|
||||||
leadTextPushedInCurrentTurn: false,
|
leadTurnSeq: 0,
|
||||||
|
leadTurnMessageTimestamp: null,
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
silentUserDmForwardClearHandle: null,
|
silentUserDmForwardClearHandle: null,
|
||||||
|
|
@ -2795,13 +2799,36 @@ export class TeamProvisioningService {
|
||||||
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
||||||
const MAX = 100;
|
const MAX = 100;
|
||||||
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
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) {
|
if (list.length > MAX) {
|
||||||
list.splice(0, list.length - MAX);
|
list.splice(0, list.length - MAX);
|
||||||
}
|
}
|
||||||
this.liveLeadProcessMessages.set(teamName, list);
|
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.
|
* 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;
|
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 ?? [])
|
const textParts = (content ?? [])
|
||||||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||||
.map((part) => part.text as string);
|
.map((part) => part.text as string);
|
||||||
|
|
@ -2859,41 +2894,6 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
|
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.
|
// During provisioning (before provisioningComplete), accumulate for live UI preview.
|
||||||
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
|
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
|
||||||
if (!run.provisioningComplete) {
|
if (!run.provisioningComplete) {
|
||||||
|
|
@ -2913,9 +2913,52 @@ export class TeamProvisioningService {
|
||||||
}, capture.idleMs);
|
}, capture.idleMs);
|
||||||
}
|
}
|
||||||
} else if (run.provisioningComplete) {
|
} else if (run.provisioningComplete) {
|
||||||
// Accumulate assistant text for direct user→lead messages (no relay capture).
|
// Accumulate assistant text for a single "live lead turn" message in Messages.
|
||||||
if (!run.silentUserDmForward) {
|
// 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);
|
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();
|
const combined = capture.textParts.join('').trim();
|
||||||
capture.resolveOnce(combined);
|
capture.resolveOnce(combined);
|
||||||
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
|
} 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();
|
const rawReply = run.directReplyParts.join('').trim();
|
||||||
run.directReplyParts = [];
|
run.directReplyParts = [];
|
||||||
if (!run.leadTextPushedInCurrentTurn) {
|
const leadName =
|
||||||
const leadName =
|
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
'team-lead';
|
||||||
'team-lead';
|
const replyText = stripAgentBlocks(rawReply).trim();
|
||||||
// Strip agent-only blocks — lead may include coordination content not meant for the user
|
const finalText =
|
||||||
const replyText = stripAgentBlocks(rawReply);
|
replyText.length > 0
|
||||||
if (replyText.length > 0) {
|
? replyText
|
||||||
const replyMsg: InboxMessage = {
|
: rawReply.length > 0
|
||||||
from: leadName,
|
? '(Message received and processed)'
|
||||||
to: 'user',
|
: '';
|
||||||
text: replyText,
|
if (finalText.length > 0) {
|
||||||
timestamp: nowIso(),
|
if (!run.leadTurnMessageTimestamp) {
|
||||||
read: true,
|
run.leadTurnMessageTimestamp = nowIso();
|
||||||
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 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.
|
// Turn boundary: advance lead turn sequence.
|
||||||
run.leadTextPushedInCurrentTurn = false;
|
if (run.provisioningComplete) {
|
||||||
|
run.leadTurnSeq += 1;
|
||||||
|
run.leadTurnMessageTimestamp = null;
|
||||||
|
run.directReplyParts = [];
|
||||||
|
}
|
||||||
// Clear silent relay flag after any successful turn.
|
// Clear silent relay flag after any successful turn.
|
||||||
run.silentUserDmForward = null;
|
run.silentUserDmForward = null;
|
||||||
if (run.silentUserDmForwardClearHandle) {
|
if (run.silentUserDmForwardClearHandle) {
|
||||||
|
|
@ -3134,8 +3158,12 @@ export class TeamProvisioningService {
|
||||||
if (run.leadRelayCapture) {
|
if (run.leadRelayCapture) {
|
||||||
run.leadRelayCapture.rejectOnce(errorMsg);
|
run.leadRelayCapture.rejectOnce(errorMsg);
|
||||||
}
|
}
|
||||||
// Turn boundary: reset per-turn lead text tracking.
|
// Turn boundary: advance lead turn sequence.
|
||||||
run.leadTextPushedInCurrentTurn = false;
|
if (run.provisioningComplete) {
|
||||||
|
run.leadTurnSeq += 1;
|
||||||
|
run.leadTurnMessageTimestamp = null;
|
||||||
|
run.directReplyParts = [];
|
||||||
|
}
|
||||||
// Clear silent relay flag after any errored turn.
|
// Clear silent relay flag after any errored turn.
|
||||||
run.silentUserDmForward = null;
|
run.silentUserDmForward = null;
|
||||||
if (run.silentUserDmForwardClearHandle) {
|
if (run.silentUserDmForwardClearHandle) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { parseStructuredAgentMessage } from '@renderer/utils/agentMessageFormatting';
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||||
|
import { groupTimelineItems, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
||||||
|
|
||||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||||
|
import type { TimelineItem } from './LeadThoughtsGroup';
|
||||||
|
|
||||||
interface ActivityTimelineProps {
|
interface ActivityTimelineProps {
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
|
|
@ -185,29 +186,49 @@ export const ActivityTimeline = ({
|
||||||
[messages, visibleCount, hiddenCount]
|
[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 zebraShadeSet = useMemo(() => {
|
||||||
const result = new Set<number>();
|
const result = new Set<number>();
|
||||||
let cardCount = 0;
|
let cardCount = 0;
|
||||||
for (let i = 0; i < visibleMessages.length; i++) {
|
for (let i = 0; i < timelineItems.length; i++) {
|
||||||
if (isNoiseMessage(visibleMessages[i].text)) continue;
|
const item = timelineItems[i];
|
||||||
if (cardCount % 2 === 1) result.add(i);
|
if (item.type === 'lead-thoughts') {
|
||||||
cardCount++;
|
// 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;
|
return result;
|
||||||
}, [visibleMessages]);
|
}, [timelineItems]);
|
||||||
|
|
||||||
// Determine which messages are "new" (should animate).
|
// Determine which items are "new" (should animate).
|
||||||
|
|
||||||
const newMessageKeys = useMemo(() => {
|
const newItemKeys = useMemo(() => {
|
||||||
const getKey = (msg: InboxMessage, idx: number): string =>
|
const getItemKey = (item: TimelineItem): string => {
|
||||||
`${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`;
|
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
|
// First render: seed known keys, no animations
|
||||||
if (!isInitializedRef.current) {
|
if (!isInitializedRef.current) {
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
for (let i = 0; i < visibleMessages.length; i++) {
|
for (const key of allKeys) {
|
||||||
knownKeysRef.current.add(getKey(visibleMessages[i], i));
|
knownKeysRef.current.add(key);
|
||||||
}
|
}
|
||||||
prevVisibleCountRef.current = visibleCount;
|
prevVisibleCountRef.current = visibleCount;
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
|
|
@ -218,23 +239,22 @@ export const ActivityTimeline = ({
|
||||||
prevVisibleCountRef.current = visibleCount;
|
prevVisibleCountRef.current = visibleCount;
|
||||||
|
|
||||||
if (isPaginationExpansion) {
|
if (isPaginationExpansion) {
|
||||||
for (let i = 0; i < visibleMessages.length; i++) {
|
for (const key of allKeys) {
|
||||||
knownKeysRef.current.add(getKey(visibleMessages[i], i));
|
knownKeysRef.current.add(key);
|
||||||
}
|
}
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal update: unknown keys are new messages
|
// Normal update: unknown keys are new items
|
||||||
const newKeys = new Set<string>();
|
const newKeys = new Set<string>();
|
||||||
for (let i = 0; i < visibleMessages.length; i++) {
|
for (const key of allKeys) {
|
||||||
const key = getKey(visibleMessages[i], i);
|
|
||||||
if (!knownKeysRef.current.has(key)) {
|
if (!knownKeysRef.current.has(key)) {
|
||||||
newKeys.add(key);
|
newKeys.add(key);
|
||||||
knownKeysRef.current.add(key);
|
knownKeysRef.current.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newKeys;
|
return newKeys;
|
||||||
}, [visibleMessages, visibleCount]);
|
}, [timelineItems, visibleCount]);
|
||||||
/* eslint-enable react-hooks/refs -- end animation tracking block */
|
/* eslint-enable react-hooks/refs -- end animation tracking block */
|
||||||
|
|
||||||
const handleShowMore = (): void => {
|
const handleShowMore = (): void => {
|
||||||
|
|
@ -256,13 +276,29 @@ export const ActivityTimeline = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<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 info = memberInfo.get(message.from);
|
||||||
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
||||||
const recipientColor =
|
const recipientColor =
|
||||||
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
||||||
const globalIndex = index;
|
const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`;
|
||||||
const messageKey = `${message.messageId ?? globalIndex}-${message.timestamp}-${message.from}`;
|
|
||||||
const isUnread = readState
|
const isUnread = readState
|
||||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||||
: !message.read;
|
: !message.read;
|
||||||
|
|
@ -275,7 +311,7 @@ export const ActivityTimeline = ({
|
||||||
memberColor={info?.color}
|
memberColor={info?.color}
|
||||||
recipientColor={recipientColor}
|
recipientColor={recipientColor}
|
||||||
isUnread={isUnread}
|
isUnread={isUnread}
|
||||||
isNew={newMessageKeys.has(messageKey)}
|
isNew={newItemKeys.has(messageKey)}
|
||||||
zebraShade={zebraShadeSet.has(index)}
|
zebraShade={zebraShadeSet.has(index)}
|
||||||
memberColorMap={colorMap}
|
memberColorMap={colorMap}
|
||||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
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 =
|
const isLeadOwnedTask =
|
||||||
(currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() ||
|
(currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() ||
|
||||||
(currentTask.owner ?? '').trim().toLowerCase() === 'team-lead';
|
(currentTask.owner ?? '').trim().toLowerCase() === 'team-lead';
|
||||||
|
const allowLeadExecutionPreview = true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<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),
|
// 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.
|
// so filtering to "just the member messages" is unreliable and easy to mislead.
|
||||||
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
|
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
|
||||||
|
// Temporary debug option: for lead-owned tasks, show quick preview from lead session.
|
||||||
|
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
|
||||||
onPreviewOnlineChange={setExecutionPreviewOnline}
|
onPreviewOnlineChange={setExecutionPreviewOnline}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ interface MemberLogsTabProps {
|
||||||
onRefreshingChange?: (isRefreshing: boolean) => void;
|
onRefreshingChange?: (isRefreshing: boolean) => void;
|
||||||
/** Show last few subagent messages as a quick "where are we?" preview (task view only). */
|
/** Show last few subagent messages as a quick "where are we?" preview (task view only). */
|
||||||
showSubagentPreview?: boolean;
|
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). */
|
/** Notifies parent when preview looks "online" (recent output). */
|
||||||
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +55,7 @@ export const MemberLogsTab = ({
|
||||||
taskWorkIntervals,
|
taskWorkIntervals,
|
||||||
onRefreshingChange,
|
onRefreshingChange,
|
||||||
showSubagentPreview = false,
|
showSubagentPreview = false,
|
||||||
|
showLeadPreview = false,
|
||||||
onPreviewOnlineChange,
|
onPreviewOnlineChange,
|
||||||
}: MemberLogsTabProps): React.JSX.Element => {
|
}: MemberLogsTabProps): React.JSX.Element => {
|
||||||
const intervalsKey = useMemo(
|
const intervalsKey = useMemo(
|
||||||
|
|
@ -96,23 +102,35 @@ export const MemberLogsTab = ({
|
||||||
return withIndex.map((x) => x.log);
|
return withIndex.map((x) => x.log);
|
||||||
}, [logs]);
|
}, [logs]);
|
||||||
|
|
||||||
|
const shouldShowPreview = useMemo(() => {
|
||||||
|
return taskId != null && (showSubagentPreview || showLeadPreview);
|
||||||
|
}, [showLeadPreview, showSubagentPreview, taskId]);
|
||||||
|
|
||||||
const previewLog = useMemo((): MemberLogSummary | null => {
|
const previewLog = useMemo((): MemberLogSummary | null => {
|
||||||
if (!showSubagentPreview || taskId == null) return null;
|
if (!shouldShowPreview) return null;
|
||||||
|
|
||||||
const candidates = sortedLogs.filter((l) => l.kind === 'subagent');
|
if (showSubagentPreview) {
|
||||||
if (candidates.length === 0) return null;
|
const candidates = sortedLogs.filter((l) => l.kind === 'subagent');
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
if (taskOwner) {
|
if (taskOwner) {
|
||||||
const target = taskOwner.trim().toLowerCase();
|
const target = taskOwner.trim().toLowerCase();
|
||||||
const match = candidates.find((l) => (l.memberName ?? '').trim().toLowerCase() === target);
|
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.
|
// 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
|
// 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).
|
// rather than risk showing a different member's activity.
|
||||||
return match ?? null;
|
return match ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates[0] ?? null;
|
if (showLeadPreview) {
|
||||||
}, [showSubagentPreview, sortedLogs, taskId, taskOwner]);
|
return sortedLogs.find((l) => l.kind === 'lead_session') ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]);
|
||||||
|
|
||||||
const previewMessages = useMemo((): SubagentPreviewMessage[] => {
|
const previewMessages = useMemo((): SubagentPreviewMessage[] => {
|
||||||
if (!previewChunks || previewChunks.length === 0) return [];
|
if (!previewChunks || previewChunks.length === 0) return [];
|
||||||
|
|
@ -216,7 +234,7 @@ export const MemberLogsTab = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSubagentPreview || taskId == null) {
|
if (!shouldShowPreview) {
|
||||||
setPreviewChunks(null);
|
setPreviewChunks(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -240,10 +258,10 @@ export const MemberLogsTab = ({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [fetchDetailForLog, previewLog, showSubagentPreview, taskId]);
|
}, [fetchDetailForLog, previewLog, shouldShowPreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSubagentPreview || taskId == null) return;
|
if (!shouldShowPreview) return;
|
||||||
if (!previewLog) return;
|
if (!previewLog) return;
|
||||||
|
|
||||||
const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing;
|
const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing;
|
||||||
|
|
@ -264,7 +282,7 @@ export const MemberLogsTab = ({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [fetchDetailForLog, previewLog, showSubagentPreview, taskId, taskStatus]);
|
}, [fetchDetailForLog, previewLog, shouldShowPreview, taskStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||||
|
|
@ -350,7 +368,7 @@ export const MemberLogsTab = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-w-0 space-y-1.5">
|
<div className="w-full min-w-0 space-y-1.5">
|
||||||
{showSubagentPreview && previewLog && previewMessages.length > 0 ? (
|
{shouldShowPreview && previewLog && previewMessages.length > 0 ? (
|
||||||
<SubagentRecentMessagesPreview
|
<SubagentRecentMessagesPreview
|
||||||
messages={previewMessages}
|
messages={previewMessages}
|
||||||
memberName={previewLog.memberName ?? undefined}
|
memberName={previewLog.memberName ?? undefined}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue