diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 4ba59754..afcdc0bf 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -17,6 +17,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; +import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; @@ -627,6 +628,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]); const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? ''); + const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName ?? ''); const messagesUnreadCount = useMemo( () => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length, [filteredMessages, readSet] @@ -1562,6 +1564,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele members={data.members} readState={{ readSet, getMessageKey: toMessageKey }} allCollapsed={messagesCollapsed} + expandOverrides={expandedSet} + onToggleExpandOverride={toggleExpandOverride} onMemberClick={setSelectedMember} onCreateTaskFromMessage={(subject, description) => { openCreateTaskDialog(subject, description); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index b8093241..0cd1d3dc 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -340,13 +340,21 @@ export const ActivityItem = ({ 'flex items-center gap-2 px-3 py-2', isHeaderClickable ? 'cursor-pointer select-none' : '', ].join(' ')} - onClick={isHeaderClickable ? () => setIsExpanded((v) => !v) : undefined} + onClick={ + isHeaderClickable + ? () => { + setIsExpanded((v) => !v); + onCollapseToggle?.(); + } + : undefined + } onKeyDown={ isHeaderClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsExpanded((v) => !v); + onCollapseToggle?.(); } } : undefined diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 1f94c3fa..f65c9248 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; @@ -28,6 +29,10 @@ interface ActivityTimelineProps { onRestartTeam?: () => void; /** When true, collapse all message bodies — show only headers with expand chevrons. */ allCollapsed?: boolean; + /** Set of stable message keys that the user has manually expanded in collapsed mode. */ + expandOverrides?: Set; + /** Called when user toggles expand/collapse override on a specific message. */ + onToggleExpandOverride?: (key: string) => void; } const VIEWPORT_THRESHOLD = 0.15; @@ -50,6 +55,7 @@ const MessageRowWithObserver = ({ onTaskIdClick, onRestartTeam, forceCollapsed, + onCollapseToggle, }: { message: InboxMessage; teamName: string; @@ -67,6 +73,7 @@ const MessageRowWithObserver = ({ onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; forceCollapsed?: boolean; + onCollapseToggle?: () => void; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -115,6 +122,7 @@ const MessageRowWithObserver = ({ onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} forceCollapsed={forceCollapsed} + onCollapseToggle={onCollapseToggle} /> ); @@ -132,6 +140,8 @@ export const ActivityTimeline = ({ onTaskIdClick, onRestartTeam, allCollapsed, + expandOverrides, + onToggleExpandOverride, }: ActivityTimelineProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); @@ -297,6 +307,50 @@ export const ActivityTimeline = ({ const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; const startIndex = pinnedThoughtGroup ? 1 : 0; + // Determine the index of the "newest" non-thought timeline item (for auto-expand). + // Pinned thought group is always at index 0 when present, so newest message is the + // first non-thought item in the remaining list. + const newestMessageIndex = useMemo(() => { + for (let i = startIndex; i < timelineItems.length; i++) { + if (timelineItems[i].type !== 'lead-thoughts') return i; + } + return -1; + }, [timelineItems, startIndex]); + + /** + * Compute per-item forceCollapsed + onCollapseToggle based on: + * - allCollapsed mode enabled/disabled + * - Whether this is the newest message (auto-expanded, no chevron) + * - Whether user has manually expanded this item (override in localStorage) + * + * | allCollapsed | isNewest | inOverrides | forceCollapsed | onCollapseToggle | + * |-------------|----------|-------------|----------------|------------------| + * | false | any | any | undefined | undefined | + * | true | yes | any | undefined | undefined | + * | true | no | yes | false | fn | + * | true | no | no | true | fn | + */ + const getItemCollapseProps = useCallback( + ( + stableKey: string, + itemIndex: number + ): { forceCollapsed?: boolean; onCollapseToggle?: () => void } => { + if (!allCollapsed) return {}; + if (itemIndex === newestMessageIndex) return {}; + // Pinned thought group (index 0) is always the newest thought → expanded + if (itemIndex === 0 && pinnedThoughtGroup) return {}; + + const isOverridden = expandOverrides?.has(stableKey) ?? false; + return { + forceCollapsed: !isOverridden, + onCollapseToggle: onToggleExpandOverride + ? () => onToggleExpandOverride(stableKey) + : undefined, + }; + }, + [allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] + ); + return (
{/* Pinned (newest) thought group — always at top */} @@ -306,6 +360,8 @@ export const ActivityTimeline = ({ const firstThought = group.thoughts[0]; const info = memberInfo.get(firstThought.from); const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`; + const stableKey = toMessageKey(firstThought); + const collapseProps = getItemCollapseProps(stableKey, 0); return ( ); })()} @@ -348,6 +405,8 @@ export const ActivityTimeline = ({ const firstThought = group.thoughts[0]; const info = memberInfo.get(firstThought.from); const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`; + const stableKey = toMessageKey(firstThought); + const collapseProps = getItemCollapseProps(stableKey, realIndex); return ( {sessionSeparator} @@ -358,7 +417,8 @@ export const ActivityTimeline = ({ isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(realIndex)} - forceCollapsed={allCollapsed} + forceCollapsed={collapseProps.forceCollapsed} + onCollapseToggle={collapseProps.onCollapseToggle} /> ); @@ -370,6 +430,8 @@ export const ActivityTimeline = ({ const recipientColor = recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`; + const stableKey = toMessageKey(message); + const collapseProps = getItemCollapseProps(stableKey, realIndex); const isUnread = readState ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) : !message.read; @@ -392,7 +454,8 @@ export const ActivityTimeline = ({ onVisible={onMessageVisible} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} - forceCollapsed={allCollapsed} + forceCollapsed={collapseProps.forceCollapsed} + onCollapseToggle={collapseProps.onCollapseToggle} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 12997803..83690d93 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -88,6 +88,8 @@ interface LeadThoughtsGroupRowProps { zebraShade?: boolean; /** When true, collapse the thought body — show only the header with expand chevron. */ forceCollapsed?: boolean; + /** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */ + onCollapseToggle?: () => void; } function formatTime(timestamp: string): string { @@ -346,6 +348,7 @@ export const LeadThoughtsGroupRow = ({ canBeLive, zebraShade, forceCollapsed, + onCollapseToggle, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); @@ -521,26 +524,34 @@ export const LeadThoughtsGroupRow = ({ {/* Header */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
setIsBodyVisible((v) => !v) : undefined} + onClick={ + forceCollapsed === true || onCollapseToggle != null + ? () => { + setIsBodyVisible((v) => !v); + onCollapseToggle?.(); + } + : undefined + } onKeyDown={ - forceCollapsed === true + forceCollapsed === true || onCollapseToggle != null ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsBodyVisible((v) => !v); + onCollapseToggle?.(); } } : undefined } > {/* Chevron for collapse mode */} - {forceCollapsed === true ? ( + {forceCollapsed === true || onCollapseToggle != null ? (