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:
parent
964a8772ea
commit
c09ab76d43
4 changed files with 97 additions and 11 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
Loading…
Reference in a new issue