From 038c3f8bb44d2eb81a657810387da72a3df2bf13 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 14 Mar 2026 12:16:16 +0200 Subject: [PATCH] refactor: improve task management protocols and enhance UI components - Updated task management instructions in tasks.js and TeamProvisioningService.ts to clarify the process for handling follow-up work on tasks. - Enhanced member briefing and task status protocols with critical reminders to ensure proper task handling. - Refactored TeamDetailView to improve data consistency and tab management. - Simplified MessagesPanel by integrating team status and pending replies for better user experience. - Enhanced MarkdownViewer and ActivityItem components to support team click actions and improve interactivity. - Introduced stable member management hooks to optimize rendering performance in team activity components. --- agent-teams-controller/src/internal/tasks.js | 7 +- .../services/team/TeamProvisioningService.ts | 11 +- .../chat/viewers/MarkdownViewer.tsx | 30 +- .../components/team/TeamDetailView.tsx | 123 ++- .../components/team/activity/ActivityItem.tsx | 991 ++++++++++-------- .../team/activity/ActivityTimeline.tsx | 219 +++- .../team/activity/LeadThoughtsGroup.tsx | 659 +++++++----- .../team/messages/MessagesPanel.tsx | 29 +- .../team/review/ChangesLoadingAnimation.tsx | 272 ++++- .../hooks/useStableTeamMentionMeta.ts | 93 ++ src/renderer/utils/messageRenderEquality.ts | 131 +++ 11 files changed, 1687 insertions(+), 878 deletions(-) create mode 100644 src/renderer/hooks/useStableTeamMentionMeta.ts create mode 100644 src/renderer/utils/messageRenderEquality.ts diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 4302e487..68b201b6 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -369,8 +369,9 @@ function buildMemberTaskProtocol(teamName) { - Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work. 3. Use MCP tool task_complete BEFORE sending your final reply: { teamName: "${teamName}", taskId: "" } - - If a new task comment means you must do more real work on that same task, FIRST run task_start again before doing the follow-up work. - - After that follow-up work is finished, run task_complete again before your reply. + - If a new task comment means you must do more real work on that same task, FIRST add a short task comment saying what you are going to do, THEN run task_start again before doing the follow-up work. + - After that follow-up work finishes, add a short task comment with the result, what changed, or what you verified. + - After that, run task_complete again before your reply. - Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved. 4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true } @@ -498,7 +499,7 @@ async function memberBriefing(context, memberName) { const lines = [ `Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`, `Role: ${role}.`, - `CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST move it to in_progress with task_start, THEN do the work, and when finished move it to done with task_complete. Never skip this reopen -> work -> done cycle.`, + `CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`, `Team lead: ${leadName}.`, buildMemberLanguageInstruction(config), `You must NOT start work, claim tasks, or improvise task/process protocol before reading and following this briefing.`, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 41705b47..12916af7 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -442,7 +442,7 @@ After member_briefing succeeds: - Introduce yourself briefly (name and role) and confirm you are ready. - Then wait for task assignments. - When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough. -- CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST move it to in_progress with task_start, THEN do the work, and when finished move it to done with task_complete. Never skip this reopen -> work -> done cycle. +- CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle. ${buildTeammateAgentBlockReminder()} ${actionModeProtocol}`; } @@ -475,7 +475,7 @@ ${actionModeProtocol} - Before you start any needsFix or pending task, call task_get for that specific task. - If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. - Only then run task_start when you truly begin. - - If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST run task_start, then do the work, and when finished run task_complete again. Never skip this reopen -> work -> done cycle. + - If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST leave a short task comment saying what you are about to do, THEN run task_start, then do the work, and when finished leave a short result comment and run task_complete again. Never skip this comment -> reopen -> work -> comment -> done cycle. - If you have no tasks, wait for new assignments.`; } @@ -515,7 +515,7 @@ ${actionModeProtocol} - Before you start any needsFix or pending task, call task_get for that specific task. - If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. - Only then run task_start when you truly begin. - - If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST run task_start, then do the work, and when finished run task_complete again. Never skip this reopen -> work -> done cycle. + - If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST leave a short task comment saying what you are about to do, THEN run task_start, then do the work, and when finished leave a short result comment and run task_complete again. Never skip this comment -> reopen -> work -> comment -> done cycle. - If you have no tasks, wait for new assignments.`; } @@ -606,8 +606,9 @@ function buildTaskStatusProtocol(teamName: string): string { - Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work. 3. Use MCP tool task_complete BEFORE sending your final reply: { teamName: "${teamName}", taskId: "" } - - If a new task comment means you must do more real work on that same task, FIRST run task_start again before doing the follow-up work. - - After that follow-up work is finished, run task_complete again before your reply. + - If a new task comment means you must do more real work on that same task, FIRST add a short task comment saying what you are going to do, THEN run task_start again before doing the follow-up work. + - After that follow-up work finishes, add a short task comment with the result, what changed, or what you verified. + - After that, run task_complete again before your reply. - Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved. 4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true } diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 0d954f9e..9ff1cce8 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -27,6 +27,7 @@ import { import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import type { SearchMatch } from '@renderer/store/types'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { nameColorSet } from '@renderer/utils/projectColor'; import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; @@ -62,8 +63,17 @@ interface MarkdownViewerProps { bare?: boolean; /** Base directory for resolving relative URLs (images, links) via local-resource:// protocol */ baseDir?: string; + /** Optional precomputed team color map to avoid subscribing to the full team list. */ + teamColorByName?: ReadonlyMap; + /** Optional team click handler to avoid subscribing to store in leaf renderers. */ + onTeamClick?: (teamName: string) => void; } +const EMPTY_TEAMS: Array<{ teamName?: string; displayName?: string; color?: string }> = []; +const EMPTY_TEAM_COLOR_MAP = new Map(); +const EMPTY_SEARCH_MATCHES: SearchMatch[] = []; +const NOOP_TEAM_CLICK = (): void => undefined; + // ============================================================================= // Helpers // ============================================================================= @@ -517,15 +527,16 @@ export const MarkdownViewer: React.FC = ({ copyable = false, bare = false, baseDir, + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, }) => { const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const { isLight } = useTheme(); - const teams = useStore((s) => s.teams); + const teams = useStore((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)); + const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); - const openTeamTab = useStore((s) => s.openTeamTab); - - const teamColorByName = React.useMemo(() => { + const fallbackTeamColorByName = React.useMemo(() => { const result = new Map(); for (const team of teams) { if (team.teamName) { @@ -537,6 +548,9 @@ export const MarkdownViewer: React.FC = ({ } return result; }, [teams]); + const teamColorByName = + providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP; + const onTeamClick = providedOnTeamClick ?? openTeamTab; const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; @@ -545,7 +559,7 @@ export const MarkdownViewer: React.FC = ({ const { searchQuery, searchMatches, currentSearchIndex } = useStore( useShallow((s) => ({ searchQuery: itemId ? s.searchQuery : '', - searchMatches: itemId ? s.searchMatches : [], + searchMatches: itemId ? s.searchMatches : EMPTY_SEARCH_MATCHES, currentSearchIndex: itemId ? s.currentSearchIndex : -1, })) ); @@ -689,10 +703,10 @@ export const MarkdownViewer: React.FC = ({ // When search is active, create fresh each render (match counter is stateful and must start at 0) // useMemo would cache stale closures when parent re-renders without search deps changing const baseComponents = searchCtx - ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, openTeamTab) + ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick) : isLight - ? createViewerMarkdownComponents(null, true, teamColorByName, openTeamTab) - : createViewerMarkdownComponents(null, false, teamColorByName, openTeamTab); + ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick) + : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick); // When baseDir is set (editor preview), override img to load local files via IPC const components = baseDir diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 6f4b6638..93c45d93 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -105,6 +105,51 @@ interface CreateTaskDialogState { defaultChip?: InlineChip; } +function areResolvedMembersEqual( + prev: readonly ResolvedTeamMember[], + next: readonly ResolvedTeamMember[] +): boolean { + if (prev === next) return true; + if (prev.length !== next.length) return false; + + for (let i = 0; i < prev.length; i++) { + const prevMember = prev[i]; + const nextMember = next[i]; + if ( + prevMember.name !== nextMember.name || + prevMember.status !== nextMember.status || + prevMember.currentTaskId !== nextMember.currentTaskId || + prevMember.color !== nextMember.color || + prevMember.agentType !== nextMember.agentType || + prevMember.role !== nextMember.role || + prevMember.workflow !== nextMember.workflow || + prevMember.cwd !== nextMember.cwd || + prevMember.gitBranch !== nextMember.gitBranch || + prevMember.removedAt !== nextMember.removedAt + ) { + return false; + } + } + + return true; +} + +function useStableActiveMembers( + members: readonly ResolvedTeamMember[] | undefined +): ResolvedTeamMember[] { + const filteredMembers = useMemo( + () => (members ?? []).filter((member) => !member.removedAt), + [members] + ); + const stableMembersRef = useRef(filteredMembers); + + if (!areResolvedMembersEqual(stableMembersRef.current, filteredMembers)) { + stableMembersRef.current = filteredMembers; + } + + return stableMembersRef.current; +} + interface TimeWindow { start: number; end: number; @@ -230,6 +275,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele clearProvisioningError, isTeamProvisioning, leadActivityByTeam, + leadContextUpdatedAt, memberSpawnStatuses, fetchMemberSpawnStatuses, refreshTeamData, @@ -278,6 +324,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele clearProvisioningError: s.clearProvisioningError, isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, leadActivityByTeam: s.leadActivityByTeam, + leadContextUpdatedAt: teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined, memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses, refreshTeamData: s.refreshTeamData, @@ -637,10 +684,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return result; }, [data, timeWindow, kanbanFilter.selectedOwners]); - const activeMembers = useMemo( - () => (data?.members ?? []).filter((m) => !m.removedAt), - [data?.members] - ); + const activeMembers = useStableActiveMembers(data?.members); const kanbanDisplayTasks = useMemo(() => { const query = kanbanSearch.trim(); @@ -682,6 +726,31 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }); }; + const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { + openCreateTaskDialog(subject, description); + }, []); + + const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }, []); + + const handleRestartTeam = useCallback(() => { + setLaunchDialogOpen(true); + }, []); + + const handleTaskIdClick = useCallback( + (taskId: string) => { + const task = + taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }, + [taskMap, data?.tasks] + ); + const handleEditorAction = useCallback( (action: EditorSelectionAction) => { const chip = createChipFromSelection(action, []) ?? undefined; @@ -987,6 +1056,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele tasks={data.tasks} messages={data.messages} isTeamAlive={data.isAlive} + leadActivity={leadActivityByTeam[teamName]} + leadContextUpdatedAt={leadContextUpdatedAt} timeWindow={timeWindow} teamSessionIds={teamSessionIds} currentLeadSessionId={data?.config.leadSessionId} @@ -994,23 +1065,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onPendingReplyChange={setPendingRepliesByMember} onMemberClick={setSelectedMember} onTaskClick={setSelectedTask} - onCreateTaskFromMessage={(subject, description) => { - openCreateTaskDialog(subject, description); - }} - onReplyToMessage={(message) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }} - onRestartTeam={() => setLaunchDialogOpen(true)} - onTaskIdClick={(taskId) => { - const task = - taskMap.get(taskId) ?? - data.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); - }} + onCreateTaskFromMessage={handleCreateTaskFromMessage} + onReplyToMessage={handleReplyToMessage} + onRestartTeam={handleRestartTeam} + onTaskIdClick={handleTaskIdClick} /> @@ -1584,6 +1642,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele tasks={data.tasks} messages={data.messages} isTeamAlive={data.isAlive} + leadActivity={leadActivityByTeam[teamName]} + leadContextUpdatedAt={leadContextUpdatedAt} timeWindow={timeWindow} teamSessionIds={teamSessionIds} currentLeadSessionId={data?.config.leadSessionId} @@ -1591,23 +1651,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onPendingReplyChange={setPendingRepliesByMember} onMemberClick={setSelectedMember} onTaskClick={setSelectedTask} - onCreateTaskFromMessage={(subject, description) => { - openCreateTaskDialog(subject, description); - }} - onReplyToMessage={(message) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }} - onRestartTeam={() => setLaunchDialogOpen(true)} - onTaskIdClick={(taskId) => { - const task = - taskMap.get(taskId) ?? - data.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); - }} + onCreateTaskFromMessage={handleCreateTaskFromMessage} + onReplyToMessage={handleReplyToMessage} + onRestartTeam={handleRestartTeam} + onTaskIdClick={handleTaskIdClick} /> )} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 9b1e786e..46f23f9c 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,4 +1,4 @@ -import { Fragment, useMemo } from 'react'; +import { Fragment, memo, useCallback, useMemo } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { CopyButton } from '@renderer/components/common/CopyButton'; @@ -16,7 +16,6 @@ import { } from '@renderer/constants/cssVariables'; import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; -import { useStore } from '@renderer/store'; import { getMessageTypeLabel, getStructuredMessageSummary, @@ -25,7 +24,13 @@ import { } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { + areInboxMessagesEquivalentForRender, + areStringArraysEqual, + areStringMapsEqual, +} from '@renderer/utils/messageRenderEquality'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_SENT_SOURCE, @@ -38,10 +43,8 @@ import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react'; -import { isManagedCollapseState } from './collapseState'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; -import type { ActivityCollapseState } from './collapseState'; import type { TeamColorSet } from '@renderer/constants/teamColors'; import type { InboxMessage } from '@shared/types'; @@ -139,6 +142,7 @@ interface ActivityItemProps { message: InboxMessage; teamName: string; localMemberNames?: Set; + teamNames?: string[]; memberRole?: string; memberColor?: string; recipientColor?: string; @@ -146,6 +150,10 @@ interface ActivityItemProps { isUnread?: boolean; /** Map of member name → color name for @mention badge rendering. */ memberColorMap?: Map; + /** Team color mapping for team:// links rendered inside markdown. */ + teamColorByName?: ReadonlyMap; + /** Opens a team tab from cross-team badges or team:// links. */ + onTeamClick?: (teamName: string) => void; onMemberNameClick?: (memberName: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; @@ -155,12 +163,20 @@ interface ActivityItemProps { onRestartTeam?: () => void; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; - /** Explicit collapse state for timeline-controlled collapsed mode. */ - collapseState?: ActivityCollapseState; + /** Collapsed-mode primitives stabilized by ActivityTimeline. */ + collapseMode?: 'default' | 'managed'; + isCollapsed?: boolean; + canToggleCollapse?: boolean; + collapseToggleKey?: string; + onToggleCollapse?: (key: string) => void; /** Compact header mode for narrow message lists. */ compactHeader?: boolean; } +function areMessagesEquivalentForActivityItem(prev: InboxMessage, next: InboxMessage): boolean { + return areInboxMessagesEquivalentForRender(prev, next); +} + function getStringField(obj: StructuredMessage, key: string): string | null { const value = obj[key]; return typeof value === 'string' && value.trim() !== '' ? value : null; @@ -311,468 +327,525 @@ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React. }); } -export const ActivityItem = ({ - message, - teamName, - localMemberNames, - memberRole, - memberColor, - recipientColor, - isUnread, - memberColorMap, - onMemberNameClick, - onCreateTask, - onReply, - onTaskIdClick, - onRestartTeam, - zebraShade, - collapseState, - compactHeader = false, -}: ActivityItemProps): React.JSX.Element => { - const colors = getTeamColorSet(memberColor ?? message.color ?? ''); - const { isLight } = useTheme(); - // Hide role when it matches the sender name (avoids "lead" badge + "Team Lead" text duplication) - const formattedRole = - memberRole && memberRole !== message.from ? formatAgentRole(memberRole) : null; +export const ActivityItem = memo( + function ActivityItem({ + message, + teamName, + localMemberNames, + teamNames = [], + memberRole, + memberColor, + recipientColor, + isUnread, + memberColorMap, + teamColorByName, + onTeamClick, + onMemberNameClick, + onCreateTask, + onReply, + onTaskIdClick, + onRestartTeam, + zebraShade, + collapseMode = 'default', + isCollapsed = false, + canToggleCollapse = false, + collapseToggleKey, + onToggleCollapse, + compactHeader = false, + }: ActivityItemProps): React.JSX.Element { + const colors = getTeamColorSet(memberColor ?? message.color ?? ''); + const { isLight } = useTheme(); + // Hide role when it matches the sender name (avoids "lead" badge + "Team Lead" text duplication) + const formattedRole = + memberRole && memberRole !== message.from ? formatAgentRole(memberRole) : null; - const teams = useStore((s) => s.teams); - const openTeamTab = useStore((s) => s.openTeamTab); - const teamNames = useMemo( - () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), - [teams] - ); + const timestamp = useMemo(() => { + if (Number.isNaN(Date.parse(message.timestamp))) return message.timestamp; + const date = new Date(message.timestamp); + const now = new Date(); + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + return isToday + ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleString(); + }, [message.timestamp]); - const timestamp = useMemo(() => { - if (Number.isNaN(Date.parse(message.timestamp))) return message.timestamp; - const date = new Date(message.timestamp); - const now = new Date(); - const isToday = - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate(); - return isToday - ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : date.toLocaleString(); - }, [message.timestamp]); + const structured = parseStructuredAgentMessage(message.text); + // Only flag agent messages as rate-limited, not user's own quotes + const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text); + // Highlight messages containing API errors + const isApiError = message.text.includes('API Error'); + // Detect auth errors that may be resolved by restarting the team + const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text)); + // Never collapse rate limit messages as noise — they must be visible + const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; - const structured = parseStructuredAgentMessage(message.text); - // Only flag agent messages as rate-limited, not user's own quotes - const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text); - // Highlight messages containing API errors - const isApiError = message.text.includes('API Error'); - // Detect auth errors that may be resolved by restarting the team - const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text)); - // Never collapse rate limit messages as noise — they must be visible - const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; + const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; + const isManaged = collapseMode === 'managed'; + const isExpanded = isManaged ? !isCollapsed : true; - const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; - const isManaged = isManagedCollapseState(collapseState); - const isExpanded = isManaged ? !collapseState.isCollapsed : true; + const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]); + const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]); + const crossTeamSentTarget = useMemo( + () => getCrossTeamSentTarget(message.to, teamName, localMemberNames), + [message.to, teamName, localMemberNames] + ); + const crossTeamSentMemberName = useMemo( + () => getCrossTeamSentMemberName(message.to), + [message.to] + ); + const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null; + const isCrossTeamSent = + message.source === CROSS_TEAM_SENT_SOURCE || crossTeamSentTarget !== null; + const isCrossTeamAny = isCrossTeam || isCrossTeamSent; + const crossTeamOrigin = useMemo(() => { + if (!isCrossTeam) return null; + const fromValue = parsedCrossTeamPrefix?.from ?? message.from; + const dot = fromValue.indexOf('.'); + if (dot <= 0 || dot === fromValue.length - 1) return null; + return { + teamName: fromValue.substring(0, dot), + memberName: fromValue.substring(dot + 1), + }; + }, [isCrossTeam, message.from, parsedCrossTeamPrefix]); + const crossTeamTarget = useMemo(() => { + if (!isCrossTeamSent) return null; + if (crossTeamSentTarget) return crossTeamSentTarget; + if (qualifiedRecipient) return qualifiedRecipient.teamName; + if (!message.to) return null; + const dot = message.to.indexOf('.'); + if (dot <= 0) return message.to; + return message.to.substring(0, dot); + }, [crossTeamSentTarget, isCrossTeamSent, message.to, qualifiedRecipient]); + const senderName = crossTeamOrigin ? crossTeamOrigin.memberName : message.from; + const senderColor = crossTeamOrigin ? undefined : (memberColor ?? message.color); + const senderHideAvatar = + message.from === 'user' || + message.from === 'system' || + crossTeamOrigin?.memberName === 'user'; - const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]); - const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]); - const crossTeamSentTarget = useMemo( - () => getCrossTeamSentTarget(message.to, teamName, localMemberNames), - [message.to, teamName, localMemberNames] - ); - const crossTeamSentMemberName = useMemo( - () => getCrossTeamSentMemberName(message.to), - [message.to] - ); - const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null; - const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE || crossTeamSentTarget !== null; - const isCrossTeamAny = isCrossTeam || isCrossTeamSent; - const crossTeamOrigin = useMemo(() => { - if (!isCrossTeam) return null; - const fromValue = parsedCrossTeamPrefix?.from ?? message.from; - const dot = fromValue.indexOf('.'); - if (dot <= 0 || dot === fromValue.length - 1) return null; - return { - teamName: fromValue.substring(0, dot), - memberName: fromValue.substring(dot + 1), - }; - }, [isCrossTeam, message.from, parsedCrossTeamPrefix]); - const crossTeamTarget = useMemo(() => { - if (!isCrossTeamSent) return null; - if (crossTeamSentTarget) return crossTeamSentTarget; - if (qualifiedRecipient) return qualifiedRecipient.teamName; - if (!message.to) return null; - const dot = message.to.indexOf('.'); - if (dot <= 0) return message.to; - return message.to.substring(0, dot); - }, [crossTeamSentTarget, isCrossTeamSent, message.to, qualifiedRecipient]); - const senderName = crossTeamOrigin ? crossTeamOrigin.memberName : message.from; - const senderColor = crossTeamOrigin ? undefined : (memberColor ?? message.color); - const senderHideAvatar = - message.from === 'user' || message.from === 'system' || crossTeamOrigin?.memberName === 'user'; - - // Strip agent-only blocks + normalize escape sequences (before linkification) - const strippedText = useMemo(() => { - if (structured) return null; - let stripped = stripAgentBlocks(message.text).trim(); - if (!stripped) return null; // All content was agent-only blocks → show summary instead - // Strip cross-team metadata tag (e.g. `\n`) - // — kept in stored text for CLI agents / durable artifacts. - if (isCrossTeamAny) { - stripped = stripCrossTeamPrefix(stripped); - } - // Normalize literal \n from historical CLI-produced text to real newlines - return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); - }, [structured, message.text, isCrossTeamAny]); - - // Parse reply BEFORE linkification — linkifyAllMentionsInMarkdown transforms @name - // into markdown links which breaks the reply regex matcher - const parsedReply = useMemo( - () => (strippedText ? parseMessageReply(strippedText) : null), - [strippedText] - ); - - // Linkify task IDs (always, for TaskTooltip) + @mentions for display - const displayText = useMemo(() => { - if (!strippedText) return null; - let result = highlightSystemLabels(strippedText, !!systemLabel); - result = linkifyTaskIdsInMarkdown(result, message.taskRefs); - if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) - result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames); - return result; - }, [strippedText, memberColorMap, teamNames, systemLabel]); - - const crossTeamPreview = useMemo(() => { - if (!isCrossTeamAny || !strippedText) return ''; - const oneLine = strippedText.replace(/\n+/g, ' ').trim(); - if (!oneLine) return ''; - return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; - }, [isCrossTeamAny, strippedText]); - - const rawSummary = useMemo(() => { - if (crossTeamPreview) return crossTeamPreview; - const s = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; - if (s) return s; - // Fallback: use the beginning of message text as preview for plain-text messages - const plain = stripAgentBlocks(message.text).trim(); - if (!plain) return ''; - const oneLine = plain.replace(/\n+/g, ' '); - return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; - }, [crossTeamPreview, message.summary, structured, message.text]); - const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); - - // Noise messages: minimal inline row - if (noiseLabel) { - return ; - } - - const messageType = - structured && typeof structured.type === 'string' ? getMessageTypeLabel(structured.type) : null; - const autoSummary = structured ? getStructuredMessageSummary(structured) : null; - - const handleCreateTask = (): void => { - const subject = message.summary || autoSummary || `Task from ${message.from}`; - const plainText = structured - ? JSON.stringify(structured, null, 2) - : stripAgentBlocks(message.text); - const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000); - onCreateTask?.(subject, description); - }; - - const isHeaderClickable = isManaged ? collapseState.canToggle : false; - const showChevron = isHeaderClickable && !compactHeader; - const isUserSent = message.source === 'user_sent' || isCrossTeamSent; - const isSystemMessage = message.from === 'system'; - const onManagedToggle = isManaged ? collapseState.onToggle : undefined; - const handleHeaderToggle = isHeaderClickable - ? (): void => { - onManagedToggle?.(); + // Strip agent-only blocks + normalize escape sequences (before linkification) + const strippedText = useMemo(() => { + if (structured) return null; + let stripped = stripAgentBlocks(message.text).trim(); + if (!stripped) return null; // All content was agent-only blocks → show summary instead + // Strip cross-team metadata tag (e.g. `\n`) + // — kept in stored text for CLI agents / durable artifacts. + if (isCrossTeamAny) { + stripped = stripCrossTeamPrefix(stripped); } - : undefined; + // Normalize literal \n from historical CLI-produced text to real newlines + return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); + }, [structured, message.text, isCrossTeamAny]); - return ( -
- {/* Header — div with role=button (cannot use + + Reply to message + + ) : null} + {onCreateTask ? ( + + + + + Create task from message + + ) : null} + + + + { + const link = (e.target as HTMLElement).closest( + '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 + } + > + + + + + ) : summaryText ? ( +

+ {summaryText} +

+ ) : null} + {/* Auth error recovery action */} + {isAuthError && onRestartTeam ? ( +
+ +
+

+ Authentication failed. Restarting the team will refresh the session and may + resolve this issue. If the problem persists, check your API credentials or try + again later. +

+ +
+
+ ) : null} + {message.attachments?.length && message.messageId ? ( + ) : null} - + ) : null} - - {/* Summary */} - - {onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText} - - - {/* Timestamp */} -
- - {timestamp} - -
- - - {/* Content — collapsed for system messages, expanded for others */} - {isExpanded ? ( -
- {structured ? ( -
- {autoSummary && autoSummary !== messageType ? ( -

{autoSummary}

- ) : null} -
- - Raw JSON - -
-                  {JSON.stringify(structured, null, 2)}
-                
-
-
- ) : parsedReply ? ( - - ) : displayText ? ( -
-
- {onReply ? ( - - - - - Reply to message - - ) : null} - {onCreateTask ? ( - - - - - Create task from message - - ) : null} - -
- - { - const link = (e.target as HTMLElement).closest( - '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 - } - > - - - -
- ) : summaryText ? ( -

- {summaryText} -

- ) : null} - {/* Auth error recovery action */} - {isAuthError && onRestartTeam ? ( -
- -
-

- Authentication failed. Restarting the team will refresh the session and may - resolve this issue. If the problem persists, check your API credentials or try - again later. -

- -
-
- ) : null} - {message.attachments?.length && message.messageId ? ( - - ) : null} -
- ) : null} -
- ); -}; + + ); + }, + (prev, next) => + prev.teamName === next.teamName && + prev.localMemberNames === next.localMemberNames && + prev.memberRole === next.memberRole && + prev.memberColor === next.memberColor && + prev.recipientColor === next.recipientColor && + prev.isUnread === next.isUnread && + prev.memberColorMap === next.memberColorMap && + areStringArraysEqual(prev.teamNames, next.teamNames) && + areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && + prev.onTeamClick === next.onTeamClick && + prev.onMemberNameClick === next.onMemberNameClick && + prev.onCreateTask === next.onCreateTask && + prev.onReply === next.onReply && + prev.onTaskIdClick === next.onTaskIdClick && + prev.onRestartTeam === next.onRestartTeam && + prev.zebraShade === next.zebraShade && + prev.collapseMode === next.collapseMode && + prev.isCollapsed === next.isCollapsed && + prev.canToggleCollapse === next.canToggleCollapse && + prev.collapseToggleKey === next.collapseToggleKey && + prev.onToggleCollapse === next.onToggleCollapse && + prev.compactHeader === next.compactHeader && + areMessagesEquivalentForActivityItem(prev.message, next.message) +); diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 689707d9..0516466d 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,6 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { + areInboxMessagesEquivalentForRender, + areStringArraysEqual, + areStringMapsEqual, +} from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { Layers } from 'lucide-react'; @@ -16,7 +21,6 @@ import { } from './LeadThoughtsGroup'; import { useNewItemKeys } from './useNewItemKeys'; -import type { ActivityCollapseState } from './collapseState'; import type { TimelineItem } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; @@ -52,11 +56,35 @@ interface ActivityTimelineProps { teamSessionIds?: Set; /** Current lead session ID for the active team, if known. */ currentLeadSessionId?: string; + /** Whether the current team is alive. */ + isTeamAlive?: boolean; + /** Current lead activity status for the active team. */ + leadActivity?: string; + /** Latest lead context timestamp for the active team. */ + leadContextUpdatedAt?: string; + /** Team names used for mention/team-link rendering. */ + teamNames?: string[]; + /** Team color mapping used by markdown viewers. */ + teamColorByName?: ReadonlyMap; + /** Opens a team tab from cross-team badges or team:// links. */ + onTeamClick?: (teamName: 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(); +const EMPTY_LOCAL_MEMBER_NAMES = new Set(); +const EMPTY_TEAM_NAMES: string[] = []; +const EMPTY_TEAM_COLOR_MAP = new Map(); +const DEFAULT_COLLAPSE_MODE = 'default' as const; + +interface ItemCollapseProps { + collapseMode: 'default' | 'managed'; + isCollapsed: boolean; + canToggleCollapse: boolean; + collapseToggleKey?: string; +} /** Inline compaction boundary divider — styled like session separators but with amber accent. */ const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => ( @@ -98,8 +126,15 @@ const MessageRowWithObserver = ({ onVisible, onTaskIdClick, onRestartTeam, - collapseState, + collapseMode, + isCollapsed, + canToggleCollapse, + collapseToggleKey, + onToggleCollapse, compactHeader, + teamNames, + teamColorByName, + onTeamClick, }: { message: InboxMessage; teamName: string; @@ -117,8 +152,15 @@ const MessageRowWithObserver = ({ onVisible?: (message: InboxMessage) => void; onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; - collapseState?: ActivityCollapseState; + collapseMode: 'default' | 'managed'; + isCollapsed: boolean; + canToggleCollapse: boolean; + collapseToggleKey?: string; + onToggleCollapse?: (key: string) => void; compactHeader?: boolean; + teamNames?: string[]; + teamColorByName?: ReadonlyMap; + onTeamClick?: (teamName: string) => void; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -167,14 +209,51 @@ const MessageRowWithObserver = ({ onReply={onReply} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} - collapseState={collapseState} + collapseMode={collapseMode} + isCollapsed={isCollapsed} + canToggleCollapse={canToggleCollapse} + collapseToggleKey={collapseToggleKey} + onToggleCollapse={onToggleCollapse} compactHeader={compactHeader} + teamNames={teamNames} + teamColorByName={teamColorByName} + onTeamClick={onTeamClick} /> ); }; -export const ActivityTimeline = ({ +const MemoizedMessageRowWithObserver = React.memo( + MessageRowWithObserver, + (prev, next) => + prev.teamName === next.teamName && + prev.memberRole === next.memberRole && + prev.memberColor === next.memberColor && + prev.recipientColor === next.recipientColor && + prev.isUnread === next.isUnread && + prev.isNew === next.isNew && + prev.zebraShade === next.zebraShade && + prev.memberColorMap === next.memberColorMap && + prev.localMemberNames === next.localMemberNames && + prev.onMemberNameClick === next.onMemberNameClick && + prev.onCreateTask === next.onCreateTask && + prev.onReply === next.onReply && + prev.onVisible === next.onVisible && + prev.onTaskIdClick === next.onTaskIdClick && + prev.onRestartTeam === next.onRestartTeam && + prev.collapseMode === next.collapseMode && + prev.isCollapsed === next.isCollapsed && + prev.canToggleCollapse === next.canToggleCollapse && + prev.collapseToggleKey === next.collapseToggleKey && + prev.onToggleCollapse === next.onToggleCollapse && + prev.compactHeader === next.compactHeader && + areStringArraysEqual(prev.teamNames, next.teamNames) && + areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && + prev.onTeamClick === next.onTeamClick && + areInboxMessagesEquivalentForRender(prev.message, next.message) +); + +export const ActivityTimeline = React.memo(function ActivityTimeline({ messages, teamName, members, @@ -190,7 +269,13 @@ export const ActivityTimeline = ({ onToggleExpandOverride, teamSessionIds, currentLeadSessionId, -}: ActivityTimelineProps): React.JSX.Element => { + isTeamAlive, + leadActivity, + leadContextUpdatedAt, + teamNames = EMPTY_TEAM_NAMES, + teamColorByName = EMPTY_TEAM_COLOR_MAP, + onTeamClick, +}: ActivityTimelineProps): React.JSX.Element { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const rootRef = useRef(null); const [compactHeader, setCompactHeader] = useState(false); @@ -218,36 +303,53 @@ export const ActivityTimeline = ({ return () => observer.disconnect(); }, []); - const colorMap = members ? buildMemberColorMap(members) : new Map(); - const localMemberNames = new Set((members ?? []).map((member) => member.name.trim())); - const memberInfo = new Map(); - if (members) { - for (const m of members) { + 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(); + if (!members) return infoMap; + + for (const member of members) { const info = { - role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined), - color: colorMap.get(m.name), + role: + member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined), + color: colorMap.get(member.name), }; - memberInfo.set(m.name, info); - if (m.agentType && m.agentType !== m.name) { - memberInfo.set(m.agentType, info); + infoMap.set(member.name, info); + if (member.agentType && member.agentType !== member.name) { + infoMap.set(member.agentType, info); } } - // Map "user" to team-lead's resolved color and role + const leadMember = members.find( - (m) => m.agentType === 'team-lead' || m.role?.toLowerCase().includes('lead') + (member) => member.agentType === 'team-lead' || member.role?.toLowerCase().includes('lead') ); if (leadMember) { - const leadInfo = memberInfo.get(leadMember.name); + const leadInfo = infoMap.get(leadMember.name); if (leadInfo) { - memberInfo.set('user', { role: undefined, color: colorMap.get('user') }); + infoMap.set('user', { role: undefined, color: colorMap.get('user') }); } } - } - const handleMemberNameClick = (name: string): void => { - const member = members?.find((m) => m.name === name || m.agentType === name); - if (member) onMemberClick?.(member); - }; + return infoMap; + }, [members, colorMap]); + + const handleMemberNameClick = useCallback( + (name: string) => { + const member = members?.find( + (candidate) => candidate.name === name || candidate.agentType === name + ); + if (member) onMemberClick?.(member); + }, + [members, onMemberClick] + ); // Pagination counts only significant (non-thought) messages so that lead thoughts // don't consume the page limit — they collapse into a single visual group anyway. @@ -361,9 +463,9 @@ export const ActivityTimeline = ({ * In collapsed mode we always keep the newest real message open, keep the pinned * thought group open, and let localStorage overrides reopen older items. */ - const getItemCollapseState = useCallback( - (stableKey: string, itemIndex: number): ActivityCollapseState => - resolveTimelineCollapseState({ + const getItemCollapseProps = useCallback( + (stableKey: string, itemIndex: number): ItemCollapseProps => { + const collapseState = resolveTimelineCollapseState({ allCollapsed, itemIndex, newestMessageIndex, @@ -372,7 +474,23 @@ export const ActivityTimeline = ({ onToggleOverride: onToggleExpandOverride ? () => onToggleExpandOverride(stableKey) : undefined, - }), + }); + + if (collapseState.mode !== DEFAULT_COLLAPSE_MODE) { + return { + collapseMode: collapseState.mode, + isCollapsed: collapseState.isCollapsed, + canToggleCollapse: collapseState.canToggle, + collapseToggleKey: collapseState.canToggle ? stableKey : undefined, + }; + } + + return { + collapseMode: DEFAULT_COLLAPSE_MODE, + isCollapsed: false, + canToggleCollapse: false, + }; + }, [allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] ); @@ -395,21 +513,31 @@ export const ActivityTimeline = ({ const info = memberInfo.get(firstThought.from); const itemKey = getThoughtGroupKey(group); const stableKey = itemKey; - const collapseState = getItemCollapseState(stableKey, 0); + const collapseProps = getItemCollapseProps(stableKey, 0); return ( ); })()} @@ -455,7 +583,7 @@ export const ActivityTimeline = ({ const info = memberInfo.get(firstThought.from); const itemKey = getThoughtGroupKey(group); const stableKey = itemKey; - const collapseState = getItemCollapseState(stableKey, realIndex); + const collapseProps = getItemCollapseProps(stableKey, realIndex); return ( {sessionSeparator} @@ -463,14 +591,24 @@ export const ActivityTimeline = ({ group={group} memberColor={info?.color} canBeLive={false} + isTeamAlive={isTeamAlive} + leadActivity={leadActivity} + leadContextUpdatedAt={leadContextUpdatedAt} isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(realIndex)} - collapseState={collapseState} + collapseMode={collapseProps.collapseMode} + isCollapsed={collapseProps.isCollapsed} + canToggleCollapse={collapseProps.canToggleCollapse} + collapseToggleKey={collapseProps.collapseToggleKey} + onToggleCollapse={onToggleExpandOverride} onTaskIdClick={onTaskIdClick} memberColorMap={colorMap} onReply={onReplyToMessage} compactHeader={compactHeader} + teamNames={teamNames} + teamColorByName={teamColorByName} + onTeamClick={onTeamClick} /> ); @@ -495,14 +633,14 @@ export const ActivityTimeline = ({ recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); const messageKey = toMessageKey(message); const stableKey = messageKey; - const collapseState = getItemCollapseState(stableKey, realIndex); + const collapseProps = getItemCollapseProps(stableKey, realIndex); const isUnread = readState ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) : !message.read; return ( {sessionSeparator} - ); @@ -571,4 +716,4 @@ export const ActivityTimeline = ({ )} ); -}; +}); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index ac8853ca..74fbbb4a 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +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'; @@ -12,8 +12,12 @@ import { CARD_TEXT_LIGHT, } from '@renderer/constants/cssVariables'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -import { useStore } from '@renderer/store'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; +import { + areStringArraysEqual, + 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'; @@ -25,9 +29,7 @@ import { ENTRY_REVEAL_ANIMATION_MS, ENTRY_REVEAL_EASING, } from './AnimatedHeightReveal'; -import { isManagedCollapseState } from './collapseState'; -import type { ActivityCollapseState } from './collapseState'; import type { InboxMessage, ToolCallMeta } from '@shared/types'; export interface LeadThoughtGroup { @@ -116,14 +118,30 @@ interface LeadThoughtsGroupRowProps { onVisible?: (message: InboxMessage) => void; /** When false, the live indicator is always off (for historical thought groups). */ canBeLive?: boolean; + /** Whether the owning team is currently alive. */ + isTeamAlive?: boolean; + /** Current lead activity status for the owning team. */ + leadActivity?: string; + /** Latest lead context timestamp for the owning team. */ + leadContextUpdatedAt?: string; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; - /** Explicit collapse state for timeline-controlled collapsed mode. */ - collapseState?: ActivityCollapseState; + /** Collapsed-mode primitives stabilized by ActivityTimeline. */ + collapseMode: 'default' | 'managed'; + isCollapsed: boolean; + canToggleCollapse: boolean; + collapseToggleKey?: string; + onToggleCollapse?: (key: string) => void; /** Called when a task ID link (e.g. #10) is clicked in thought text. */ onTaskIdClick?: (taskId: string) => void; /** Map of member name → color name for @mention badge rendering. */ memberColorMap?: Map; + /** Team names used for mention/team-link rendering. */ + teamNames?: string[]; + /** Team color mapping used by markdown viewers. */ + teamColorByName?: ReadonlyMap; + /** Opens a team tab from cross-team badges or team:// links. */ + onTeamClick?: (teamName: string) => void; /** Called when user clicks the reply button on a thought. */ onReply?: (message: InboxMessage) => void; /** Compact header mode for narrow message lists. */ @@ -212,258 +230,355 @@ interface LeadThoughtItemProps { shouldAnimate: boolean; onTaskIdClick?: (taskId: string) => void; memberColorMap?: Map; + teamNames?: string[]; + teamColorByName?: ReadonlyMap; + onTeamClick?: (teamName: string) => void; onReply?: (message: InboxMessage) => void; } -const LeadThoughtItem = ({ - thought, - showDivider, - shouldAnimate, - onTaskIdClick, - memberColorMap, - onReply, -}: LeadThoughtItemProps): JSX.Element => { - const wrapperRef = useRef(null); - const contentRef = useRef(null); - const previousHeightRef = useRef(null); - const animationFrameRef = useRef(null); - const cleanupTimerRef = useRef(null); - const initialAnimationCompletedRef = useRef(!shouldAnimate); - const [shouldAnimateOnMount] = useState(() => shouldAnimate); +function hasSelectionWithin(container: HTMLElement | null): boolean { + if (!container) return false; + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return false; + } - const teams = useStore((s) => s.teams); - const teamNames = useMemo( - () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), - [teams] + const anchorNode = selection.anchorNode; + const focusNode = selection.focusNode; + return ( + (!!anchorNode && container.contains(anchorNode)) || + (!!focusNode && container.contains(focusNode)) ); +} - 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); +function areThoughtGroupsEquivalent(prev: LeadThoughtGroup, next: LeadThoughtGroup): boolean { + if (prev === next) return true; + if (getThoughtGroupKey(prev) !== getThoughtGroupKey(next)) return false; + if (prev.thoughts.length !== next.thoughts.length) return false; + for (let i = 0; i < prev.thoughts.length; i++) { + if (!areThoughtMessagesEquivalentForRender(prev.thoughts[i], next.thoughts[i])) { + return false; } - return text; - }, [thought.text, memberColorMap, teamNames]); + } + return true; +} - const clearPendingAnimation = useCallback(() => { - if (animationFrameRef.current !== null) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - if (cleanupTimerRef.current !== null) { - window.clearTimeout(cleanupTimerRef.current); - cleanupTimerRef.current = null; - } - }, []); +const LeadThoughtItem = memo( + function LeadThoughtItem({ + thought, + showDivider, + shouldAnimate, + onTaskIdClick, + memberColorMap, + teamNames = [], + teamColorByName, + onTeamClick, + onReply, + }: LeadThoughtItemProps): JSX.Element { + const wrapperRef = useRef(null); + const contentRef = useRef(null); + const previousHeightRef = useRef(null); + const animationFrameRef = useRef(null); + const cleanupTimerRef = useRef(null); + const initialAnimationCompletedRef = useRef(!shouldAnimate); + const [shouldAnimateOnMount] = useState(() => shouldAnimate); - const resetWrapperStyles = useCallback(() => { - const wrapper = wrapperRef.current; - if (!wrapper) return; - wrapper.style.height = 'auto'; - wrapper.style.opacity = '1'; - wrapper.style.overflow = 'visible'; - wrapper.style.transition = ''; - wrapper.style.willChange = ''; - }, []); - - useLayoutEffect(() => { - const wrapper = wrapperRef.current; - const content = contentRef.current; - if (!wrapper || !content) return; - - const applyTransition = (targetHeight: number): void => { - wrapper.style.transition = [ - `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ${ENTRY_REVEAL_EASING}`, - `opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`, - ].join(', '); - wrapper.style.height = `${Math.max(targetHeight, 0)}px`; - wrapper.style.opacity = '1'; - }; - - const scheduleTransition = (targetHeight: number): void => { - animationFrameRef.current = requestAnimationFrame(() => { - applyTransition(targetHeight); - }); - }; - - const animateHeight = ( - targetHeight: number, - startHeight: number, - startOpacity: number - ): void => { - initialAnimationCompletedRef.current = false; - clearPendingAnimation(); - wrapper.style.transition = 'none'; - wrapper.style.overflow = 'hidden'; - wrapper.style.height = `${Math.max(startHeight, 0)}px`; - wrapper.style.opacity = `${startOpacity}`; - wrapper.style.willChange = 'height, opacity'; - // Force layout reflow so the browser registers the starting values - const _reflow = wrapper.offsetHeight; - if (_reflow < -1) return; // unreachable — prevents unused-variable lint - - animationFrameRef.current = requestAnimationFrame(() => { - scheduleTransition(targetHeight); - }); - - cleanupTimerRef.current = window.setTimeout(() => { - resetWrapperStyles(); - initialAnimationCompletedRef.current = true; - cleanupTimerRef.current = null; - }, THOUGHT_HEIGHT_ANIMATION_MS + 40); - }; - - const syncHeight = (nextHeight: number, animateFromZero: boolean): void => { - const previousHeight = previousHeightRef.current; - previousHeightRef.current = nextHeight; - - if (!shouldAnimateOnMount) { - initialAnimationCompletedRef.current = true; - resetWrapperStyles(); - return; + 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]); - if (previousHeight === null) { - if (nextHeight > 0 && animateFromZero) { - animateHeight(nextHeight, 0, 0); - } else { + const clearPendingAnimation = useCallback(() => { + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (cleanupTimerRef.current !== null) { + window.clearTimeout(cleanupTimerRef.current); + cleanupTimerRef.current = null; + } + }, []); + + const resetWrapperStyles = useCallback(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + wrapper.style.height = 'auto'; + wrapper.style.opacity = '1'; + wrapper.style.overflow = 'visible'; + wrapper.style.transition = ''; + wrapper.style.willChange = ''; + }, []); + + useLayoutEffect(() => { + const wrapper = wrapperRef.current; + const content = contentRef.current; + if (!wrapper || !content) return; + + const applyTransition = (targetHeight: number): void => { + wrapper.style.transition = [ + `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ${ENTRY_REVEAL_EASING}`, + `opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`, + ].join(', '); + wrapper.style.height = `${Math.max(targetHeight, 0)}px`; + wrapper.style.opacity = '1'; + }; + + const scheduleTransition = (targetHeight: number): void => { + animationFrameRef.current = requestAnimationFrame(() => { + applyTransition(targetHeight); + }); + }; + + const animateHeight = ( + targetHeight: number, + startHeight: number, + startOpacity: number + ): void => { + initialAnimationCompletedRef.current = false; + clearPendingAnimation(); + wrapper.style.transition = 'none'; + wrapper.style.overflow = 'hidden'; + wrapper.style.height = `${Math.max(startHeight, 0)}px`; + wrapper.style.opacity = `${startOpacity}`; + wrapper.style.willChange = 'height, opacity'; + // Force layout reflow so the browser registers the starting values + const _reflow = wrapper.offsetHeight; + if (_reflow < -1) return; // unreachable — prevents unused-variable lint + + animationFrameRef.current = requestAnimationFrame(() => { + scheduleTransition(targetHeight); + }); + + cleanupTimerRef.current = window.setTimeout(() => { + resetWrapperStyles(); + initialAnimationCompletedRef.current = true; + cleanupTimerRef.current = null; + }, THOUGHT_HEIGHT_ANIMATION_MS + 40); + }; + + const syncHeight = (nextHeight: number, animateFromZero: boolean): void => { + const previousHeight = previousHeightRef.current; + previousHeightRef.current = nextHeight; + + if (!shouldAnimateOnMount) { initialAnimationCompletedRef.current = true; resetWrapperStyles(); + return; } - return; - } - if (Math.abs(nextHeight - previousHeight) < 1) return; + if (previousHeight === null) { + if (nextHeight > 0 && animateFromZero) { + animateHeight(nextHeight, 0, 0); + } else { + initialAnimationCompletedRef.current = true; + resetWrapperStyles(); + } + return; + } - // Only the first reveal should animate. Late content growth (for example when - // tool summary metadata appears after the text) should resize naturally. - if (initialAnimationCompletedRef.current) { + if (Math.abs(nextHeight - previousHeight) < 1) return; + + // Only the first reveal should animate. Late content growth (for example when + // tool summary metadata appears after the text) should resize naturally. + if (initialAnimationCompletedRef.current) { + resetWrapperStyles(); + return; + } + + const renderedHeight = wrapper.getBoundingClientRect().height; + animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1); + }; + + syncHeight(content.getBoundingClientRect().height, true); + + const observer = new ResizeObserver((entries) => { + const nextHeight = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height; + syncHeight(nextHeight, false); + }); + observer.observe(content); + + return () => { + observer.disconnect(); + clearPendingAnimation(); + initialAnimationCompletedRef.current = true; resetWrapperStyles(); - return; - } + }; + }, [clearPendingAnimation, resetWrapperStyles, shouldAnimateOnMount]); - const renderedHeight = wrapper.getBoundingClientRect().height; - animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1); - }; + useEffect( + () => () => { + clearPendingAnimation(); + }, + [clearPendingAnimation] + ); - syncHeight(content.getBoundingClientRect().height, true); + return ( +
+
+ {showDivider && ( +
+ + {formatTimeWithSec(thought.timestamp)} + +
+ )} +
+
+ { + const link = (e.target as HTMLElement).closest( + '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 + } + > + + +
+
+ {onReply ? ( + + + + + Reply + + ) : null} + +
+
+ {thought.toolSummary && ( + + +
+ 🔧 {thought.toolSummary} +
+
+ + + +
+ )} +
+
+ ); + }, + (prev, next) => + prev.showDivider === next.showDivider && + prev.shouldAnimate === next.shouldAnimate && + prev.onTaskIdClick === next.onTaskIdClick && + prev.memberColorMap === next.memberColorMap && + areStringArraysEqual(prev.teamNames, next.teamNames) && + areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && + prev.onTeamClick === next.onTeamClick && + prev.onReply === next.onReply && + areThoughtMessagesEquivalentForRender(prev.thought, next.thought) +); - const observer = new ResizeObserver((entries) => { - const nextHeight = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height; - syncHeight(nextHeight, false); - }); - observer.observe(content); - - return () => { - observer.disconnect(); - clearPendingAnimation(); - initialAnimationCompletedRef.current = true; - resetWrapperStyles(); - }; - }, [clearPendingAnimation, resetWrapperStyles, shouldAnimateOnMount]); - - useEffect( - () => () => { - clearPendingAnimation(); - }, - [clearPendingAnimation] +const LiveThoughtStatusBadge = ({ + canBeLive, + isTeamAlive, + leadActivity, + leadContextUpdatedAt, + newestTimestamp, +}: { + canBeLive?: boolean; + isTeamAlive?: boolean; + leadActivity?: string; + leadContextUpdatedAt?: string; + newestTimestamp: string; +}): JSX.Element | null => { + const computeIsLive = useCallback( + () => + canBeLive !== false && + !!isTeamAlive && + (leadActivity === 'active' || + (leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) || + isRecentTimestamp(newestTimestamp)), + [canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newestTimestamp] ); + const [isLive, setIsLive] = useState(computeIsLive); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap + setIsLive(computeIsLive()); + const id = window.setInterval(() => setIsLive(computeIsLive()), 1000); + return () => window.clearInterval(id); + }, [computeIsLive]); + + if (!isLive) return null; + return ( -
-
- {showDivider && ( -
- - {formatTimeWithSec(thought.timestamp)} - -
- )} -
-
- { - const link = (e.target as HTMLElement).closest( - '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 - } - > - - -
-
- {onReply ? ( - - - - - Reply - - ) : null} - -
-
- {thought.toolSummary && ( - - -
- 🔧 {thought.toolSummary} -
-
- - - -
- )} -
-
+ + + + ); }; -export const LeadThoughtsGroupRow = ({ +const LeadThoughtsGroupRowComponent = ({ group, memberColor, isNew, onVisible, canBeLive, + isTeamAlive, + leadActivity, + leadContextUpdatedAt, zebraShade, - collapseState, + collapseMode, + isCollapsed, + canToggleCollapse, + collapseToggleKey, + onToggleCollapse, onTaskIdClick, memberColorMap, + teamNames = [], + teamColorByName, + onTeamClick, onReply, compactHeader = false, }: LeadThoughtsGroupRowProps): React.JSX.Element => { @@ -473,15 +588,6 @@ export const LeadThoughtsGroupRow = ({ const isUserScrolledUpRef = useRef(false); const distanceFromBottomRef = useRef(0); const scrollSyncFrameRef = useRef(null); - const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); - const leadActivity = useStore((s) => { - const teamName = s.selectedTeamName; - return teamName ? s.leadActivityByTeam[teamName] : undefined; - }); - const leadContextUpdatedAt = useStore((s) => { - const teamName = s.selectedTeamName; - return teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined; - }); const colors = getTeamColorSet(memberColor ?? ''); const { thoughts } = group; @@ -531,34 +637,17 @@ export const LeadThoughtsGroupRow = ({ return null; }, [thoughts]); - // Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought) - const computeIsLive = useCallback( - () => - canBeLive !== false && - isTeamAlive && - (leadActivity === 'active' || - (leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) || - isRecentTimestamp(newest.timestamp)), - [canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp] - ); - const [isLive, setIsLive] = useState(computeIsLive); const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); - const isManaged = isManagedCollapseState(collapseState); - const isBodyVisible = isManaged ? !collapseState.isCollapsed : true; - const canToggleBodyVisibility = isManaged && collapseState.canToggle; - const handleBodyToggle = canToggleBodyVisibility - ? (): void => { - collapseState.onToggle?.(); - } - : undefined; - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap - setIsLive(computeIsLive()); - const id = window.setInterval(() => setIsLive(computeIsLive()), 1000); - return () => window.clearInterval(id); - }, [computeIsLive]); + const isManaged = collapseMode === 'managed'; + const isBodyVisible = isManaged ? !isCollapsed : true; + const canToggleBodyVisibility = isManaged && canToggleCollapse; + const handleBodyToggle = useCallback(() => { + if (canToggleBodyVisibility && collapseToggleKey) { + onToggleCollapse?.(collapseToggleKey); + } + }, [canToggleBodyVisibility, collapseToggleKey, onToggleCollapse]); + const shouldAnimateLatestThought = canBeLive !== false && isRecentTimestamp(newest.timestamp); // Track how many thoughts have been reported as visible so far. const reportedCountRef = useRef(0); @@ -600,6 +689,10 @@ export const LeadThoughtsGroupRow = ({ scrollSyncFrameRef.current = null; return; } + if (hasSelectionWithin(scrollEl)) { + scrollSyncFrameRef.current = null; + return; + } const nextScrollTop = mode === 'bottom' @@ -689,7 +782,7 @@ export const LeadThoughtsGroupRow = ({ requestAnimationFrame(() => { requestAnimationFrame(() => { const scrollEl = scrollRef.current; - if (scrollEl) { + if (scrollEl && !hasSelectionWithin(scrollEl)) { scrollEl.scrollTop = scrollEl.scrollHeight; } ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); @@ -747,12 +840,13 @@ export const LeadThoughtsGroupRow = ({ className="size-5 rounded-full bg-[var(--color-surface-raised)]" loading="lazy" /> - {isLive ? ( - - - - - ) : null} + ) : null} @@ -822,9 +916,14 @@ export const LeadThoughtsGroupRow = ({ key={toMessageKey(thought)} thought={thought} showDivider={idx > 0} - shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} + shouldAnimate={ + shouldAnimateLatestThought && idx === chronologicalThoughts.length - 1 + } onTaskIdClick={onTaskIdClick} memberColorMap={memberColorMap} + teamNames={teamNames} + teamColorByName={teamColorByName} + onTeamClick={onTeamClick} onReply={onReply} /> ))} @@ -871,3 +970,29 @@ export const LeadThoughtsGroupRow = ({ ); }; + +export const LeadThoughtsGroupRow = memo( + LeadThoughtsGroupRowComponent, + (prev, next) => + prev.memberColor === next.memberColor && + prev.isNew === next.isNew && + prev.onVisible === next.onVisible && + prev.canBeLive === next.canBeLive && + prev.isTeamAlive === next.isTeamAlive && + prev.leadActivity === next.leadActivity && + prev.leadContextUpdatedAt === next.leadContextUpdatedAt && + prev.zebraShade === next.zebraShade && + prev.collapseMode === next.collapseMode && + prev.isCollapsed === next.isCollapsed && + prev.canToggleCollapse === next.canToggleCollapse && + prev.collapseToggleKey === next.collapseToggleKey && + prev.onToggleCollapse === next.onToggleCollapse && + prev.onTaskIdClick === next.onTaskIdClick && + prev.memberColorMap === next.memberColorMap && + areStringArraysEqual(prev.teamNames, next.teamNames) && + areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && + prev.onTeamClick === next.onTeamClick && + prev.onReply === next.onReply && + prev.compactHeader === next.compactHeader && + areThoughtGroupsEquivalent(prev.group, next.group) +); diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 7acdcb91..4dbacd1d 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; +import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta'; import { useStore } from '@renderer/store'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; @@ -48,6 +49,10 @@ interface MessagesPanelProps { messages: InboxMessage[]; /** Whether the team is alive. */ isTeamAlive?: boolean; + /** Live lead activity status for the current team. */ + leadActivity?: string; + /** Latest lead context timestamp for the current team. */ + leadContextUpdatedAt?: string; /** Time window for filtering. */ timeWindow: TimeWindow | null; /** Team session IDs for timeline. */ @@ -72,7 +77,7 @@ interface MessagesPanelProps { onTaskIdClick?: (taskId: string) => void; } -export const MessagesPanel = ({ +export const MessagesPanel = memo(function MessagesPanel({ teamName, position, onTogglePosition, @@ -80,6 +85,8 @@ export const MessagesPanel = ({ tasks, messages, isTeamAlive, + leadActivity, + leadContextUpdatedAt, timeWindow, teamSessionIds, currentLeadSessionId, @@ -91,12 +98,14 @@ export const MessagesPanel = ({ onReplyToMessage, onRestartTeam, onTaskIdClick, -}: MessagesPanelProps): React.JSX.Element => { +}: MessagesPanelProps): React.JSX.Element { const sendTeamMessage = useStore((s) => s.sendTeamMessage); const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage); const sendingMessage = useStore((s) => s.sendingMessage); const sendMessageError = useStore((s) => s.sendMessageError); const lastSendMessageResult = useStore((s) => s.lastSendMessageResult); + const teams = useStore((s) => s.teams); + const openTeamTab = useStore((s) => s.openTeamTab); const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); const [messagesFilter, setMessagesFilter] = useState({ @@ -129,6 +138,10 @@ export const MessagesPanel = ({ [markRead] ); + const readState = useMemo(() => ({ readSet, getMessageKey: toMessageKey }), [readSet]); + + const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); + const handleMarkAllRead = useCallback(() => { const keys = filteredMessages .filter((m) => !m.read && !readSet.has(toMessageKey(m))) @@ -290,12 +303,18 @@ export const MessagesPanel = ({ messages={filteredMessages} teamName={teamName} members={members} - readState={{ readSet, getMessageKey: toMessageKey }} + readState={readState} allCollapsed={messagesCollapsed} expandOverrides={expandedSet} onToggleExpandOverride={toggleExpandOverride} teamSessionIds={teamSessionIds} currentLeadSessionId={currentLeadSessionId} + isTeamAlive={isTeamAlive} + leadActivity={leadActivity} + leadContextUpdatedAt={leadContextUpdatedAt} + teamNames={teamNames} + teamColorByName={teamColorByName} + onTeamClick={openTeamTab} onMemberClick={onMemberClick} onCreateTaskFromMessage={onCreateTaskFromMessage} onReplyToMessage={onReplyToMessage} @@ -499,4 +518,4 @@ export const MessagesPanel = ({ {messagesContent} ); -}; +}); diff --git a/src/renderer/components/team/review/ChangesLoadingAnimation.tsx b/src/renderer/components/team/review/ChangesLoadingAnimation.tsx index 748bfa65..4ccfd09e 100644 --- a/src/renderer/components/team/review/ChangesLoadingAnimation.tsx +++ b/src/renderer/components/team/review/ChangesLoadingAnimation.tsx @@ -1,83 +1,243 @@ -import { FileCode, FileDiff, FileText, GitCommit, RefreshCw } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { FileCode, FileDiff, FileText, GitBranch, GitCommit, RefreshCw } from 'lucide-react'; -const floatingIcons = [ - { Icon: FileText, delay: '0s', x: -40, y: -30 }, - { Icon: FileDiff, delay: '0.4s', x: 35, y: -25 }, - { Icon: FileCode, delay: '0.8s', x: -30, y: 20 }, - { Icon: GitCommit, delay: '1.2s', x: 40, y: 15 }, - { Icon: RefreshCw, delay: '1.6s', x: 0, y: -40 }, +const orbitIcons = [ + { Icon: FileText, orbitRadius: 52, speed: 12, startAngle: 0, size: 15 }, + { Icon: FileDiff, orbitRadius: 52, speed: 12, startAngle: 72, size: 14 }, + { Icon: FileCode, orbitRadius: 52, speed: 12, startAngle: 144, size: 15 }, + { Icon: GitCommit, orbitRadius: 52, speed: 12, startAngle: 216, size: 13 }, + { Icon: GitBranch, orbitRadius: 52, speed: 12, startAngle: 288, size: 14 }, ]; -export const ChangesLoadingAnimation = (): React.JSX.Element => { - return ( -
- {/* Animated icons cluster */} -
- {/* Central pulsing ring */} -
-
+const particles = Array.from({ length: 8 }, (_, i) => ({ + id: i, + delay: i * 0.6, + duration: 3 + (i % 3) * 0.8, + startAngle: i * 45, + radius: 34 + (i % 3) * 14, +})); - {/* Floating file icons */} - {floatingIcons.map(({ Icon, delay, x, y }, i) => ( +const messages = ['Analyzing files…', 'Computing diffs…', 'Loading changes…', 'Resolving hunks…']; + +export const ChangesLoadingAnimation = (): React.JSX.Element => { + const [msgIndex, setMsgIndex] = useState(0); + const [isFading, setIsFading] = useState(false); + + useEffect(() => { + const interval = setInterval(() => { + setIsFading(true); + setTimeout(() => { + setMsgIndex((prev) => (prev + 1) % messages.length); + setIsFading(false); + }, 300); + }, 2400); + return () => clearInterval(interval); + }, []); + + return ( +
+ {/* Main animation container */} +
+ {/* Outer rotating ring */} + + + + + {/* Inner rotating ring (counter) */} + + + + + {/* Particles on inner orbit */} + {particles.map((p) => ( +
+ ))} + + {/* Orbiting icons */} + {orbitIcons.map(({ Icon, orbitRadius, speed, startAngle, size }, i) => (
- +
+ +
))} - {/* Center icon */} + {/* Glow pulse behind center */} +
- + className="absolute size-16 rounded-2xl bg-[var(--color-text-muted)] opacity-[0.03]" + style={{ animation: 'changesGlowPulse 3s ease-in-out infinite 0.5s' }} + /> + + {/* Center icon block */} +
+ +
+ + {/* Scanning beam */} +
+
- {/* Progress bar */} -
-
+ {/* Segmented progress */} +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+
+
+ ))}
- {/* Text */} + {/* Rotating message */}

- Loading changes... + {messages[msgIndex]}

diff --git a/src/renderer/hooks/useStableTeamMentionMeta.ts b/src/renderer/hooks/useStableTeamMentionMeta.ts new file mode 100644 index 00000000..437df493 --- /dev/null +++ b/src/renderer/hooks/useStableTeamMentionMeta.ts @@ -0,0 +1,93 @@ +import { useMemo, useRef } from 'react'; + +import type { TeamSummary } from '@shared/types'; + +const EMPTY_TEAM_NAMES: string[] = []; +const EMPTY_TEAM_COLOR_MAP = new Map(); + +interface TeamMentionEntry { + teamName: string; + displayName: string; + color: string; + deletedAt: string; +} + +export interface TeamMentionMeta { + teamNames: string[]; + teamColorByName: ReadonlyMap; +} + +function buildTeamMentionEntries(teams: readonly TeamSummary[]): TeamMentionEntry[] { + return teams.map((team) => ({ + teamName: team.teamName ?? '', + displayName: team.displayName ?? '', + color: team.color ?? '', + deletedAt: team.deletedAt ?? '', + })); +} + +function areTeamMentionEntriesEqual( + prev: readonly TeamMentionEntry[], + next: readonly TeamMentionEntry[] +): boolean { + if (prev === next) return true; + if (prev.length !== next.length) return false; + + for (let i = 0; i < prev.length; i++) { + const prevEntry = prev[i]; + const nextEntry = next[i]; + if ( + prevEntry.teamName !== nextEntry.teamName || + prevEntry.displayName !== nextEntry.displayName || + prevEntry.color !== nextEntry.color || + prevEntry.deletedAt !== nextEntry.deletedAt + ) { + return false; + } + } + + return true; +} + +function buildTeamMentionMeta(entries: readonly TeamMentionEntry[]): TeamMentionMeta { + if (entries.length === 0) { + return { teamNames: EMPTY_TEAM_NAMES, teamColorByName: EMPTY_TEAM_COLOR_MAP }; + } + + const teamNames: string[] = []; + const teamColorByName = new Map(); + + for (const entry of entries) { + if (!entry.deletedAt && entry.teamName) { + teamNames.push(entry.teamName); + } + + if (entry.teamName) { + teamColorByName.set(entry.teamName, entry.color); + } + if (entry.displayName) { + teamColorByName.set(entry.displayName, entry.color); + } + } + + return { teamNames, teamColorByName }; +} + +export function useStableTeamMentionMeta(teams: readonly TeamSummary[]): TeamMentionMeta { + const entries = useMemo(() => buildTeamMentionEntries(teams), [teams]); + const stableRef = useRef<{ entries: readonly TeamMentionEntry[]; value: TeamMentionMeta } | null>( + null + ); + + if ( + stableRef.current === null || + !areTeamMentionEntriesEqual(stableRef.current.entries, entries) + ) { + stableRef.current = { + entries, + value: buildTeamMentionMeta(entries), + }; + } + + return stableRef.current.value; +} diff --git a/src/renderer/utils/messageRenderEquality.ts b/src/renderer/utils/messageRenderEquality.ts new file mode 100644 index 00000000..3bece4ad --- /dev/null +++ b/src/renderer/utils/messageRenderEquality.ts @@ -0,0 +1,131 @@ +import { toMessageKey } from '@renderer/utils/teamMessageKey'; + +import type { AttachmentMeta, InboxMessage, TaskRef, ToolCallMeta } from '@shared/types'; + +export function areStringArraysEqual( + prev: readonly string[] | undefined, + next: readonly string[] | undefined +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + if (prev.length !== next.length) return false; + + for (let i = 0; i < prev.length; i++) { + if (prev[i] !== next[i]) return false; + } + + return true; +} + +export function areStringMapsEqual( + prev: ReadonlyMap | undefined, + next: ReadonlyMap | undefined +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + if (prev.size !== next.size) return false; + + for (const [key, value] of prev) { + if (next.get(key) !== value) return false; + } + + return true; +} + +export function areTaskRefsEqual(prev?: readonly TaskRef[], next?: readonly TaskRef[]): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + if (prev.length !== next.length) return false; + + for (let i = 0; i < prev.length; i++) { + if ( + prev[i].taskId !== next[i].taskId || + prev[i].displayId !== next[i].displayId || + prev[i].teamName !== next[i].teamName + ) { + return false; + } + } + + return true; +} + +export function areAttachmentsEqual( + prev?: readonly AttachmentMeta[], + next?: readonly AttachmentMeta[] +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + if (prev.length !== next.length) return false; + + for (let i = 0; i < prev.length; i++) { + if ( + prev[i].id !== next[i].id || + prev[i].filename !== next[i].filename || + prev[i].mimeType !== next[i].mimeType || + prev[i].size !== next[i].size + ) { + return false; + } + } + + return true; +} + +export function areToolCallsEqual( + prev?: readonly ToolCallMeta[], + next?: readonly ToolCallMeta[] +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + if (prev.length !== next.length) return false; + + for (let i = 0; i < prev.length; i++) { + if (prev[i].name !== next[i].name || prev[i].preview !== next[i].preview) { + return false; + } + } + + return true; +} + +export function areInboxMessagesEquivalentForRender( + prev: InboxMessage, + next: InboxMessage +): boolean { + if (prev === next) return true; + if (toMessageKey(prev) !== toMessageKey(next)) return false; + if (prev.messageId !== next.messageId) return false; + if (prev.timestamp !== next.timestamp) return false; + if (prev.from !== next.from) return false; + if (prev.to !== next.to) return false; + if (prev.text !== next.text) return false; + if (prev.summary !== next.summary) return false; + if (prev.color !== next.color) return false; + if (prev.read !== next.read) return false; + if (prev.source !== next.source) return false; + if (prev.leadSessionId !== next.leadSessionId) return false; + if (prev.toolSummary !== next.toolSummary) return false; + + return ( + areTaskRefsEqual(prev.taskRefs, next.taskRefs) && + areAttachmentsEqual(prev.attachments, next.attachments) + ); +} + +export function areThoughtMessagesEquivalentForRender( + prev: InboxMessage, + next: InboxMessage +): boolean { + if (prev === next) return true; + if (toMessageKey(prev) !== toMessageKey(next)) return false; + if (prev.messageId !== next.messageId) return false; + if (prev.timestamp !== next.timestamp) return false; + if (prev.text !== next.text) return false; + if (prev.toolSummary !== next.toolSummary) return false; + + return ( + areTaskRefsEqual(prev.taskRefs, next.taskRefs) && + areToolCallsEqual(prev.toolCalls, next.toolCalls) + ); +}