agent-ecosystem/src/renderer/components/team/activity/ActivityItem.tsx
iliya 7cf1789c6a feat: implement auto-sync for task-related comments from inbox messages
- Added functionality in TeamDataService to automatically create comments from task-related inbox messages, enhancing task management.
- Introduced syncLinkedComments method to process messages and link them to corresponding tasks, ensuring relevant discussions are captured.
- Updated TeamTaskWriter to support deduplication of comments based on ID, preventing duplicate entries.
- Enhanced ActivityItem and ActivityTimeline components to improve recipient color handling for better visual feedback.

These changes aim to streamline communication around tasks and improve overall user experience in task tracking.
2026-02-23 12:18:34 +02:00

401 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 || '';
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 */}
<div
className={[
'flex items-center gap-2 px-3 py-2',
systemLabel ? 'cursor-pointer select-none' : '',
].join(' ')}
role={systemLabel ? 'button' : undefined}
tabIndex={systemLabel ? 0 : undefined}
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' ? (
<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>
) : 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>
</div>
{/* 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>
);
};