agent-ecosystem/src/renderer/components/team/messages/MessagesPanel.tsx
iliya da4d98ec2b refactor: enhance task management protocols and notification handling
- Updated task management instructions in tasks.js to clarify the process for handling newly assigned tasks that must wait due to ongoing work, emphasizing the importance of leaving comments with reasons and estimated completion times.
- Improved member briefing messages to include critical reminders about task status and comment handling.
- Enhanced TeamDataService to implement task comment notification features, ensuring leads are notified of teammate comments on tasks.
- Refactored related UI components to support better interaction and visibility of task statuses and notifications.
2026-03-14 17:46:15 +02:00

579 lines
20 KiB
TypeScript

import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
import { useStore } from '@renderer/store';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import {
Bell,
CheckCheck,
ChevronsDownUp,
ChevronsUpDown,
MessageSquare,
PanelLeftClose,
PanelLeft,
Search,
X,
} from 'lucide-react';
import { ActivityTimeline } from '../activity/ActivityTimeline';
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
import { MessageExpandDialog } from '../activity/MessageExpandDialog';
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
import { MessageComposer } from './MessageComposer';
import { MessagesFilterPopover } from './MessagesFilterPopover';
import { StatusBlock } from './StatusBlock';
import type { MessagesFilterState } from './MessagesFilterPopover';
import type { ActionMode } from './ActionModeSelector';
import type { TimelineItem } from '../activity/LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types';
interface TimeWindow {
start: number;
end: number;
}
interface MessagesPanelProps {
teamName: string;
position: 'sidebar' | 'inline';
onTogglePosition: () => void;
/** Active (non-removed) members. */
members: ResolvedTeamMember[];
/** All team tasks. */
tasks: TeamTaskWithKanban[];
/** All raw messages from team data. */
messages: InboxMessage[];
/** Whether the team is alive. */
isTeamAlive?: boolean;
/** Live lead activity status for the current team. */
leadActivity?: string;
/** Latest lead context timestamp for the current team. */
leadContextUpdatedAt?: string;
/** Time window for filtering. */
timeWindow: TimeWindow | null;
/** Team session IDs for timeline. */
teamSessionIds: Set<string>;
/** Current lead session ID. */
currentLeadSessionId?: string;
/** Pending replies tracker (shared with parent for MemberList). */
pendingRepliesByMember: Record<string, number>;
/** Update pending replies tracker. */
onPendingReplyChange: (updater: (prev: Record<string, number>) => Record<string, number>) => void;
/** Callback when a member is clicked in the timeline. */
onMemberClick?: (member: ResolvedTeamMember) => void;
/** Callback when a task is clicked from timeline or status block. */
onTaskClick?: (task: TeamTaskWithKanban) => void;
/** Callback to open create task dialog from a message. */
onCreateTaskFromMessage?: (subject: string, description: string) => void;
/** Callback to open reply dialog for a message. */
onReplyToMessage?: (message: InboxMessage) => void;
/** Callback when "Restart team" is clicked. */
onRestartTeam?: () => void;
/** Callback when a task ID link is clicked. */
onTaskIdClick?: (taskId: string) => void;
}
export const MessagesPanel = memo(function MessagesPanel({
teamName,
position,
onTogglePosition,
members,
tasks,
messages,
isTeamAlive,
leadActivity,
leadContextUpdatedAt,
timeWindow,
teamSessionIds,
currentLeadSessionId,
pendingRepliesByMember,
onPendingReplyChange,
onMemberClick,
onTaskClick,
onCreateTaskFromMessage,
onReplyToMessage,
onRestartTeam,
onTaskIdClick,
}: MessagesPanelProps): React.JSX.Element {
const sendTeamMessage = useStore((s) => s.sendTeamMessage);
const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage);
const sendingMessage = useStore((s) => s.sendingMessage);
const sendMessageError = useStore((s) => s.sendMessageError);
const lastSendMessageResult = useStore((s) => s.lastSendMessageResult);
const teams = useStore((s) => s.teams);
const openTeamTab = useStore((s) => s.openTeamTab);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const handleExpandContent = useCallback(() => {
composerTextareaRef.current?.focus();
}, []);
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
from: new Set(),
to: new Set(),
showNoise: false,
});
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
const [messagesCollapsed, setMessagesCollapsed] = useState(true);
const [sidebarSearchVisible, setSidebarSearchVisible] = useState(false);
const [expandedItemKey, setExpandedItemKey] = useState<string | null>(null);
const filteredMessages = useMemo(() => {
return filterTeamMessages(messages, {
timeWindow,
filter: messagesFilter,
searchQuery: messagesSearchQuery,
});
}, [messages, timeWindow, messagesFilter, messagesSearchQuery]);
// Resolve the expanded item from filtered messages
const expandedItem = useMemo<TimelineItem | null>(() => {
if (!expandedItemKey) return null;
if (!expandedItemKey.startsWith('thoughts-')) {
const msg = filteredMessages.find((m) => toMessageKey(m) === expandedItemKey);
return msg ? { type: 'message', message: msg } : null;
}
const allItems = groupTimelineItems(filteredMessages);
return (
allItems.find(
(item) =>
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
) ?? null
);
}, [expandedItemKey, filteredMessages]);
// Auto-clear stale expanded key
useEffect(() => {
if (expandedItemKey && expandedItem === null) {
setExpandedItemKey(null);
}
}, [expandedItemKey, expandedItem]);
const handleExpandItem = useCallback((key: string) => {
setExpandedItemKey(key);
}, []);
const handleExpandDialogChange = useCallback((open: boolean) => {
if (!open) setExpandedItemKey(null);
}, []);
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName);
const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName);
const messagesUnreadCount = useMemo(
() => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length,
[filteredMessages, readSet]
);
const handleMessageVisible = useCallback(
(message: InboxMessage) => markRead(toMessageKey(message)),
[markRead]
);
const readState = useMemo(() => ({ readSet, getMessageKey: toMessageKey }), [readSet]);
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
const handleMarkAllRead = useCallback(() => {
const keys = filteredMessages
.filter((m) => !m.read && !readSet.has(toMessageKey(m)))
.map((m) => toMessageKey(m));
markAllRead(keys);
}, [filteredMessages, readSet, markAllRead]);
// Auto-clear pending replies when a member actually responds
useEffect(() => {
if (Object.keys(pendingRepliesByMember).length === 0) return;
const next = { ...pendingRepliesByMember };
let changed = false;
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
const hasReply = messages.some((m) => {
if (m.from !== memberName) return false;
const ts = Date.parse(m.timestamp);
return Number.isFinite(ts) && ts > sentAtMs;
});
if (hasReply) {
delete next[memberName];
changed = true;
}
}
if (changed) onPendingReplyChange(() => next);
}, [messages, pendingRepliesByMember, onPendingReplyChange]);
const handleSend = useCallback(
(
member: string,
text: string,
summary?: string,
attachments?: Parameters<typeof sendTeamMessage>[1] extends { attachments?: infer A }
? A
: never,
actionMode?: ActionMode,
taskRefs?: TaskRef[]
) => {
const sentAtMs = Date.now();
onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs }));
void sendTeamMessage(teamName, {
member,
text,
summary,
attachments,
actionMode,
taskRefs,
}).catch(() => {
onPendingReplyChange((prev) => {
if (prev[member] !== sentAtMs) return prev;
const next = { ...prev };
delete next[member];
return next;
});
});
},
[teamName, sendTeamMessage, onPendingReplyChange]
);
const handleCrossTeamSend = useCallback(
(
toTeam: string,
text: string,
summary?: string,
actionMode?: ActionMode,
taskRefs?: TaskRef[]
) => {
void sendCrossTeamMessage({
fromTeam: teamName,
fromMember: 'user',
toTeam,
text,
taskRefs,
actionMode,
summary,
});
},
[teamName, sendCrossTeamMessage]
);
// ---- Shared content (used in both modes) ----
const searchAndFilterControls = (
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<input
type="text"
placeholder="Search..."
value={messagesSearchQuery}
onChange={(e) => setMessagesSearchQuery(e.target.value)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/>
{messagesSearchQuery && (
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setMessagesSearchQuery('')}
>
<X size={14} />
</button>
)}
</div>
<MessagesFilterPopover
filter={messagesFilter}
messages={messages}
open={messagesFilterOpen}
onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter}
/>
</div>
);
const searchAndFilterBar = (
<div className="flex items-center gap-2">
{searchAndFilterControls}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
setMessagesCollapsed((v) => !v);
}}
>
{messagesCollapsed ? <ChevronsUpDown size={14} /> : <ChevronsDownUp size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
</TooltipContent>
</Tooltip>
</div>
);
const messagesContent = (
<>
<MessageComposer
teamName={teamName}
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
<StatusBlock
members={members}
tasks={tasks}
messages={messages}
pendingRepliesByMember={pendingRepliesByMember}
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
<ActivityTimeline
messages={filteredMessages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
teamSessionIds={teamSessionIds}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
/>
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItemKey !== null}
onOpenChange={handleExpandDialogChange}
teamName={teamName}
members={members}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMemberClick={onMemberClick}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
/>
</>
);
// ---- Sidebar mode ----
if (position === 'sidebar') {
return (
<div className="flex size-full flex-col overflow-hidden bg-[var(--color-surface)]">
{/* Header */}
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-section-bg)] px-3 py-2">
<MessageSquare size={14} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="text-sm font-medium text-[var(--color-text)]">Messages</span>
{filteredMessages.length > 0 && (
<Badge
variant="secondary"
className="px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
{filteredMessages.length}
</Badge>
)}
{messagesUnreadCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className="bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-normal leading-none text-blue-600 dark:text-blue-400"
>
{messagesUnreadCount} new
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom">{messagesUnreadCount} unread</TooltipContent>
</Tooltip>
)}
{messagesUnreadCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
onClick={handleMarkAllRead}
>
<CheckCheck size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Mark all as read</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => {
void window.electronAPI.openExternal(
'https://github.com/777genius/claude-notifications-go'
);
}}
>
<Bell size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
</Tooltip>
<div className="ml-auto flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setMessagesCollapsed((v) => !v)}
>
{messagesCollapsed ? <ChevronsUpDown size={14} /> : <ChevronsDownUp size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setSidebarSearchVisible((v) => !v)}
>
{sidebarSearchVisible ? <X size={14} /> : <Search size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{sidebarSearchVisible ? 'Hide search' : 'Search messages'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={onTogglePosition}
>
<PanelLeftClose size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Move to inline</TooltipContent>
</Tooltip>
</div>
</div>
{/* Search & filter bar (toggleable) */}
{sidebarSearchVisible && (
<div className="shrink-0 border-b border-[var(--color-border)] px-3 py-1.5">
{searchAndFilterControls}
</div>
)}
{/* Scrollable content */}
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-2">
{messagesContent}
</div>
</div>
);
}
// ---- Inline mode (wrapped in CollapsibleTeamSection) ----
return (
<CollapsibleTeamSection
sectionId="messages"
title="Messages"
icon={<MessageSquare size={14} />}
badge={filteredMessages.length}
secondaryBadge={
filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined
}
afterBadge={
messagesUnreadCount > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
onClick={(e) => {
e.stopPropagation();
handleMarkAllRead();
}}
>
<CheckCheck size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Mark all as read</TooltipContent>
</Tooltip>
) : undefined
}
headerExtra={
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
void window.electronAPI.openExternal(
'https://github.com/777genius/claude-notifications-go'
);
}}
>
<Bell size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
onTogglePosition();
}}
>
<PanelLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Move to sidebar</TooltipContent>
</Tooltip>
</>
}
defaultOpen
action={<div className="flex items-center gap-2 pl-2 pr-2">{searchAndFilterBar}</div>}
>
{messagesContent}
</CollapsibleTeamSection>
);
});