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 { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
|
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||||
|
|
@ -627,6 +628,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
}, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]);
|
}, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]);
|
||||||
|
|
||||||
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
|
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
|
||||||
|
const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName ?? '');
|
||||||
const messagesUnreadCount = useMemo(
|
const messagesUnreadCount = useMemo(
|
||||||
() => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length,
|
() => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length,
|
||||||
[filteredMessages, readSet]
|
[filteredMessages, readSet]
|
||||||
|
|
@ -1562,6 +1564,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
members={data.members}
|
members={data.members}
|
||||||
readState={{ readSet, getMessageKey: toMessageKey }}
|
readState={{ readSet, getMessageKey: toMessageKey }}
|
||||||
allCollapsed={messagesCollapsed}
|
allCollapsed={messagesCollapsed}
|
||||||
|
expandOverrides={expandedSet}
|
||||||
|
onToggleExpandOverride={toggleExpandOverride}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={setSelectedMember}
|
||||||
onCreateTaskFromMessage={(subject, description) => {
|
onCreateTaskFromMessage={(subject, description) => {
|
||||||
openCreateTaskDialog(subject, description);
|
openCreateTaskDialog(subject, description);
|
||||||
|
|
|
||||||
|
|
@ -340,13 +340,21 @@ export const ActivityItem = ({
|
||||||
'flex items-center gap-2 px-3 py-2',
|
'flex items-center gap-2 px-3 py-2',
|
||||||
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={isHeaderClickable ? () => setIsExpanded((v) => !v) : undefined}
|
onClick={
|
||||||
|
isHeaderClickable
|
||||||
|
? () => {
|
||||||
|
setIsExpanded((v) => !v);
|
||||||
|
onCollapseToggle?.();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
isHeaderClickable
|
isHeaderClickable
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsExpanded((v) => !v);
|
setIsExpanded((v) => !v);
|
||||||
|
onCollapseToggle?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined
|
: 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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
|
||||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||||
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
||||||
|
|
@ -28,6 +29,10 @@ interface ActivityTimelineProps {
|
||||||
onRestartTeam?: () => void;
|
onRestartTeam?: () => void;
|
||||||
/** When true, collapse all message bodies — show only headers with expand chevrons. */
|
/** When true, collapse all message bodies — show only headers with expand chevrons. */
|
||||||
allCollapsed?: boolean;
|
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;
|
const VIEWPORT_THRESHOLD = 0.15;
|
||||||
|
|
@ -50,6 +55,7 @@ const MessageRowWithObserver = ({
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
onRestartTeam,
|
onRestartTeam,
|
||||||
forceCollapsed,
|
forceCollapsed,
|
||||||
|
onCollapseToggle,
|
||||||
}: {
|
}: {
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -67,6 +73,7 @@ const MessageRowWithObserver = ({
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
onRestartTeam?: () => void;
|
onRestartTeam?: () => void;
|
||||||
forceCollapsed?: boolean;
|
forceCollapsed?: boolean;
|
||||||
|
onCollapseToggle?: () => void;
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const reportedRef = useRef(false);
|
const reportedRef = useRef(false);
|
||||||
|
|
@ -115,6 +122,7 @@ const MessageRowWithObserver = ({
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onRestartTeam={onRestartTeam}
|
onRestartTeam={onRestartTeam}
|
||||||
forceCollapsed={forceCollapsed}
|
forceCollapsed={forceCollapsed}
|
||||||
|
onCollapseToggle={onCollapseToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -132,6 +140,8 @@ export const ActivityTimeline = ({
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
onRestartTeam,
|
onRestartTeam,
|
||||||
allCollapsed,
|
allCollapsed,
|
||||||
|
expandOverrides,
|
||||||
|
onToggleExpandOverride,
|
||||||
}: ActivityTimelineProps): React.JSX.Element => {
|
}: ActivityTimelineProps): React.JSX.Element => {
|
||||||
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
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 pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
|
||||||
const startIndex = pinnedThoughtGroup ? 1 : 0;
|
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 (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{/* Pinned (newest) thought group — always at top */}
|
{/* Pinned (newest) thought group — always at top */}
|
||||||
|
|
@ -306,6 +360,8 @@ export const ActivityTimeline = ({
|
||||||
const firstThought = group.thoughts[0];
|
const firstThought = group.thoughts[0];
|
||||||
const info = memberInfo.get(firstThought.from);
|
const info = memberInfo.get(firstThought.from);
|
||||||
const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`;
|
const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`;
|
||||||
|
const stableKey = toMessageKey(firstThought);
|
||||||
|
const collapseProps = getItemCollapseProps(stableKey, 0);
|
||||||
return (
|
return (
|
||||||
<LeadThoughtsGroupRow
|
<LeadThoughtsGroupRow
|
||||||
key={itemKey}
|
key={itemKey}
|
||||||
|
|
@ -315,7 +371,8 @@ export const ActivityTimeline = ({
|
||||||
isNew={newItemKeys.has(itemKey)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(0)}
|
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 firstThought = group.thoughts[0];
|
||||||
const info = memberInfo.get(firstThought.from);
|
const info = memberInfo.get(firstThought.from);
|
||||||
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
|
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
|
||||||
|
const stableKey = toMessageKey(firstThought);
|
||||||
|
const collapseProps = getItemCollapseProps(stableKey, realIndex);
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={itemKey}>
|
<React.Fragment key={itemKey}>
|
||||||
{sessionSeparator}
|
{sessionSeparator}
|
||||||
|
|
@ -358,7 +417,8 @@ export const ActivityTimeline = ({
|
||||||
isNew={newItemKeys.has(itemKey)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(realIndex)}
|
zebraShade={zebraShadeSet.has(realIndex)}
|
||||||
forceCollapsed={allCollapsed}
|
forceCollapsed={collapseProps.forceCollapsed}
|
||||||
|
onCollapseToggle={collapseProps.onCollapseToggle}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
@ -370,6 +430,8 @@ export const ActivityTimeline = ({
|
||||||
const recipientColor =
|
const recipientColor =
|
||||||
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
||||||
const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`;
|
const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`;
|
||||||
|
const stableKey = toMessageKey(message);
|
||||||
|
const collapseProps = getItemCollapseProps(stableKey, realIndex);
|
||||||
const isUnread = readState
|
const isUnread = readState
|
||||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||||
: !message.read;
|
: !message.read;
|
||||||
|
|
@ -392,7 +454,8 @@ export const ActivityTimeline = ({
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onRestartTeam={onRestartTeam}
|
onRestartTeam={onRestartTeam}
|
||||||
forceCollapsed={allCollapsed}
|
forceCollapsed={collapseProps.forceCollapsed}
|
||||||
|
onCollapseToggle={collapseProps.onCollapseToggle}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ interface LeadThoughtsGroupRowProps {
|
||||||
zebraShade?: boolean;
|
zebraShade?: boolean;
|
||||||
/** When true, collapse the thought body — show only the header with expand chevron. */
|
/** When true, collapse the thought body — show only the header with expand chevron. */
|
||||||
forceCollapsed?: boolean;
|
forceCollapsed?: boolean;
|
||||||
|
/** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */
|
||||||
|
onCollapseToggle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: string): string {
|
function formatTime(timestamp: string): string {
|
||||||
|
|
@ -346,6 +348,7 @@ export const LeadThoughtsGroupRow = ({
|
||||||
canBeLive,
|
canBeLive,
|
||||||
zebraShade,
|
zebraShade,
|
||||||
forceCollapsed,
|
forceCollapsed,
|
||||||
|
onCollapseToggle,
|
||||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -521,26 +524,34 @@ export const LeadThoughtsGroupRow = ({
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
|
||||||
<div
|
<div
|
||||||
role={forceCollapsed === true ? 'button' : undefined}
|
role={forceCollapsed === true || onCollapseToggle != null ? 'button' : undefined}
|
||||||
tabIndex={forceCollapsed === true ? 0 : undefined}
|
tabIndex={forceCollapsed === true || onCollapseToggle != null ? 0 : undefined}
|
||||||
className={[
|
className={[
|
||||||
'flex select-none items-center gap-2 px-3 py-1.5',
|
'flex select-none items-center gap-2 px-3 py-1.5',
|
||||||
forceCollapsed === true ? 'cursor-pointer' : '',
|
forceCollapsed === true || onCollapseToggle != null ? 'cursor-pointer' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={forceCollapsed === true ? () => setIsBodyVisible((v) => !v) : undefined}
|
onClick={
|
||||||
|
forceCollapsed === true || onCollapseToggle != null
|
||||||
|
? () => {
|
||||||
|
setIsBodyVisible((v) => !v);
|
||||||
|
onCollapseToggle?.();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
forceCollapsed === true
|
forceCollapsed === true || onCollapseToggle != null
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsBodyVisible((v) => !v);
|
setIsBodyVisible((v) => !v);
|
||||||
|
onCollapseToggle?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Chevron for collapse mode */}
|
{/* Chevron for collapse mode */}
|
||||||
{forceCollapsed === true ? (
|
{forceCollapsed === true || onCollapseToggle != null ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className="size-3 shrink-0 transition-transform duration-150"
|
className="size-3 shrink-0 transition-transform duration-150"
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue