- 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.
401 lines
14 KiB
TypeScript
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 }}>→ </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 }}>→ </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>
|
|
);
|
|
};
|