agent-ecosystem/src/renderer/components/team/activity/ActivityItem.tsx
iliya a6eabc840c feat: enhance team message handling and UI components
- Updated `dev:kill` script to use a dedicated Node.js script for improved process termination.
- Enhanced `TeamProvisioningService` to trigger team refresh events for live lead replies, improving message handling.
- Refactored message deduplication logic in `handleGetData` to prevent duplicate messages from lead sessions and lead processes.
- Introduced `validateOpenPathUserSelected` function to allow user-selected paths while enforcing security checks.
- Improved UI components in `TeamListView` and `ActivityItem` for better user experience and accessibility.
- Added progress bar for task completion in `DashboardView`, enhancing task tracking visibility.
2026-02-23 17:34:30 +02:00

405 lines
14 KiB
TypeScript

import { useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import {
CARD_BG,
CARD_BORDER_STYLE,
CARD_ICON_MUTED,
CARD_TEXT_LIGHT,
} from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import {
getMessageTypeLabel,
getStructuredMessageSummary,
parseMessageReply,
parseStructuredAgentMessage,
} from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
import { Bot, ChevronRight, ListPlus, MessageSquare, Reply } from 'lucide-react';
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
import type { TeamColorSet } from '@renderer/constants/teamColors';
import type { InboxMessage } from '@shared/types';
type StructuredMessage = Record<string, unknown>;
interface ActivityItemProps {
message: InboxMessage;
memberRole?: string;
memberColor?: string;
recipientColor?: string;
onMemberNameClick?: (memberName: string) => void;
onCreateTask?: (subject: string, description: string) => void;
onReply?: (message: InboxMessage) => void;
}
function getStringField(obj: StructuredMessage, key: string): string | null {
const value = obj[key];
return typeof value === 'string' && value.trim() !== '' ? value : null;
}
function getNoiseLabel(parsed: StructuredMessage): string | null {
const type = getStringField(parsed, 'type');
if (type === 'idle_notification') {
const reason = getStringField(parsed, 'idleReason');
return reason ? `Idle (${reason})` : 'Idle';
}
if (type === 'shutdown_response') {
return parsed.approve === true ? 'Shut down' : 'Rejected shutdown';
}
if (type === 'shutdown_request') {
return 'Shutdown requested';
}
if (type === 'shutdown_approved' || type === 'teammate_terminated') {
return type === 'shutdown_approved' ? 'Shutdown confirmed' : 'Terminated';
}
if (type === 'task_completed') {
const rawTaskId = parsed.taskId;
const taskId =
typeof rawTaskId === 'string' || typeof rawTaskId === 'number' ? rawTaskId : null;
return taskId !== null ? `Completed task #${taskId}` : 'Completed a task';
}
return null;
}
// ---------------------------------------------------------------------------
// Compact noise row (idle, shutdown, terminated) — minimal dot + name + label
// ---------------------------------------------------------------------------
const NoiseRow = ({
name,
label,
colors,
}: {
name: string;
label: string;
colors: TeamColorSet;
}): React.JSX.Element => (
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}>
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: colors.border }} />
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{name}
</span>
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{label}
</span>
</div>
);
// ---------------------------------------------------------------------------
// Detect system/automated messages that should be collapsed by default.
// These are generated by teamctl.js and contain tool instructions, not
// human-written content, so showing them expanded adds visual noise.
// ---------------------------------------------------------------------------
const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [
{ pattern: /^New task assigned to you:/, label: 'Task assignment' },
{ pattern: /^Task #\d+\s+approved/, label: 'Task approved' },
{ pattern: /^Task #\d+\s+needs fixes/, label: 'Review changes requested' },
];
function getSystemMessageLabel(text: string): string | null {
for (const { pattern, label } of SYSTEM_MESSAGE_PATTERNS) {
if (pattern.test(text)) return label;
}
return null;
}
/** Strip ```info_for_agent ... ``` blocks from text for UI display. */
function stripAgentBlocks(text: string): string {
return text.replace(createAgentBlockRegex(), '').trim();
}
// ---------------------------------------------------------------------------
// Full message card — left colored border, name badge, collapsible content
// ---------------------------------------------------------------------------
export const ActivityItem = ({
message,
memberRole,
memberColor,
recipientColor,
onMemberNameClick,
onCreateTask,
onReply,
}: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const recipientColors = message.to && recipientColor ? getTeamColorSet(recipientColor) : null;
const formattedRole = formatAgentRole(memberRole);
const timestamp = Number.isNaN(Date.parse(message.timestamp))
? message.timestamp
: new Date(message.timestamp).toLocaleString();
const structured = parseStructuredAgentMessage(message.text);
const noiseLabel = structured ? getNoiseLabel(structured) : null;
// System/automated messages start collapsed
const systemLabel = !structured ? getSystemMessageLabel(message.text) : null;
const [isExpanded, setIsExpanded] = useState(!systemLabel);
// Strip agent-only blocks from displayed text
const displayText = useMemo(
() => (structured ? null : stripAgentBlocks(message.text)),
[structured, message.text]
);
// Check if this is a reply message
const parsedReply = useMemo(
() => (displayText ? parseMessageReply(displayText) : null),
[displayText]
);
// Noise messages: minimal inline row
if (noiseLabel) {
return <NoiseRow name={message.from} label={noiseLabel} colors={colors} />;
}
const messageType =
structured && typeof structured.type === 'string' ? getMessageTypeLabel(structured.type) : null;
const autoSummary = structured ? getStructuredMessageSummary(structured) : null;
const handleCreateTask = (): void => {
const subject = message.summary || autoSummary || `Task from ${message.from}`;
const plainText = structured ? JSON.stringify(structured, null, 2) : message.text;
const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000);
onCreateTask?.(subject, description);
};
const summaryText = message.summary || autoSummary || '';
const HeaderTag = systemLabel ? 'button' : 'div';
return (
<article
className="group overflow-hidden rounded-md"
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
}}
>
{/* Header — clickable when system message to toggle expand */}
<HeaderTag
type={systemLabel ? 'button' : undefined}
className={[
'flex items-center gap-2 px-3 py-2',
systemLabel ? 'w-full cursor-pointer select-none border-0 bg-transparent text-left' : '',
].join(' ')}
onClick={systemLabel ? () => setIsExpanded((v) => !v) : undefined}
onKeyDown={
systemLabel
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded((v) => !v);
}
}
: undefined
}
>
{/* Chevron for collapsible system messages */}
{systemLabel ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
color: CARD_ICON_MUTED,
transform: isExpanded ? 'rotate(90deg)' : undefined,
}}
/>
) : null}
{message.source === 'lead_session' || message.source === 'lead_process' ? (
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
) : (
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
)}
{/* Name badge — clickable to open member popup */}
{onMemberNameClick ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.from);
}}
>
{message.from}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{message.from}
</span>
)}
{/* Role */}
{formattedRole ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formattedRole}
</span>
) : null}
{/* Message type label or system label */}
{systemLabel ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
{systemLabel}
</span>
) : messageType ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
{messageType}
</span>
) : null}
{/* Lead session marker */}
{message.source === 'lead_session' ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
session
</span>
) : message.source === 'lead_process' ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
live
</span>
) : null}
{/* Recipient — badge like sender, clickable to open member popup */}
{message.to && message.to !== message.from && recipientColors ? (
<span className="text-[10px]">
<span style={{ color: CARD_ICON_MUTED }}>&rarr; </span>
{onMemberNameClick ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{
backgroundColor: recipientColors.badge,
color: recipientColors.text,
border: `1px solid ${recipientColors.border}40`,
}}
onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.to!);
}}
>
{message.to}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: recipientColors.badge,
color: recipientColors.text,
border: `1px solid ${recipientColors.border}40`,
}}
>
{message.to}
</span>
)}
</span>
) : message.to && message.to !== message.from ? (
<span className="text-[10px]">
<span style={{ color: CARD_ICON_MUTED }}>&rarr; </span>
{onMemberNameClick ? (
<button
type="button"
className="rounded px-0.5 py-0 font-medium transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.to!);
}}
>
{message.to}
</button>
) : (
<span style={{ color: CARD_ICON_MUTED }}>{message.to}</span>
)}
</span>
) : null}
{/* Summary */}
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText}
</span>
{/* Timestamp + reply + create task */}
<div className="flex shrink-0 items-center gap-1.5">
{onReply && (
<button
type="button"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
title="Reply to message"
onClick={(e) => {
e.stopPropagation();
onReply(message);
}}
>
<Reply size={14} />
</button>
)}
{onCreateTask && (
<button
type="button"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
title="Create task from message"
onClick={(e) => {
e.stopPropagation();
handleCreateTask();
}}
>
<ListPlus size={14} />
</button>
)}
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
</HeaderTag>
{/* Content — collapsed for system messages, expanded for others */}
{isExpanded ? (
<div className="px-3 pb-3">
{structured ? (
<div className="space-y-2">
{autoSummary && autoSummary !== messageType ? (
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
) : null}
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
Raw JSON
</summary>
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{JSON.stringify(structured, null, 2)}
</pre>
</details>
</div>
) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} />
) : (
<MarkdownViewer content={displayText ?? message.text} maxHeight="max-h-56" copyable />
)}
</div>
) : null}
</article>
);
};