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:
parent
f0a6db6461
commit
e92d4658f4
8 changed files with 600 additions and 169 deletions
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
208
src/renderer/components/team/activity/MessageExpandDialog.tsx
Normal file
208
src/renderer/components/team/activity/MessageExpandDialog.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
153
src/renderer/components/team/activity/ThoughtBodyContent.tsx
Normal file
153
src/renderer/components/team/activity/ThoughtBodyContent.tsx
Normal 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)
|
||||
);
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue