feat: enhance message collapsing functionality in team activity components

- Added support for user-controlled message expansion in ActivityTimeline and LeadThoughtsGroup components.
- Integrated expand/collapse overrides to allow users to manually expand messages even in collapsed mode.
- Updated ActivityItem to trigger collapse toggle callbacks on click and key events for improved accessibility.
- Refactored related components to accommodate new expand/collapse logic, enhancing user experience in message handling.
This commit is contained in:
iliya 2026-03-06 21:45:20 +02:00
parent 964a8772ea
commit c09ab76d43
4 changed files with 97 additions and 11 deletions

View file

@ -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);

View file

@ -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

View file

@ -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<string>;
/** 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<HTMLDivElement>(null);
const reportedRef = useRef(false);
@ -115,6 +122,7 @@ const MessageRowWithObserver = ({
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
forceCollapsed={forceCollapsed}
onCollapseToggle={onCollapseToggle}
/>
</div>
);
@ -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 (
<div className="space-y-1">
{/* 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 (
<LeadThoughtsGroupRow
key={itemKey}
@ -315,7 +371,8 @@ export const ActivityTimeline = ({
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(0)}
forceCollapsed={allCollapsed}
forceCollapsed={collapseProps.forceCollapsed}
onCollapseToggle={collapseProps.onCollapseToggle}
/>
);
})()}
@ -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 (
<React.Fragment key={itemKey}>
{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}
/>
</React.Fragment>
);
@ -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}
/>
</React.Fragment>
);

View file

@ -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<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(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 */}
<div
role={forceCollapsed === true ? 'button' : undefined}
tabIndex={forceCollapsed === true ? 0 : undefined}
role={forceCollapsed === true || onCollapseToggle != null ? 'button' : undefined}
tabIndex={forceCollapsed === true || onCollapseToggle != null ? 0 : undefined}
className={[
'flex select-none items-center gap-2 px-3 py-1.5',
forceCollapsed === true ? 'cursor-pointer' : '',
forceCollapsed === true || onCollapseToggle != null ? 'cursor-pointer' : '',
].join(' ')}
onClick={forceCollapsed === true ? () => 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 ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{