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.
This commit is contained in:
iliya 2026-03-14 14:21:11 +02:00
parent f0a6db6461
commit e92d4658f4
8 changed files with 600 additions and 169 deletions

View file

@ -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(
</span>
{/* Timestamp */}
<div className="flex shrink-0 items-center gap-1.5">
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
<div className="relative flex shrink-0 items-center gap-1.5">
<span
className={cn(
'text-[10px] transition-opacity',
onExpand && expandItemKey && 'group-hover:opacity-0'
)}
style={{ color: CARD_ICON_MUTED }}
>
{timestamp}
</span>
{onExpand && expandItemKey && (
<button
type="button"
aria-label="Expand message"
className="absolute inset-0 flex items-center justify-center rounded opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onExpand(expandItemKey);
}}
onKeyDown={(e) => e.stopPropagation()}
>
<Maximize2 size={12} />
</button>
)}
</div>
</div>
@ -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)
);

View file

@ -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<string, string>;
/** 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<string, string>();
const EMPTY_LOCAL_MEMBER_NAMES = new Set<string>();
const EMPTY_TEAM_NAMES: string[] = [];
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
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<string, string>;
onTeamClick?: (teamName: string) => void;
onExpand?: (key: string) => void;
expandItemKey?: string;
}): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false);
@ -218,6 +223,8 @@ const MessageRowWithObserver = ({
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={onExpand}
expandItemKey={expandItemKey}
/>
</AnimatedHeightReveal>
);
@ -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<HTMLDivElement>(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<string, { role?: string; color?: string }>();
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}
/>
</React.Fragment>
);
@ -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({
<MemoizedMessageRowWithObserver
message={message}
teamName={teamName}
memberRole={info?.role}
memberColor={info?.color}
recipientColor={recipientColor}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
isUnread={isUnread}
isNew={newItemKeys.has(messageKey)}
zebraShade={zebraShadeSet.has(realIndex)}
@ -666,6 +642,8 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? messageKey : undefined}
/>
</React.Fragment>
);

View file

@ -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 (
<div ref={wrapperRef}>
<div ref={contentRef}>
{showDivider && (
<div className="py-px text-center">
<span className="font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
{formatTimeWithSec(thought.timestamp)}
</span>
</div>
)}
<div className="group/thought relative flex text-[11px]">
<div
className="min-w-0 flex-1 [&>span>div>div>div]:py-2"
style={{ color: CARD_TEXT_LIGHT }}
>
<span
onClickCapture={
onTaskIdClick
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'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
}
>
<MarkdownViewer
content={displayContent}
maxHeight="max-h-none"
bare
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</span>
</div>
<div className="absolute right-1 top-0.5 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/thought:opacity-100">
{onReply ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
onReply(thought);
}}
>
<Reply size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Reply</TooltipContent>
</Tooltip>
) : null}
<CopyButton text={thought.text} inline />
</div>
</div>
{thought.toolSummary && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="mb-[7px] cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
🔧 {thought.toolSummary}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-[420px] font-mono text-[11px]"
>
<ToolSummaryTooltipContent
toolCalls={thought.toolCalls}
toolSummary={thought.toolSummary}
/>
</TooltipContent>
</Tooltip>
)}
<ThoughtBodyContent
thought={thought}
showDivider={showDivider}
onTaskIdClick={onTaskIdClick}
onReply={onReply}
memberColorMap={memberColorMap}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</div>
);
@ -581,6 +502,8 @@ const LeadThoughtsGroupRowComponent = ({
onTeamClick,
onReply,
compactHeader = false,
onExpand,
expandItemKey,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@ -887,11 +810,34 @@ const LeadThoughtsGroupRowComponent = ({
</TooltipContent>
</Tooltip>
) : null}
<span className="ml-auto shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`}
</span>
<div className="relative ml-auto flex shrink-0 items-center gap-1.5">
<span
className={cn(
'text-[10px] transition-opacity',
onExpand && expandItemKey && 'group-hover:opacity-0'
)}
style={{ color: CARD_ICON_MUTED }}
>
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`}
</span>
{onExpand && expandItemKey && (
<button
type="button"
aria-label="Expand thoughts"
className="absolute inset-0 flex items-center justify-center rounded opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onExpand(expandItemKey);
}}
onKeyDown={(e) => e.stopPropagation()}
>
<Maximize2 size={12} />
</button>
)}
</div>
</div>
{/* 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)
);

View file

@ -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<string, string>;
teamNames?: string[];
teamColorByName?: ReadonlyMap<string, string>;
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 (
<div>
{/* Header */}
<div className="flex items-center gap-2 pb-3">
<img
src={agentAvatarUrl(newest.from, 32)}
alt=""
className="size-6 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<MemberBadge name={newest.from} color={memberColor} hideAvatar />
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{thoughts.length} thoughts
</span>
<span className="ml-auto text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`}
</span>
</div>
{/* Body */}
<div
className="rounded-md"
style={{
border: `1px solid var(--color-border-subtle)`,
borderLeft: `3px solid ${colors.border}`,
}}
>
{chronological.map((thought, idx) => (
<ThoughtBodyContent
key={thought.messageId ?? idx}
thought={thought}
showDivider={idx > 0}
onTaskIdClick={onTaskIdClick}
onReply={onReply}
memberColorMap={memberColorMap}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
))}
</div>
</div>
);
};
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<string, string>;
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<TimelineItem | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] w-[80vw] max-w-[2000px] flex-col overflow-hidden p-0">
<DialogHeader className="shrink-0 px-4 pt-4">
<DialogTitle className="text-sm">{headerTitle}</DialogTitle>
<DialogDescription className="sr-only">Expanded message view</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4">
{displayItem?.type === 'message' ? (
<ActivityItem
message={displayItem.message}
teamName={teamName}
memberRole={renderProps?.memberRole}
memberColor={renderProps?.memberColor}
recipientColor={renderProps?.recipientColor}
memberColorMap={ctx.colorMap}
localMemberNames={ctx.localMemberNames}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
compactHeader={false}
isCollapsed={false}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
) : displayItem?.type === 'lead-thoughts' ? (
<DialogThoughtsContent
group={displayItem.group}
memberColor={thoughtMemberColor}
onTaskIdClick={onTaskIdClick}
onReply={onReplyToMessage}
memberColorMap={ctx.colorMap}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
) : null}
</div>
</DialogContent>
</Dialog>
);
});

