From e92d4658f41d24400e7501f8a3a4189f182e8caf Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 14 Mar 2026 14:21:11 +0200 Subject: [PATCH] refactor: enhance ActivityItem and ActivityTimeline components for expanded message functionality - Added support for expanding messages into a fullscreen dialog in ActivityItem and ActivityTimeline components. - Introduced new props `onExpand` and `expandItemKey` to facilitate the expansion feature. - Updated rendering logic to conditionally display expand buttons and handle user interactions for expanded views. - Refactored related components to ensure consistent behavior and improved user experience when interacting with messages. --- .../components/team/activity/ActivityItem.tsx | 36 ++- .../team/activity/ActivityTimeline.tsx | 72 +++--- .../team/activity/LeadThoughtsGroup.tsx | 154 +++++-------- .../team/activity/MessageExpandDialog.tsx | 208 ++++++++++++++++++ .../team/activity/ThoughtBodyContent.tsx | 153 +++++++++++++ .../team/activity/activityMessageContext.ts | 73 ++++++ .../team/dialogs/TaskDetailDialog.tsx | 22 +- .../team/messages/MessagesPanel.tsx | 51 +++++ 8 files changed, 600 insertions(+), 169 deletions(-) create mode 100644 src/renderer/components/team/activity/MessageExpandDialog.tsx create mode 100644 src/renderer/components/team/activity/ThoughtBodyContent.tsx create mode 100644 src/renderer/components/team/activity/activityMessageContext.ts diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 46f23f9c..4a77cf25 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -24,6 +24,7 @@ import { } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { cn } from '@renderer/lib/utils'; import { areInboxMessagesEquivalentForRender, areStringArraysEqual, @@ -41,7 +42,7 @@ import { import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; -import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react'; +import { AlertTriangle, ChevronRight, ListPlus, Maximize2, RefreshCw, Reply } from 'lucide-react'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; @@ -171,6 +172,10 @@ interface ActivityItemProps { onToggleCollapse?: (key: string) => void; /** Compact header mode for narrow message lists. */ compactHeader?: boolean; + /** Callback to expand this item into a fullscreen dialog. */ + onExpand?: (key: string) => void; + /** Stable key for expand identification. */ + expandItemKey?: string; } function areMessagesEquivalentForActivityItem(prev: InboxMessage, next: InboxMessage): boolean { @@ -352,6 +357,8 @@ export const ActivityItem = memo( collapseToggleKey, onToggleCollapse, compactHeader = false, + onExpand, + expandItemKey, }: ActivityItemProps): React.JSX.Element { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const { isLight } = useTheme(); @@ -683,10 +690,31 @@ export const ActivityItem = memo( {/* Timestamp */} -
- +
+ {timestamp} + {onExpand && expandItemKey && ( + + )}
@@ -847,5 +875,7 @@ export const ActivityItem = memo( prev.collapseToggleKey === next.collapseToggleKey && prev.onToggleCollapse === next.onToggleCollapse && prev.compactHeader === next.compactHeader && + prev.onExpand === next.onExpand && + prev.expandItemKey === next.expandItemKey && areMessagesEquivalentForActivityItem(prev.message, next.message) ); diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 505b9f2b..434b1b3c 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { areInboxMessagesEquivalentForRender, areStringArraysEqual, @@ -9,6 +8,8 @@ import { import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { Layers } from 'lucide-react'; +import { buildMessageContext, resolveMessageRenderProps } from './activityMessageContext'; + import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { AnimatedHeightReveal } from './AnimatedHeightReveal'; import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState'; @@ -68,13 +69,13 @@ interface ActivityTimelineProps { teamColorByName?: ReadonlyMap; /** Opens a team tab from cross-team badges or team:// links. */ onTeamClick?: (teamName: string) => void; + /** Callback to expand a message/thought item into a fullscreen dialog. */ + onExpandItem?: (key: string) => void; } const VIEWPORT_THRESHOLD = 0.15; const MESSAGES_PAGE_SIZE = 30; const COMPACT_MESSAGES_WIDTH_PX = 400; -const EMPTY_MEMBER_COLOR_MAP = new Map(); -const EMPTY_LOCAL_MEMBER_NAMES = new Set(); const EMPTY_TEAM_NAMES: string[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const DEFAULT_COLLAPSE_MODE = 'default' as const; @@ -135,6 +136,8 @@ const MessageRowWithObserver = ({ teamNames, teamColorByName, onTeamClick, + onExpand, + expandItemKey, }: { message: InboxMessage; teamName: string; @@ -161,6 +164,8 @@ const MessageRowWithObserver = ({ teamNames?: string[]; teamColorByName?: ReadonlyMap; onTeamClick?: (teamName: string) => void; + onExpand?: (key: string) => void; + expandItemKey?: string; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -218,6 +223,8 @@ const MessageRowWithObserver = ({ teamNames={teamNames} teamColorByName={teamColorByName} onTeamClick={onTeamClick} + onExpand={onExpand} + expandItemKey={expandItemKey} /> ); @@ -250,6 +257,8 @@ const MemoizedMessageRowWithObserver = React.memo( areStringArraysEqual(prev.teamNames, next.teamNames) && areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && prev.onTeamClick === next.onTeamClick && + prev.onExpand === next.onExpand && + prev.expandItemKey === next.expandItemKey && areInboxMessagesEquivalentForRender(prev.message, next.message) ); @@ -275,6 +284,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ teamNames = EMPTY_TEAM_NAMES, teamColorByName = EMPTY_TEAM_COLOR_MAP, onTeamClick, + onExpandItem, }: ActivityTimelineProps): React.JSX.Element { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const rootRef = useRef(null); @@ -303,43 +313,8 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ return () => observer.disconnect(); }, []); - const colorMap = useMemo( - () => (members ? buildMemberColorMap(members) : EMPTY_MEMBER_COLOR_MAP), - [members] - ); - const localMemberNames = useMemo( - () => - members ? new Set(members.map((member) => member.name.trim())) : EMPTY_LOCAL_MEMBER_NAMES, - [members] - ); - const memberInfo = useMemo(() => { - const infoMap = new Map(); - if (!members) return infoMap; - - for (const member of members) { - const info = { - role: - member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined), - color: colorMap.get(member.name), - }; - infoMap.set(member.name, info); - if (member.agentType && member.agentType !== member.name) { - infoMap.set(member.agentType, info); - } - } - - const leadMember = members.find( - (member) => member.agentType === 'team-lead' || member.role?.toLowerCase().includes('lead') - ); - if (leadMember) { - const leadInfo = infoMap.get(leadMember.name); - if (leadInfo) { - infoMap.set('user', { role: undefined, color: colorMap.get('user') }); - } - } - - return infoMap; - }, [members, colorMap]); + const ctx = useMemo(() => buildMessageContext(members), [members]); + const { colorMap, localMemberNames, memberInfo } = ctx; const handleMemberNameClick = useCallback( (name: string) => { @@ -541,6 +516,8 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ teamNames={teamNames} teamColorByName={teamColorByName} onTeamClick={onTeamClick} + onExpand={compactHeader ? onExpandItem : undefined} + expandItemKey={compactHeader ? itemKey : undefined} /> ); })()} @@ -609,6 +586,8 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ teamNames={teamNames} teamColorByName={teamColorByName} onTeamClick={onTeamClick} + onExpand={compactHeader ? onExpandItem : undefined} + expandItemKey={compactHeader ? itemKey : undefined} /> ); @@ -627,10 +606,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ ); } - const info = memberInfo.get(message.from); - const recipientInfo = message.to ? memberInfo.get(message.to) : undefined; - const recipientColor = - recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); + const renderProps = resolveMessageRenderProps(message, ctx); const messageKey = toMessageKey(message); const stableKey = messageKey; const collapseProps = getItemCollapseProps(stableKey, realIndex); @@ -643,9 +619,9 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index d018b704..0999de72 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -1,7 +1,5 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; -import { CopyButton } from '@renderer/components/common/CopyButton'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { @@ -18,17 +16,17 @@ import { areStringMapsEqual, areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; -import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; -import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; -import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react'; +import { cn } from '@renderer/lib/utils'; +import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; import { AnimatedHeightReveal, ENTRY_REVEAL_ANIMATION_MS, ENTRY_REVEAL_EASING, } from './AnimatedHeightReveal'; +import { ThoughtBodyContent } from './ThoughtBodyContent'; import type { InboxMessage, ToolCallMeta } from '@shared/types'; @@ -146,6 +144,10 @@ interface LeadThoughtsGroupRowProps { onReply?: (message: InboxMessage) => void; /** Compact header mode for narrow message lists. */ compactHeader?: boolean; + /** Callback to expand this item into a fullscreen dialog. */ + onExpand?: (key: string) => void; + /** Stable key for expand identification. */ + expandItemKey?: string; } function formatTime(timestamp: string): string { @@ -154,7 +156,7 @@ function formatTime(timestamp: string): string { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } -function formatTimeWithSec(timestamp: string): string { +export function formatTimeWithSec(timestamp: string): string { const d = new Date(timestamp); if (Number.isNaN(d.getTime())) return timestamp; return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); @@ -166,7 +168,7 @@ function isRecentTimestamp(timestamp: string): boolean { return Date.now() - t <= LIVE_WINDOW_MS; } -const ToolSummaryTooltipContent = ({ +export const ToolSummaryTooltipContent = ({ toolCalls, toolSummary, }: Readonly<{ @@ -283,15 +285,6 @@ const LeadThoughtItem = memo( const initialAnimationCompletedRef = useRef(!shouldAnimate); const [shouldAnimateOnMount] = useState(() => shouldAnimate); - const displayContent = useMemo(() => { - let text = thought.text.replace(/\n/g, ' \n'); - text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); - if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { - text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames); - } - return text; - }, [thought.text, memberColorMap, teamNames]); - const clearPendingAnimation = useCallback(() => { if (animationFrameRef.current !== null) { cancelAnimationFrame(animationFrameRef.current); @@ -419,88 +412,16 @@ const LeadThoughtItem = memo( return (
- {showDivider && ( -
- - {formatTimeWithSec(thought.timestamp)} - -
- )} -
-
- { - const link = (e.target as HTMLElement).closest( - 'a[href^="task://"]' - ); - if (link) { - e.preventDefault(); - e.stopPropagation(); - const href = link.getAttribute('href'); - const parsedTaskLink = href ? parseTaskLinkHref(href) : null; - if (parsedTaskLink?.taskId) onTaskIdClick(parsedTaskLink.taskId); - } - } - : undefined - } - > - - -
-
- {onReply ? ( - - - - - Reply - - ) : null} - -
-
- {thought.toolSummary && ( - - -
- 🔧 {thought.toolSummary} -
-
- - - -
- )} +
); @@ -581,6 +502,8 @@ const LeadThoughtsGroupRowComponent = ({ onTeamClick, onReply, compactHeader = false, + onExpand, + expandItemKey, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); @@ -887,11 +810,34 @@ const LeadThoughtsGroupRowComponent = ({ ) : null} - - {formatTime(oldest.timestamp) === formatTime(newest.timestamp) - ? formatTime(oldest.timestamp) - : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`} - +
+ + {formatTime(oldest.timestamp) === formatTime(newest.timestamp) + ? formatTime(oldest.timestamp) + : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`} + + {onExpand && expandItemKey && ( + + )} +
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */} @@ -988,5 +934,7 @@ export const LeadThoughtsGroupRow = memo( prev.onTeamClick === next.onTeamClick && prev.onReply === next.onReply && prev.compactHeader === next.compactHeader && + prev.onExpand === next.onExpand && + prev.expandItemKey === next.expandItemKey && areThoughtGroupsEquivalent(prev.group, next.group) ); diff --git a/src/renderer/components/team/activity/MessageExpandDialog.tsx b/src/renderer/components/team/activity/MessageExpandDialog.tsx new file mode 100644 index 00000000..57d06a27 --- /dev/null +++ b/src/renderer/components/team/activity/MessageExpandDialog.tsx @@ -0,0 +1,208 @@ +import { memo, useCallback, useMemo, useRef } from 'react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; + +import { ActivityItem } from './ActivityItem'; +import { buildMessageContext, resolveMessageRenderProps } from './activityMessageContext'; +import { MemberBadge } from '../MemberBadge'; +import { ThoughtBodyContent } from './ThoughtBodyContent'; + +import type { TimelineItem, LeadThoughtGroup } from './LeadThoughtsGroup'; +import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; + +function formatTime(timestamp: string): string { + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return timestamp; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +interface DialogThoughtsContentProps { + group: LeadThoughtGroup; + memberColor?: string; + onTaskIdClick?: (taskId: string) => void; + onReply?: (message: InboxMessage) => void; + memberColorMap?: Map; + teamNames?: string[]; + teamColorByName?: ReadonlyMap; + onTeamClick?: (teamName: string) => void; +} + +const DialogThoughtsContent = ({ + group, + memberColor, + onTaskIdClick, + onReply, + memberColorMap, + teamNames = [], + teamColorByName, + onTeamClick, +}: DialogThoughtsContentProps): React.JSX.Element => { + const { thoughts } = group; + const newest = thoughts[0]; + const oldest = thoughts[thoughts.length - 1]; + const colors = getTeamColorSet(memberColor ?? ''); + const chronological = useMemo(() => [...thoughts].reverse(), [thoughts]); + + return ( +
+ {/* Header */} +
+ + + + {thoughts.length} thoughts + + + {formatTime(oldest.timestamp) === formatTime(newest.timestamp) + ? formatTime(oldest.timestamp) + : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`} + +
+ {/* Body */} +
+ {chronological.map((thought, idx) => ( + 0} + onTaskIdClick={onTaskIdClick} + onReply={onReply} + memberColorMap={memberColorMap} + teamNames={teamNames} + teamColorByName={teamColorByName} + onTeamClick={onTeamClick} + /> + ))} +
+
+ ); +}; + +interface MessageExpandDialogProps { + expandedItem: TimelineItem | null; + open: boolean; + onOpenChange: (open: boolean) => void; + teamName: string; + members?: ResolvedTeamMember[]; + onCreateTaskFromMessage?: (subject: string, description: string) => void; + onReplyToMessage?: (message: InboxMessage) => void; + onMemberClick?: (member: ResolvedTeamMember) => void; + onTaskIdClick?: (taskId: string) => void; + onRestartTeam?: () => void; + teamNames?: string[]; + teamColorByName?: ReadonlyMap; + onTeamClick?: (teamName: string) => void; +} + +export const MessageExpandDialog = memo(function MessageExpandDialog({ + expandedItem, + open, + onOpenChange, + teamName, + members, + onCreateTaskFromMessage, + onReplyToMessage, + onMemberClick, + onTaskIdClick, + onRestartTeam, + teamNames = [], + teamColorByName, + onTeamClick, +}: MessageExpandDialogProps): React.JSX.Element { + // Keep last valid item for exit animation + const lastItemRef = useRef(null); + if (expandedItem) lastItemRef.current = expandedItem; + const displayItem = expandedItem ?? lastItemRef.current; + + const ctx = useMemo(() => buildMessageContext(members), [members]); + + const handleMemberNameClick = useCallback( + (name: string) => { + const member = members?.find( + (candidate) => candidate.name === name || candidate.agentType === name + ); + if (member) onMemberClick?.(member); + }, + [members, onMemberClick] + ); + + const renderProps = + displayItem?.type === 'message' ? resolveMessageRenderProps(displayItem.message, ctx) : null; + + const thoughtMemberColor = + displayItem?.type === 'lead-thoughts' + ? ctx.memberInfo.get(displayItem.group.thoughts[0].from)?.color + : undefined; + + const headerTitle = + displayItem?.type === 'message' + ? displayItem.message.from + : displayItem?.type === 'lead-thoughts' + ? `${displayItem.group.thoughts[0].from} — thoughts` + : ''; + + return ( + + + + {headerTitle} + Expanded message view + +
+ {displayItem?.type === 'message' ? ( + + ) : displayItem?.type === 'lead-thoughts' ? ( + + ) : null} +
+
+
+ ); +}); diff --git a/src/renderer/components/team/activity/ThoughtBodyContent.tsx b/src/renderer/components/team/activity/ThoughtBodyContent.tsx new file mode 100644 index 00000000..c4899a48 --- /dev/null +++ b/src/renderer/components/team/activity/ThoughtBodyContent.tsx @@ -0,0 +1,153 @@ +import { memo, useCallback, useMemo } from 'react'; + +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { CopyButton } from '@renderer/components/common/CopyButton'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables'; +import { + areStringArraysEqual, + areStringMapsEqual, + areThoughtMessagesEquivalentForRender, +} from '@renderer/utils/messageRenderEquality'; +import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; +import { Reply } from 'lucide-react'; + +import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup'; + +import type { InboxMessage } from '@shared/types'; + +interface ThoughtBodyContentProps { + thought: InboxMessage; + showDivider?: boolean; + onTaskIdClick?: (taskId: string) => void; + onReply?: (message: InboxMessage) => void; + memberColorMap?: ReadonlyMap; + teamNames?: string[]; + teamColorByName?: ReadonlyMap; + onTeamClick?: (teamName: string) => void; +} + +export const ThoughtBodyContent = memo( + function ThoughtBodyContent({ + thought, + showDivider, + onTaskIdClick, + onReply, + memberColorMap, + teamNames = [], + teamColorByName, + onTeamClick, + }: ThoughtBodyContentProps): JSX.Element { + const displayContent = useMemo(() => { + let text = thought.text.replace(/\n/g, ' \n'); + text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); + if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { + text = linkifyAllMentionsInMarkdown( + text, + (memberColorMap ?? new Map()) as Map, + teamNames + ); + } + return text; + }, [thought.text, thought.taskRefs, memberColorMap, teamNames]); + + const handleTaskLinkClick = useCallback( + (e: React.MouseEvent) => { + if (!onTaskIdClick) return; + const link = (e.target as HTMLElement).closest('a[href^="task://"]'); + if (!link) return; + e.preventDefault(); + e.stopPropagation(); + const href = link.getAttribute('href'); + const parsed = href ? parseTaskLinkHref(href) : null; + if (parsed?.taskId) onTaskIdClick(parsed.taskId); + }, + [onTaskIdClick] + ); + + const handleReply = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onReply?.(thought); + }, + [onReply, thought] + ); + + return ( + <> + {showDivider && ( +
+ + {formatTimeWithSec(thought.timestamp)} + +
+ )} +
+
+ + + +
+
+ {onReply ? ( + + + + + Reply + + ) : null} + +
+
+ {thought.toolSummary && ( + + +
+ 🔧 {thought.toolSummary} +
+
+ + + +
+ )} + + ); + }, + (prev, next) => + prev.showDivider === next.showDivider && + prev.onTaskIdClick === next.onTaskIdClick && + prev.onReply === next.onReply && + prev.memberColorMap === next.memberColorMap && + areStringArraysEqual(prev.teamNames, next.teamNames) && + areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && + prev.onTeamClick === next.onTeamClick && + areThoughtMessagesEquivalentForRender(prev.thought, next.thought) +); diff --git a/src/renderer/components/team/activity/activityMessageContext.ts b/src/renderer/components/team/activity/activityMessageContext.ts new file mode 100644 index 00000000..98b96351 --- /dev/null +++ b/src/renderer/components/team/activity/activityMessageContext.ts @@ -0,0 +1,73 @@ +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; + +import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; + +export interface MessageContext { + colorMap: Map; + localMemberNames: Set; + memberInfo: Map; +} + +const EMPTY_CONTEXT: MessageContext = { + colorMap: new Map(), + localMemberNames: new Set(), + memberInfo: new Map(), +}; + +/** + * Build derived member context (color map, local names set, member info map) + * from a list of resolved team members. Shared between ActivityTimeline and + * MessageExpandDialog to avoid drift. + */ +export function buildMessageContext(members?: ResolvedTeamMember[]): MessageContext { + if (!members || members.length === 0) return EMPTY_CONTEXT; + + const colorMap = buildMemberColorMap(members); + const localMemberNames = new Set(members.map((m) => m.name.trim())); + + const memberInfo = new Map(); + for (const member of members) { + const info = { + role: member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined), + color: colorMap.get(member.name), + }; + memberInfo.set(member.name, info); + if (member.agentType && member.agentType !== member.name) { + memberInfo.set(member.agentType, info); + } + } + + const leadMember = members.find( + (m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead') + ); + if (leadMember && memberInfo.has(leadMember.name)) { + memberInfo.set('user', { role: undefined, color: colorMap.get('user') }); + } + + return { colorMap, localMemberNames, memberInfo }; +} + +export interface MessageRenderProps { + memberRole?: string; + memberColor?: string; + recipientColor?: string; +} + +/** + * Resolve per-message render props (role, colors) from the shared context. + * Used by both ActivityTimeline render-loop and MessageExpandDialog. + */ +export function resolveMessageRenderProps( + message: InboxMessage, + ctx: MessageContext +): MessageRenderProps { + const info = ctx.memberInfo.get(message.from); + const recipientInfo = message.to ? ctx.memberInfo.get(message.to) : undefined; + const recipientColor = + recipientInfo?.color ?? (message.to ? ctx.colorMap.get(message.to) : undefined); + return { + memberRole: info?.role, + memberColor: info?.color, + recipientColor, + }; +} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 989f2823..c01eb08e 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -566,24 +566,14 @@ export const TaskDetailDialog = ({ Unassigned )} - {currentTask.reviewer || - (currentTask.reviewState && currentTask.reviewState !== 'none') ? ( + {currentTask.reviewer ? (
- {currentTask.reviewer ? ( - - ) : null} - {currentTask.reviewState && currentTask.reviewState !== 'none' ? ( - - {REVIEW_STATE_DISPLAY[currentTask.reviewState].label} - - ) : null} +
) : null} {currentTask.createdBy ? ( diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 4dbacd1d..c1fa1c18 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -23,6 +23,8 @@ import { } 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'; @@ -30,6 +32,7 @@ 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 { @@ -116,6 +119,7 @@ export const MessagesPanel = memo(function MessagesPanel({ const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); const [messagesCollapsed, setMessagesCollapsed] = useState(true); const [sidebarSearchVisible, setSidebarSearchVisible] = useState(false); + const [expandedItemKey, setExpandedItemKey] = useState(null); const filteredMessages = useMemo(() => { return filterTeamMessages(messages, { @@ -125,6 +129,37 @@ export const MessagesPanel = memo(function MessagesPanel({ }); }, [messages, timeWindow, messagesFilter, messagesSearchQuery]); + // Resolve the expanded item from filtered messages + const expandedItem = useMemo(() => { + 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); @@ -321,6 +356,22 @@ export const MessagesPanel = memo(function MessagesPanel({ onMessageVisible={handleMessageVisible} onRestartTeam={onRestartTeam} onTaskIdClick={onTaskIdClick} + onExpandItem={handleExpandItem} + /> + );