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.
This commit is contained in:
iliya 2026-03-14 12:16:16 +02:00
parent c55787d6e1
commit 038c3f8bb4
11 changed files with 1687 additions and 878 deletions

View file

@ -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: "<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: "<taskId>", note?: "<optional 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.`,

View file

@ -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: "<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: "<taskId>", note?: "<optional note>", notifyOwner: true }

View file

@ -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<string, string>;
/** 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<string, string>();
const EMPTY_SEARCH_MATCHES: SearchMatch[] = [];
const NOOP_TEAM_CLICK = (): void => undefined;
// =============================================================================
// Helpers
// =============================================================================
@ -517,15 +527,16 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
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<string, string>();
for (const team of teams) {
if (team.teamName) {
@ -537,6 +548,9 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}
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<MarkdownViewerProps> = ({
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<MarkdownViewerProps> = ({
// 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

View file

@ -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}
/>
</div>
</div>
@ -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}
/>
)}

File diff suppressed because it is too large Load diff

View file

@ -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<string>;
/** 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<string, string>;
/** 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<string, string>();
const EMPTY_LOCAL_MEMBER_NAMES = new Set<string>();
const EMPTY_TEAM_NAMES: string[] = [];
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
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<string, string>;
onTeamClick?: (teamName: string) => void;
}): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(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}
/>
</AnimatedHeightReveal>
);
};
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<HTMLDivElement>(null);
const [compactHeader, setCompactHeader] = useState(false);
@ -218,36 +303,53 @@ export const ActivityTimeline = ({
return () => observer.disconnect();
}, []);
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
const localMemberNames = new Set((members ?? []).map((member) => member.name.trim()));
const memberInfo = new Map<string, { role?: string; color?: string }>();
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<string, { role?: string; color?: string }>();
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 (
<LeadThoughtsGroupRow
key={itemKey}
group={group}
memberColor={info?.color}
canBeLive={true}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(0)}
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}
/>
);
})()}
@ -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 (
<React.Fragment key={itemKey}>
{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}
/>
</React.Fragment>
);
@ -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 (
<React.Fragment key={messageKey}>
{sessionSeparator}
<MessageRowWithObserver
<MemoizedMessageRowWithObserver
message={message}
teamName={teamName}
memberRole={info?.role}
@ -519,8 +657,15 @@ export const ActivityTimeline = ({
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
collapseState={collapseState}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</React.Fragment>
);
@ -571,4 +716,4 @@ export const ActivityTimeline = ({
)}
</div>
);
};
});

View file

@ -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<string, string>;
/** Team names used for mention/team-link rendering. */
teamNames?: string[];
/** Team color mapping used by markdown viewers. */
teamColorByName?: ReadonlyMap<string, string>;
/** 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<string, string>;
teamNames?: string[];
teamColorByName?: ReadonlyMap<string, string>;
onTeamClick?: (teamName: string) => void;
onReply?: (message: InboxMessage) => void;
}
const LeadThoughtItem = ({
thought,
showDivider,
shouldAnimate,
onTaskIdClick,
memberColorMap,
onReply,
}: LeadThoughtItemProps): JSX.Element => {
const wrapperRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const previousHeightRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null);
const cleanupTimerRef = useRef<number | null>(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<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const previousHeightRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null);
const cleanupTimerRef = useRef<number | null>(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 (
<div ref={wrapperRef}>
<div ref={contentRef}>
{showDivider && (
<div className="py-px text-center">
<span className="font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
{formatTimeWithSec(thought.timestamp)}
</span>
</div>
)}
<div className="group/thought relative flex text-[11px]">
<div
className="min-w-0 flex-1 [&>span>div>div>div]:py-2"
style={{ color: CARD_TEXT_LIGHT }}
>
<span
onClickCapture={
onTaskIdClick
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'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
}
>
<MarkdownViewer
content={displayContent}
maxHeight="max-h-none"
bare
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</span>
</div>
<div className="absolute right-1 top-0.5 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/thought:opacity-100">
{onReply ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
onReply(thought);
}}
>
<Reply size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Reply</TooltipContent>
</Tooltip>
) : null}
<CopyButton text={thought.text} inline />
</div>
</div>
{thought.toolSummary && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="mb-[7px] cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
🔧 {thought.toolSummary}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-[420px] font-mono text-[11px]"
>
<ToolSummaryTooltipContent
toolCalls={thought.toolCalls}
toolSummary={thought.toolSummary}
/>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
},
(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 (
<div ref={wrapperRef}>
<div ref={contentRef}>
{showDivider && (
<div className="py-px text-center">
<span className="font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
{formatTimeWithSec(thought.timestamp)}
</span>
</div>
)}
<div className="group/thought relative flex text-[11px]">
<div
className="min-w-0 flex-1 [&>span>div>div>div]:py-2"
style={{ color: CARD_TEXT_LIGHT }}
>
<span
onClickCapture={
onTaskIdClick
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'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
}
>
<MarkdownViewer content={displayContent} maxHeight="max-h-none" bare />
</span>
</div>
<div className="absolute right-1 top-0.5 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/thought:opacity-100">
{onReply ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
onReply(thought);
}}
>
<Reply size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Reply</TooltipContent>
</Tooltip>
) : null}
<CopyButton text={thought.text} inline />
</div>
</div>
{thought.toolSummary && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="mb-[7px] cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
🔧 {thought.toolSummary}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-[420px] font-mono text-[11px]"
>
<ToolSummaryTooltipContent
toolCalls={thought.toolCalls}
toolSummary={thought.toolSummary}
/>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
</span>
);
};
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<number | null>(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 ? (
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
</span>
) : null}
<LiveThoughtStatusBadge
canBeLive={canBeLive}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
newestTimestamp={newest.timestamp}
/>
</div>
) : null}
<MemberBadge name={leadName} color={memberColor} hideAvatar />
@ -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 = ({
</AnimatedHeightReveal>
);
};
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)
);

View file

@ -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<MessagesFilterState>({
@ -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}
</CollapsibleTeamSection>
);
};
});

View file

@ -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 (
<div className="flex w-full flex-col items-center justify-center gap-6">
{/* Animated icons cluster */}
<div className="relative flex size-28 items-center justify-center">
{/* Central pulsing ring */}
<div className="absolute size-16 animate-ping rounded-full bg-[var(--color-text-muted)] opacity-[0.04]" />
<div
className="absolute size-20 rounded-full bg-[var(--color-text-muted)] opacity-[0.03]"
style={{ animation: 'ping 2s cubic-bezier(0, 0, 0.2, 1) infinite 0.5s' }}
/>
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 (
<div className="flex w-full flex-col items-center justify-center gap-5">
{/* Main animation container */}
<div className="relative flex size-36 items-center justify-center">
{/* Outer rotating ring */}
<svg className="changes-orbit-ring absolute size-36" viewBox="0 0 144 144">
<circle
cx="72"
cy="72"
r="52"
fill="none"
stroke="var(--color-border-emphasis)"
strokeWidth="1"
strokeDasharray="6 8"
opacity="0.5"
/>
</svg>
{/* Inner rotating ring (counter) */}
<svg className="changes-orbit-ring-reverse absolute size-28" viewBox="0 0 112 112">
<circle
cx="56"
cy="56"
r="34"
fill="none"
stroke="var(--color-border-emphasis)"
strokeWidth="0.5"
strokeDasharray="3 6"
opacity="0.3"
/>
</svg>
{/* Particles on inner orbit */}
{particles.map((p) => (
<div
key={p.id}
className="absolute left-1/2 top-1/2 size-1 rounded-full bg-[var(--color-text-muted)]"
style={
{
animation: `changesParticle ${p.duration}s linear infinite ${p.delay}s`,
'--particle-radius': `${p.radius}px`,
'--particle-start': `${p.startAngle}deg`,
opacity: 0,
} as React.CSSProperties
}
/>
))}
{/* Orbiting icons */}
{orbitIcons.map(({ Icon, orbitRadius, speed, startAngle, size }, i) => (
<div
key={i}
className="absolute text-[var(--color-text-muted)]"
style={{
animation: `changesFloat 2.5s ease-in-out infinite ${delay}`,
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
transform: 'translate(-50%, -50%)',
}}
className="absolute left-1/2 top-1/2"
style={
{
animation: `changesOrbit ${speed}s linear infinite`,
'--orbit-radius': `${orbitRadius}px`,
'--start-angle': `${startAngle}deg`,
} as React.CSSProperties
}
>
<Icon size={16} strokeWidth={1.5} />
<div
className="changes-orbit-icon-inner text-[var(--color-text-muted)]"
style={
{
animation: `changesOrbitCounter ${speed}s linear infinite`,
'--start-angle': `${startAngle}deg`,
} as React.CSSProperties
}
>
<Icon size={size} strokeWidth={1.5} />
</div>
</div>
))}
{/* Center icon */}
{/* Glow pulse behind center */}
<div className="changes-glow-pulse absolute size-14 rounded-2xl bg-[var(--color-text-muted)] opacity-[0.06]" />
<div
className="relative z-10 flex size-10 items-center justify-center rounded-xl border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)]"
style={{ animation: 'changesBreath 2s ease-in-out infinite' }}
>
<FileDiff size={20} className="text-[var(--color-text-secondary)]" />
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 */}
<div className="changes-center-morph relative z-10 flex size-11 items-center justify-center rounded-xl border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)]">
<RefreshCw size={20} className="changes-center-spin text-[var(--color-text-secondary)]" />
</div>
{/* Scanning beam */}
<div className="absolute inset-0 overflow-hidden rounded-full">
<div className="changes-scan-beam absolute left-0 top-1/2 h-px w-full bg-gradient-to-r from-transparent via-[var(--color-text-muted)] to-transparent opacity-20" />
</div>
</div>
{/* Progress bar */}
<div className="w-48 overflow-hidden rounded-full bg-[var(--color-surface-raised)]">
<div
className="h-0.5 rounded-full bg-[var(--color-text-muted)]"
style={{ animation: 'changesProgress 2s ease-in-out infinite' }}
/>
{/* Segmented progress */}
<div className="flex gap-1">
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-0.5 w-8 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
>
<div
className="h-full rounded-full bg-[var(--color-text-muted)]"
style={{
animation: `changesSegment 2.5s ease-in-out infinite ${i * 0.3}s`,
}}
/>
</div>
))}
</div>
{/* Text */}
{/* Rotating message */}
<p
className="text-xs font-medium tracking-wide text-[var(--color-text-muted)]"
style={{ animation: 'changesFade 2s ease-in-out infinite' }}
className="h-4 text-xs font-medium tracking-wide text-[var(--color-text-muted)] transition-opacity duration-300"
style={{ opacity: isFading ? 0 : 0.8 }}
>
Loading changes...
{messages[msgIndex]}
</p>
<style>{`
@keyframes changesFloat {
0%, 100% { opacity: 0.3; transform: translate(-50%, -50%) scale(0.85); }
50% { opacity: 0.7; transform: translate(-50%, calc(-50% - 6px)) scale(1); }
.changes-orbit-ring {
animation: changesRingSpin 20s linear infinite;
}
@keyframes changesBreath {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
.changes-orbit-ring-reverse {
animation: changesRingSpin 15s linear infinite reverse;
}
@keyframes changesProgress {
0% { width: 0%; margin-left: 0; }
50% { width: 60%; margin-left: 20%; }
100% { width: 0%; margin-left: 100%; }
.changes-glow-pulse {
animation: changesGlowPulse 3s ease-in-out infinite;
}
@keyframes changesFade {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
.changes-center-morph {
animation: changesCenterMorph 4s ease-in-out infinite;
}
.changes-center-spin {
animation: changesCenterSpin 3s linear infinite;
}
.changes-scan-beam {
animation: changesScan 3s ease-in-out infinite;
}
.changes-orbit-icon-inner {
opacity: 0.45;
transition: opacity 0.3s;
}
@keyframes changesRingSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes changesOrbit {
from {
transform: translate(-50%, -50%) rotate(var(--start-angle)) translateX(var(--orbit-radius)) rotate(calc(-1 * var(--start-angle)));
}
to {
transform: translate(-50%, -50%) rotate(calc(var(--start-angle) + 360deg)) translateX(var(--orbit-radius)) rotate(calc(-1 * var(--start-angle) - 360deg));
}
}
@keyframes changesOrbitCounter {
from { transform: rotate(var(--start-angle)); }
to { transform: rotate(calc(var(--start-angle) + 360deg)); }
}
@keyframes changesParticle {
0% {
transform: translate(-50%, -50%) rotate(var(--particle-start)) translateX(var(--particle-radius));
opacity: 0;
}
15% { opacity: 0.6; }
85% { opacity: 0.6; }
100% {
transform: translate(-50%, -50%) rotate(calc(var(--particle-start) + 360deg)) translateX(var(--particle-radius));
opacity: 0;
}
}
@keyframes changesGlowPulse {
0%, 100% { transform: scale(1); opacity: 0.06; }
50% { transform: scale(1.2); opacity: 0.1; }
}
@keyframes changesCenterMorph {
0%, 100% { transform: scale(1); border-radius: 12px; }
25% { transform: scale(1.05); border-radius: 14px; }
50% { transform: scale(1); border-radius: 16px; }
75% { transform: scale(1.05); border-radius: 12px; }
}
@keyframes changesCenterSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes changesScan {
0% { transform: translateY(-70px) rotate(0deg); opacity: 0; }
10% { opacity: 0.2; }
50% { transform: translateY(0px) rotate(3deg); opacity: 0.3; }
90% { opacity: 0.2; }
100% { transform: translateY(70px) rotate(-3deg); opacity: 0; }
}
@keyframes changesSegment {
0%, 100% { width: 0%; opacity: 0.3; }
40% { width: 100%; opacity: 1; }
60% { width: 100%; opacity: 1; }
80% { width: 0%; opacity: 0.3; }
}
`}</style>
</div>

View file

@ -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<string, string>();
interface TeamMentionEntry {
teamName: string;
displayName: string;
color: string;
deletedAt: string;
}
export interface TeamMentionMeta {
teamNames: string[];
teamColorByName: ReadonlyMap<string, string>;
}
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<string, string>();
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;
}

View file

@ -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<string, string> | undefined,
next: ReadonlyMap<string, string> | 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)
);
}