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:
parent
c55787d6e1
commit
038c3f8bb4
11 changed files with 1687 additions and 878 deletions
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
93
src/renderer/hooks/useStableTeamMentionMeta.ts
Normal file
93
src/renderer/hooks/useStableTeamMentionMeta.ts
Normal 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;
|
||||
}
|
||||
131
src/renderer/utils/messageRenderEquality.ts
Normal file
131
src/renderer/utils/messageRenderEquality.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue