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 (
+
+ );
+});
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}
+ />
+
>
);