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