View file

@ -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<string, string>;
teamNames?: string[];
teamColorByName?: ReadonlyMap<string, string>;
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<string, string>,
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<HTMLAnchorElement>('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 && (
<div className="py-px text-center">
<span className="font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
{formatTimeWithSec(thought.timestamp)}
</span>
</div>
)}
<div className="group/thought relative flex text-[11px]">
<div
className="min-w-0 flex-1 [&>span>div>div>div]:py-2"
style={{ color: CARD_TEXT_LIGHT }}
>
<span onClickCapture={onTaskIdClick ? handleTaskLinkClick : undefined}>
<MarkdownViewer
content={displayContent}
maxHeight="max-h-none"
bare
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</span>
</div>
<div className="absolute right-1 top-0.5 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/thought:opacity-100">
{onReply ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={handleReply}
>
<Reply size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Reply</TooltipContent>
</Tooltip>
) : null}
<CopyButton text={thought.text} inline />
</div>
</div>
{thought.toolSummary && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="mb-[7px] cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
🔧 {thought.toolSummary}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-[420px] font-mono text-[11px]"
>
<ToolSummaryTooltipContent
toolCalls={thought.toolCalls}
toolSummary={thought.toolSummary}
/>
</TooltipContent>
</Tooltip>
)}
</>
);
},
(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)
);

View file

@ -0,0 +1,73 @@
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
export interface MessageContext {
colorMap: Map<string, string>;
localMemberNames: Set<string>;
memberInfo: Map<string, { role?: string; color?: string }>;
}
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<string, { role?: string; color?: string }>();
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,
};
}

View file

@ -566,24 +566,14 @@ export const TaskDetailDialog = ({
<span className="text-xs italic text-[var(--color-text-muted)]">Unassigned</span>
)}
</div>
{currentTask.reviewer ||
(currentTask.reviewState && currentTask.reviewState !== 'none') ? (
{currentTask.reviewer ? (
<div className="flex items-center gap-1.5">
<Eye size={12} className="text-[var(--color-text-muted)]" />
{currentTask.reviewer ? (
<MemberBadge
name={currentTask.reviewer}
color={colorMap.get(currentTask.reviewer)}
size="sm"
/>
) : null}
{currentTask.reviewState && currentTask.reviewState !== 'none' ? (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY[currentTask.reviewState].bg} ${REVIEW_STATE_DISPLAY[currentTask.reviewState].text}`}
>
{REVIEW_STATE_DISPLAY[currentTask.reviewState].label}
</span>
) : null}
<MemberBadge
name={currentTask.reviewer}
color={colorMap.get(currentTask.reviewer)}
size="sm"
/>
</div>
) : null}
{currentTask.createdBy ? (

View file

@ -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<string | null>(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<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);
@ -321,6 +356,22 @@ export const MessagesPanel = memo(function MessagesPanel({
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
/>
<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}
/>
</>
);