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.
|
- 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:
|
3. Use MCP tool task_complete BEFORE sending your final reply:
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>" }
|
{ 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.
|
- 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 is finished, run task_complete again before your reply.
|
- 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.
|
- 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:
|
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 }
|
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
|
||||||
|
|
@ -498,7 +499,7 @@ async function memberBriefing(context, memberName) {
|
||||||
const lines = [
|
const lines = [
|
||||||
`Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`,
|
`Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`,
|
||||||
`Role: ${role}.`,
|
`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}.`,
|
`Team lead: ${leadName}.`,
|
||||||
buildMemberLanguageInstruction(config),
|
buildMemberLanguageInstruction(config),
|
||||||
`You must NOT start work, claim tasks, or improvise task/process protocol before reading and following this briefing.`,
|
`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.
|
- Introduce yourself briefly (name and role) and confirm you are ready.
|
||||||
- Then wait for task assignments.
|
- 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.
|
- 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()}
|
${buildTeammateAgentBlockReminder()}
|
||||||
${actionModeProtocol}`;
|
${actionModeProtocol}`;
|
||||||
}
|
}
|
||||||
|
|
@ -475,7 +475,7 @@ ${actionModeProtocol}
|
||||||
- Before you start any needsFix or pending task, call task_get for that specific task.
|
- 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.
|
- 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.
|
- 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.`;
|
- 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.
|
- 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.
|
- 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.
|
- 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.`;
|
- 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.
|
- 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:
|
3. Use MCP tool task_complete BEFORE sending your final reply:
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>" }
|
{ 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.
|
- 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 is finished, run task_complete again before your reply.
|
- 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.
|
- 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:
|
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 }
|
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import type { SearchMatch } from '@renderer/store/types';
|
||||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
|
|
@ -62,8 +63,17 @@ interface MarkdownViewerProps {
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
/** Base directory for resolving relative URLs (images, links) via local-resource:// protocol */
|
/** Base directory for resolving relative URLs (images, links) via local-resource:// protocol */
|
||||||
baseDir?: string;
|
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
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -517,15 +527,16 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
copyable = false,
|
copyable = false,
|
||||||
bare = false,
|
bare = false,
|
||||||
baseDir,
|
baseDir,
|
||||||
|
teamColorByName: providedTeamColorByName,
|
||||||
|
onTeamClick: providedOnTeamClick,
|
||||||
}) => {
|
}) => {
|
||||||
const [showRaw, setShowRaw] = React.useState(false);
|
const [showRaw, setShowRaw] = React.useState(false);
|
||||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||||
const { isLight } = useTheme();
|
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 fallbackTeamColorByName = React.useMemo(() => {
|
||||||
|
|
||||||
const teamColorByName = React.useMemo(() => {
|
|
||||||
const result = new Map<string, string>();
|
const result = new Map<string, string>();
|
||||||
for (const team of teams) {
|
for (const team of teams) {
|
||||||
if (team.teamName) {
|
if (team.teamName) {
|
||||||
|
|
@ -537,6 +548,9 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [teams]);
|
}, [teams]);
|
||||||
|
const teamColorByName =
|
||||||
|
providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP;
|
||||||
|
const onTeamClick = providedOnTeamClick ?? openTeamTab;
|
||||||
|
|
||||||
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
||||||
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
||||||
|
|
@ -545,7 +559,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
searchQuery: itemId ? s.searchQuery : '',
|
searchQuery: itemId ? s.searchQuery : '',
|
||||||
searchMatches: itemId ? s.searchMatches : [],
|
searchMatches: itemId ? s.searchMatches : EMPTY_SEARCH_MATCHES,
|
||||||
currentSearchIndex: itemId ? s.currentSearchIndex : -1,
|
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)
|
// 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
|
// useMemo would cache stale closures when parent re-renders without search deps changing
|
||||||
const baseComponents = searchCtx
|
const baseComponents = searchCtx
|
||||||
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, openTeamTab)
|
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick)
|
||||||
: isLight
|
: isLight
|
||||||
? createViewerMarkdownComponents(null, true, teamColorByName, openTeamTab)
|
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick)
|
||||||
: createViewerMarkdownComponents(null, false, teamColorByName, openTeamTab);
|
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick);
|
||||||
|
|
||||||
// When baseDir is set (editor preview), override img to load local files via IPC
|
// When baseDir is set (editor preview), override img to load local files via IPC
|
||||||
const components = baseDir
|
const components = baseDir
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,51 @@ interface CreateTaskDialogState {
|
||||||
defaultChip?: InlineChip;
|
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 {
|
interface TimeWindow {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
|
|
@ -230,6 +275,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
clearProvisioningError,
|
clearProvisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivityByTeam,
|
leadActivityByTeam,
|
||||||
|
leadContextUpdatedAt,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
fetchMemberSpawnStatuses,
|
fetchMemberSpawnStatuses,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
|
|
@ -278,6 +324,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
clearProvisioningError: s.clearProvisioningError,
|
clearProvisioningError: s.clearProvisioningError,
|
||||||
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
||||||
leadActivityByTeam: s.leadActivityByTeam,
|
leadActivityByTeam: s.leadActivityByTeam,
|
||||||
|
leadContextUpdatedAt: teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined,
|
||||||
memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
|
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
|
||||||
refreshTeamData: s.refreshTeamData,
|
refreshTeamData: s.refreshTeamData,
|
||||||
|
|
@ -637,10 +684,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return result;
|
return result;
|
||||||
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
||||||
|
|
||||||
const activeMembers = useMemo(
|
const activeMembers = useStableActiveMembers(data?.members);
|
||||||
() => (data?.members ?? []).filter((m) => !m.removedAt),
|
|
||||||
[data?.members]
|
|
||||||
);
|
|
||||||
|
|
||||||
const kanbanDisplayTasks = useMemo(() => {
|
const kanbanDisplayTasks = useMemo(() => {
|
||||||
const query = kanbanSearch.trim();
|
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(
|
const handleEditorAction = useCallback(
|
||||||
(action: EditorSelectionAction) => {
|
(action: EditorSelectionAction) => {
|
||||||
const chip = createChipFromSelection(action, []) ?? undefined;
|
const chip = createChipFromSelection(action, []) ?? undefined;
|
||||||
|
|
@ -987,6 +1056,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
messages={data.messages}
|
messages={data.messages}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
|
leadActivity={leadActivityByTeam[teamName]}
|
||||||
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
timeWindow={timeWindow}
|
timeWindow={timeWindow}
|
||||||
teamSessionIds={teamSessionIds}
|
teamSessionIds={teamSessionIds}
|
||||||
currentLeadSessionId={data?.config.leadSessionId}
|
currentLeadSessionId={data?.config.leadSessionId}
|
||||||
|
|
@ -994,23 +1065,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
onPendingReplyChange={setPendingRepliesByMember}
|
onPendingReplyChange={setPendingRepliesByMember}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={setSelectedMember}
|
||||||
onTaskClick={setSelectedTask}
|
onTaskClick={setSelectedTask}
|
||||||
onCreateTaskFromMessage={(subject, description) => {
|
onCreateTaskFromMessage={handleCreateTaskFromMessage}
|
||||||
openCreateTaskDialog(subject, description);
|
onReplyToMessage={handleReplyToMessage}
|
||||||
}}
|
onRestartTeam={handleRestartTeam}
|
||||||
onReplyToMessage={(message) => {
|
onTaskIdClick={handleTaskIdClick}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1584,6 +1642,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
messages={data.messages}
|
messages={data.messages}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
|
leadActivity={leadActivityByTeam[teamName]}
|
||||||
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
timeWindow={timeWindow}
|
timeWindow={timeWindow}
|
||||||
teamSessionIds={teamSessionIds}
|
teamSessionIds={teamSessionIds}
|
||||||
currentLeadSessionId={data?.config.leadSessionId}
|
currentLeadSessionId={data?.config.leadSessionId}
|
||||||
|
|
@ -1591,23 +1651,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
onPendingReplyChange={setPendingRepliesByMember}
|
onPendingReplyChange={setPendingRepliesByMember}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={setSelectedMember}
|
||||||
onTaskClick={setSelectedTask}
|
onTaskClick={setSelectedTask}
|
||||||
onCreateTaskFromMessage={(subject, description) => {
|
onCreateTaskFromMessage={handleCreateTaskFromMessage}
|
||||||
openCreateTaskDialog(subject, description);
|
onReplyToMessage={handleReplyToMessage}
|
||||||
}}
|
onRestartTeam={handleRestartTeam}
|
||||||
onReplyToMessage={(message) => {
|
onTaskIdClick={handleTaskIdClick}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,11 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import {
|
||||||
|
areInboxMessagesEquivalentForRender,
|
||||||
|
areStringArraysEqual,
|
||||||
|
areStringMapsEqual,
|
||||||
|
} from '@renderer/utils/messageRenderEquality';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { Layers } from 'lucide-react';
|
import { Layers } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -16,7 +21,6 @@ import {
|
||||||
} from './LeadThoughtsGroup';
|
} from './LeadThoughtsGroup';
|
||||||
import { useNewItemKeys } from './useNewItemKeys';
|
import { useNewItemKeys } from './useNewItemKeys';
|
||||||
|
|
||||||
import type { ActivityCollapseState } from './collapseState';
|
|
||||||
import type { TimelineItem } from './LeadThoughtsGroup';
|
import type { TimelineItem } from './LeadThoughtsGroup';
|
||||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -52,11 +56,35 @@ interface ActivityTimelineProps {
|
||||||
teamSessionIds?: Set<string>;
|
teamSessionIds?: Set<string>;
|
||||||
/** Current lead session ID for the active team, if known. */
|
/** Current lead session ID for the active team, if known. */
|
||||||
currentLeadSessionId?: string;
|
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 VIEWPORT_THRESHOLD = 0.15;
|
||||||
const MESSAGES_PAGE_SIZE = 30;
|
const MESSAGES_PAGE_SIZE = 30;
|
||||||
const COMPACT_MESSAGES_WIDTH_PX = 400;
|
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. */
|
/** Inline compaction boundary divider — styled like session separators but with amber accent. */
|
||||||
const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => (
|
const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => (
|
||||||
|
|
@ -98,8 +126,15 @@ const MessageRowWithObserver = ({
|
||||||
onVisible,
|
onVisible,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
onRestartTeam,
|
onRestartTeam,
|
||||||
collapseState,
|
collapseMode,
|
||||||
|
isCollapsed,
|
||||||
|
canToggleCollapse,
|
||||||
|
collapseToggleKey,
|
||||||
|
onToggleCollapse,
|
||||||
compactHeader,
|
compactHeader,
|
||||||
|
teamNames,
|
||||||
|
teamColorByName,
|
||||||
|
onTeamClick,
|
||||||
}: {
|
}: {
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -117,8 +152,15 @@ const MessageRowWithObserver = ({
|
||||||
onVisible?: (message: InboxMessage) => void;
|
onVisible?: (message: InboxMessage) => void;
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
onRestartTeam?: () => void;
|
onRestartTeam?: () => void;
|
||||||
collapseState?: ActivityCollapseState;
|
collapseMode: 'default' | 'managed';
|
||||||
|
isCollapsed: boolean;
|
||||||
|
canToggleCollapse: boolean;
|
||||||
|
collapseToggleKey?: string;
|
||||||
|
onToggleCollapse?: (key: string) => void;
|
||||||
compactHeader?: boolean;
|
compactHeader?: boolean;
|
||||||
|
teamNames?: string[];
|
||||||
|
teamColorByName?: ReadonlyMap<string, string>;
|
||||||
|
onTeamClick?: (teamName: string) => void;
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const reportedRef = useRef(false);
|
const reportedRef = useRef(false);
|
||||||
|
|
@ -167,14 +209,51 @@ const MessageRowWithObserver = ({
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onRestartTeam={onRestartTeam}
|
onRestartTeam={onRestartTeam}
|
||||||
collapseState={collapseState}
|
collapseMode={collapseMode}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
canToggleCollapse={canToggleCollapse}
|
||||||
|
collapseToggleKey={collapseToggleKey}
|
||||||
|
onToggleCollapse={onToggleCollapse}
|
||||||
compactHeader={compactHeader}
|
compactHeader={compactHeader}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
/>
|
/>
|
||||||
</AnimatedHeightReveal>
|
</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,
|
messages,
|
||||||
teamName,
|
teamName,
|
||||||
members,
|
members,
|
||||||
|
|
@ -190,7 +269,13 @@ export const ActivityTimeline = ({
|
||||||
onToggleExpandOverride,
|
onToggleExpandOverride,
|
||||||
teamSessionIds,
|
teamSessionIds,
|
||||||
currentLeadSessionId,
|
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 [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [compactHeader, setCompactHeader] = useState(false);
|
const [compactHeader, setCompactHeader] = useState(false);
|
||||||
|
|
@ -218,36 +303,53 @@ export const ActivityTimeline = ({
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
|
const colorMap = useMemo(
|
||||||
const localMemberNames = new Set((members ?? []).map((member) => member.name.trim()));
|
() => (members ? buildMemberColorMap(members) : EMPTY_MEMBER_COLOR_MAP),
|
||||||
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
[members]
|
||||||
if (members) {
|
);
|
||||||
for (const m of 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 = {
|
const info = {
|
||||||
role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined),
|
role:
|
||||||
color: colorMap.get(m.name),
|
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined),
|
||||||
|
color: colorMap.get(member.name),
|
||||||
};
|
};
|
||||||
memberInfo.set(m.name, info);
|
infoMap.set(member.name, info);
|
||||||
if (m.agentType && m.agentType !== m.name) {
|
if (member.agentType && member.agentType !== member.name) {
|
||||||
memberInfo.set(m.agentType, info);
|
infoMap.set(member.agentType, info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Map "user" to team-lead's resolved color and role
|
|
||||||
const leadMember = members.find(
|
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) {
|
if (leadMember) {
|
||||||
const leadInfo = memberInfo.get(leadMember.name);
|
const leadInfo = infoMap.get(leadMember.name);
|
||||||
if (leadInfo) {
|
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 => {
|
return infoMap;
|
||||||
const member = members?.find((m) => m.name === name || m.agentType === name);
|
}, [members, colorMap]);
|
||||||
if (member) onMemberClick?.(member);
|
|
||||||
};
|
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
|
// 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.
|
// 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
|
* In collapsed mode we always keep the newest real message open, keep the pinned
|
||||||
* thought group open, and let localStorage overrides reopen older items.
|
* thought group open, and let localStorage overrides reopen older items.
|
||||||
*/
|
*/
|
||||||
const getItemCollapseState = useCallback(
|
const getItemCollapseProps = useCallback(
|
||||||
(stableKey: string, itemIndex: number): ActivityCollapseState =>
|
(stableKey: string, itemIndex: number): ItemCollapseProps => {
|
||||||
resolveTimelineCollapseState({
|
const collapseState = resolveTimelineCollapseState({
|
||||||
allCollapsed,
|
allCollapsed,
|
||||||
itemIndex,
|
itemIndex,
|
||||||
newestMessageIndex,
|
newestMessageIndex,
|
||||||
|
|
@ -372,7 +474,23 @@ export const ActivityTimeline = ({
|
||||||
onToggleOverride: onToggleExpandOverride
|
onToggleOverride: onToggleExpandOverride
|
||||||
? () => onToggleExpandOverride(stableKey)
|
? () => onToggleExpandOverride(stableKey)
|
||||||
: undefined,
|
: 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]
|
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -395,21 +513,31 @@ export const ActivityTimeline = ({
|
||||||
const info = memberInfo.get(firstThought.from);
|
const info = memberInfo.get(firstThought.from);
|
||||||
const itemKey = getThoughtGroupKey(group);
|
const itemKey = getThoughtGroupKey(group);
|
||||||
const stableKey = itemKey;
|
const stableKey = itemKey;
|
||||||
const collapseState = getItemCollapseState(stableKey, 0);
|
const collapseProps = getItemCollapseProps(stableKey, 0);
|
||||||
return (
|
return (
|
||||||
<LeadThoughtsGroupRow
|
<LeadThoughtsGroupRow
|
||||||
key={itemKey}
|
key={itemKey}
|
||||||
group={group}
|
group={group}
|
||||||
memberColor={info?.color}
|
memberColor={info?.color}
|
||||||
canBeLive={true}
|
canBeLive={true}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
leadActivity={leadActivity}
|
||||||
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
isNew={newItemKeys.has(itemKey)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(0)}
|
zebraShade={zebraShadeSet.has(0)}
|
||||||
collapseState={collapseState}
|
collapseMode={collapseProps.collapseMode}
|
||||||
|
isCollapsed={collapseProps.isCollapsed}
|
||||||
|
canToggleCollapse={collapseProps.canToggleCollapse}
|
||||||
|
collapseToggleKey={collapseProps.collapseToggleKey}
|
||||||
|
onToggleCollapse={onToggleExpandOverride}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
memberColorMap={colorMap}
|
memberColorMap={colorMap}
|
||||||
onReply={onReplyToMessage}
|
onReply={onReplyToMessage}
|
||||||
compactHeader={compactHeader}
|
compactHeader={compactHeader}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
@ -455,7 +583,7 @@ export const ActivityTimeline = ({
|
||||||
const info = memberInfo.get(firstThought.from);
|
const info = memberInfo.get(firstThought.from);
|
||||||
const itemKey = getThoughtGroupKey(group);
|
const itemKey = getThoughtGroupKey(group);
|
||||||
const stableKey = itemKey;
|
const stableKey = itemKey;
|
||||||
const collapseState = getItemCollapseState(stableKey, realIndex);
|
const collapseProps = getItemCollapseProps(stableKey, realIndex);
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={itemKey}>
|
<React.Fragment key={itemKey}>
|
||||||
{sessionSeparator}
|
{sessionSeparator}
|
||||||
|
|
@ -463,14 +591,24 @@ export const ActivityTimeline = ({
|
||||||
group={group}
|
group={group}
|
||||||
memberColor={info?.color}
|
memberColor={info?.color}
|
||||||
canBeLive={false}
|
canBeLive={false}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
leadActivity={leadActivity}
|
||||||
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
isNew={newItemKeys.has(itemKey)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(realIndex)}
|
zebraShade={zebraShadeSet.has(realIndex)}
|
||||||
collapseState={collapseState}
|
collapseMode={collapseProps.collapseMode}
|
||||||
|
isCollapsed={collapseProps.isCollapsed}
|
||||||
|
canToggleCollapse={collapseProps.canToggleCollapse}
|
||||||
|
collapseToggleKey={collapseProps.collapseToggleKey}
|
||||||
|
onToggleCollapse={onToggleExpandOverride}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
memberColorMap={colorMap}
|
memberColorMap={colorMap}
|
||||||
onReply={onReplyToMessage}
|
onReply={onReplyToMessage}
|
||||||
compactHeader={compactHeader}
|
compactHeader={compactHeader}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
@ -495,14 +633,14 @@ export const ActivityTimeline = ({
|
||||||
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
||||||
const messageKey = toMessageKey(message);
|
const messageKey = toMessageKey(message);
|
||||||
const stableKey = messageKey;
|
const stableKey = messageKey;
|
||||||
const collapseState = getItemCollapseState(stableKey, realIndex);
|
const collapseProps = getItemCollapseProps(stableKey, realIndex);
|
||||||
const isUnread = readState
|
const isUnread = readState
|
||||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||||
: !message.read;
|
: !message.read;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={messageKey}>
|
<React.Fragment key={messageKey}>
|
||||||
{sessionSeparator}
|
{sessionSeparator}
|
||||||
<MessageRowWithObserver
|
<MemoizedMessageRowWithObserver
|
||||||
message={message}
|
message={message}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
memberRole={info?.role}
|
memberRole={info?.role}
|
||||||
|
|
@ -519,8 +657,15 @@ export const ActivityTimeline = ({
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onRestartTeam={onRestartTeam}
|
onRestartTeam={onRestartTeam}
|
||||||
collapseState={collapseState}
|
collapseMode={collapseProps.collapseMode}
|
||||||
|
isCollapsed={collapseProps.isCollapsed}
|
||||||
|
canToggleCollapse={collapseProps.canToggleCollapse}
|
||||||
|
collapseToggleKey={collapseProps.collapseToggleKey}
|
||||||
|
onToggleCollapse={onToggleExpandOverride}
|
||||||
compactHeader={compactHeader}
|
compactHeader={compactHeader}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
@ -571,4 +716,4 @@ export const ActivityTimeline = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
|
|
@ -12,8 +12,12 @@ import {
|
||||||
CARD_TEXT_LIGHT,
|
CARD_TEXT_LIGHT,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
|
import {
|
||||||
|
areStringArraysEqual,
|
||||||
|
areStringMapsEqual,
|
||||||
|
areThoughtMessagesEquivalentForRender,
|
||||||
|
} from '@renderer/utils/messageRenderEquality';
|
||||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
|
@ -25,9 +29,7 @@ import {
|
||||||
ENTRY_REVEAL_ANIMATION_MS,
|
ENTRY_REVEAL_ANIMATION_MS,
|
||||||
ENTRY_REVEAL_EASING,
|
ENTRY_REVEAL_EASING,
|
||||||
} from './AnimatedHeightReveal';
|
} from './AnimatedHeightReveal';
|
||||||
import { isManagedCollapseState } from './collapseState';
|
|
||||||
|
|
||||||
import type { ActivityCollapseState } from './collapseState';
|
|
||||||
import type { InboxMessage, ToolCallMeta } from '@shared/types';
|
import type { InboxMessage, ToolCallMeta } from '@shared/types';
|
||||||
|
|
||||||
export interface LeadThoughtGroup {
|
export interface LeadThoughtGroup {
|
||||||
|
|
@ -116,14 +118,30 @@ interface LeadThoughtsGroupRowProps {
|
||||||
onVisible?: (message: InboxMessage) => void;
|
onVisible?: (message: InboxMessage) => void;
|
||||||
/** When false, the live indicator is always off (for historical thought groups). */
|
/** When false, the live indicator is always off (for historical thought groups). */
|
||||||
canBeLive?: boolean;
|
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. */
|
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
||||||
zebraShade?: boolean;
|
zebraShade?: boolean;
|
||||||
/** Explicit collapse state for timeline-controlled collapsed mode. */
|
/** Collapsed-mode primitives stabilized by ActivityTimeline. */
|
||||||
collapseState?: ActivityCollapseState;
|
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. */
|
/** Called when a task ID link (e.g. #10) is clicked in thought text. */
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
/** Map of member name → color name for @mention badge rendering. */
|
/** Map of member name → color name for @mention badge rendering. */
|
||||||
memberColorMap?: Map<string, string>;
|
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. */
|
/** Called when user clicks the reply button on a thought. */
|
||||||
onReply?: (message: InboxMessage) => void;
|
onReply?: (message: InboxMessage) => void;
|
||||||
/** Compact header mode for narrow message lists. */
|
/** Compact header mode for narrow message lists. */
|
||||||
|
|
@ -212,258 +230,355 @@ interface LeadThoughtItemProps {
|
||||||
shouldAnimate: boolean;
|
shouldAnimate: boolean;
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
memberColorMap?: Map<string, string>;
|
memberColorMap?: Map<string, string>;
|
||||||
|
teamNames?: string[];
|
||||||
|
teamColorByName?: ReadonlyMap<string, string>;
|
||||||
|
onTeamClick?: (teamName: string) => void;
|
||||||
onReply?: (message: InboxMessage) => void;
|
onReply?: (message: InboxMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LeadThoughtItem = ({
|
function hasSelectionWithin(container: HTMLElement | null): boolean {
|
||||||
thought,
|
if (!container) return false;
|
||||||
showDivider,
|
const selection = window.getSelection();
|
||||||
shouldAnimate,
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||||
onTaskIdClick,
|
return false;
|
||||||
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);
|
|
||||||
|
|
||||||
const teams = useStore((s) => s.teams);
|
const anchorNode = selection.anchorNode;
|
||||||
const teamNames = useMemo(
|
const focusNode = selection.focusNode;
|
||||||
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
return (
|
||||||
[teams]
|
(!!anchorNode && container.contains(anchorNode)) ||
|
||||||
|
(!!focusNode && container.contains(focusNode))
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const displayContent = useMemo(() => {
|
function areThoughtGroupsEquivalent(prev: LeadThoughtGroup, next: LeadThoughtGroup): boolean {
|
||||||
let text = thought.text.replace(/\n/g, ' \n');
|
if (prev === next) return true;
|
||||||
text = linkifyTaskIdsInMarkdown(text, thought.taskRefs);
|
if (getThoughtGroupKey(prev) !== getThoughtGroupKey(next)) return false;
|
||||||
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
|
if (prev.thoughts.length !== next.thoughts.length) return false;
|
||||||
text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames);
|
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(() => {
|
const LeadThoughtItem = memo(
|
||||||
if (animationFrameRef.current !== null) {
|
function LeadThoughtItem({
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
thought,
|
||||||
animationFrameRef.current = null;
|
showDivider,
|
||||||
}
|
shouldAnimate,
|
||||||
if (cleanupTimerRef.current !== null) {
|
onTaskIdClick,
|
||||||
window.clearTimeout(cleanupTimerRef.current);
|
memberColorMap,
|
||||||
cleanupTimerRef.current = null;
|
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 displayContent = useMemo(() => {
|
||||||
const wrapper = wrapperRef.current;
|
let text = thought.text.replace(/\n/g, ' \n');
|
||||||
if (!wrapper) return;
|
text = linkifyTaskIdsInMarkdown(text, thought.taskRefs);
|
||||||
wrapper.style.height = 'auto';
|
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
|
||||||
wrapper.style.opacity = '1';
|
text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames);
|
||||||
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 text;
|
||||||
|
}, [thought.text, memberColorMap, teamNames]);
|
||||||
|
|
||||||
if (previousHeight === null) {
|
const clearPendingAnimation = useCallback(() => {
|
||||||
if (nextHeight > 0 && animateFromZero) {
|
if (animationFrameRef.current !== null) {
|
||||||
animateHeight(nextHeight, 0, 0);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
} else {
|
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;
|
initialAnimationCompletedRef.current = true;
|
||||||
resetWrapperStyles();
|
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
|
if (Math.abs(nextHeight - previousHeight) < 1) return;
|
||||||
// tool summary metadata appears after the text) should resize naturally.
|
|
||||||
if (initialAnimationCompletedRef.current) {
|
// 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();
|
resetWrapperStyles();
|
||||||
return;
|
};
|
||||||
}
|
}, [clearPendingAnimation, resetWrapperStyles, shouldAnimateOnMount]);
|
||||||
|
|
||||||
const renderedHeight = wrapper.getBoundingClientRect().height;
|
useEffect(
|
||||||
animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1);
|
() => () => {
|
||||||
};
|
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 LiveThoughtStatusBadge = ({
|
||||||
const nextHeight = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height;
|
canBeLive,
|
||||||
syncHeight(nextHeight, false);
|
isTeamAlive,
|
||||||
});
|
leadActivity,
|
||||||
observer.observe(content);
|
leadContextUpdatedAt,
|
||||||
|
newestTimestamp,
|
||||||
return () => {
|
}: {
|
||||||
observer.disconnect();
|
canBeLive?: boolean;
|
||||||
clearPendingAnimation();
|
isTeamAlive?: boolean;
|
||||||
initialAnimationCompletedRef.current = true;
|
leadActivity?: string;
|
||||||
resetWrapperStyles();
|
leadContextUpdatedAt?: string;
|
||||||
};
|
newestTimestamp: string;
|
||||||
}, [clearPendingAnimation, resetWrapperStyles, shouldAnimateOnMount]);
|
}): JSX.Element | null => {
|
||||||
|
const computeIsLive = useCallback(
|
||||||
useEffect(
|
() =>
|
||||||
() => () => {
|
canBeLive !== false &&
|
||||||
clearPendingAnimation();
|
!!isTeamAlive &&
|
||||||
},
|
(leadActivity === 'active' ||
|
||||||
[clearPendingAnimation]
|
(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 (
|
return (
|
||||||
<div ref={wrapperRef}>
|
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
|
||||||
<div ref={contentRef}>
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||||
{showDivider && (
|
<span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
|
||||||
<div className="py-px text-center">
|
</span>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LeadThoughtsGroupRow = ({
|
const LeadThoughtsGroupRowComponent = ({
|
||||||
group,
|
group,
|
||||||
memberColor,
|
memberColor,
|
||||||
isNew,
|
isNew,
|
||||||
onVisible,
|
onVisible,
|
||||||
canBeLive,
|
canBeLive,
|
||||||
|
isTeamAlive,
|
||||||
|
leadActivity,
|
||||||
|
leadContextUpdatedAt,
|
||||||
zebraShade,
|
zebraShade,
|
||||||
collapseState,
|
collapseMode,
|
||||||
|
isCollapsed,
|
||||||
|
canToggleCollapse,
|
||||||
|
collapseToggleKey,
|
||||||
|
onToggleCollapse,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
memberColorMap,
|
memberColorMap,
|
||||||
|
teamNames = [],
|
||||||
|
teamColorByName,
|
||||||
|
onTeamClick,
|
||||||
onReply,
|
onReply,
|
||||||
compactHeader = false,
|
compactHeader = false,
|
||||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||||
|
|
@ -473,15 +588,6 @@ export const LeadThoughtsGroupRow = ({
|
||||||
const isUserScrolledUpRef = useRef(false);
|
const isUserScrolledUpRef = useRef(false);
|
||||||
const distanceFromBottomRef = useRef(0);
|
const distanceFromBottomRef = useRef(0);
|
||||||
const scrollSyncFrameRef = useRef<number | null>(null);
|
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 colors = getTeamColorSet(memberColor ?? '');
|
||||||
const { thoughts } = group;
|
const { thoughts } = group;
|
||||||
|
|
@ -531,34 +637,17 @@ export const LeadThoughtsGroupRow = ({
|
||||||
return null;
|
return null;
|
||||||
}, [thoughts]);
|
}, [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 [expanded, setExpanded] = useState(false);
|
||||||
const [needsTruncation, setNeedsTruncation] = useState(false);
|
const [needsTruncation, setNeedsTruncation] = useState(false);
|
||||||
const isManaged = isManagedCollapseState(collapseState);
|
const isManaged = collapseMode === 'managed';
|
||||||
const isBodyVisible = isManaged ? !collapseState.isCollapsed : true;
|
const isBodyVisible = isManaged ? !isCollapsed : true;
|
||||||
const canToggleBodyVisibility = isManaged && collapseState.canToggle;
|
const canToggleBodyVisibility = isManaged && canToggleCollapse;
|
||||||
const handleBodyToggle = canToggleBodyVisibility
|
const handleBodyToggle = useCallback(() => {
|
||||||
? (): void => {
|
if (canToggleBodyVisibility && collapseToggleKey) {
|
||||||
collapseState.onToggle?.();
|
onToggleCollapse?.(collapseToggleKey);
|
||||||
}
|
}
|
||||||
: undefined;
|
}, [canToggleBodyVisibility, collapseToggleKey, onToggleCollapse]);
|
||||||
|
const shouldAnimateLatestThought = canBeLive !== false && isRecentTimestamp(newest.timestamp);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Track how many thoughts have been reported as visible so far.
|
// Track how many thoughts have been reported as visible so far.
|
||||||
const reportedCountRef = useRef(0);
|
const reportedCountRef = useRef(0);
|
||||||
|
|
@ -600,6 +689,10 @@ export const LeadThoughtsGroupRow = ({
|
||||||
scrollSyncFrameRef.current = null;
|
scrollSyncFrameRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasSelectionWithin(scrollEl)) {
|
||||||
|
scrollSyncFrameRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nextScrollTop =
|
const nextScrollTop =
|
||||||
mode === 'bottom'
|
mode === 'bottom'
|
||||||
|
|
@ -689,7 +782,7 @@ export const LeadThoughtsGroupRow = ({
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl) {
|
if (scrollEl && !hasSelectionWithin(scrollEl)) {
|
||||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||||
}
|
}
|
||||||
ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
|
@ -747,12 +840,13 @@ export const LeadThoughtsGroupRow = ({
|
||||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{isLive ? (
|
<LiveThoughtStatusBadge
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
|
canBeLive={canBeLive}
|
||||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
isTeamAlive={isTeamAlive}
|
||||||
<span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
|
leadActivity={leadActivity}
|
||||||
</span>
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
) : null}
|
newestTimestamp={newest.timestamp}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||||
|
|
@ -822,9 +916,14 @@ export const LeadThoughtsGroupRow = ({
|
||||||
key={toMessageKey(thought)}
|
key={toMessageKey(thought)}
|
||||||
thought={thought}
|
thought={thought}
|
||||||
showDivider={idx > 0}
|
showDivider={idx > 0}
|
||||||
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
shouldAnimate={
|
||||||
|
shouldAnimateLatestThought && idx === chronologicalThoughts.length - 1
|
||||||
|
}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
memberColorMap={memberColorMap}
|
memberColorMap={memberColorMap}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -871,3 +970,29 @@ export const LeadThoughtsGroupRow = ({
|
||||||
</AnimatedHeightReveal>
|
</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 { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
|
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
|
@ -48,6 +49,10 @@ interface MessagesPanelProps {
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
/** Whether the team is alive. */
|
/** Whether the team is alive. */
|
||||||
isTeamAlive?: boolean;
|
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. */
|
/** Time window for filtering. */
|
||||||
timeWindow: TimeWindow | null;
|
timeWindow: TimeWindow | null;
|
||||||
/** Team session IDs for timeline. */
|
/** Team session IDs for timeline. */
|
||||||
|
|
@ -72,7 +77,7 @@ interface MessagesPanelProps {
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessagesPanel = ({
|
export const MessagesPanel = memo(function MessagesPanel({
|
||||||
teamName,
|
teamName,
|
||||||
position,
|
position,
|
||||||
onTogglePosition,
|
onTogglePosition,
|
||||||
|
|
@ -80,6 +85,8 @@ export const MessagesPanel = ({
|
||||||
tasks,
|
tasks,
|
||||||
messages,
|
messages,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
leadActivity,
|
||||||
|
leadContextUpdatedAt,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
teamSessionIds,
|
teamSessionIds,
|
||||||
currentLeadSessionId,
|
currentLeadSessionId,
|
||||||
|
|
@ -91,12 +98,14 @@ export const MessagesPanel = ({
|
||||||
onReplyToMessage,
|
onReplyToMessage,
|
||||||
onRestartTeam,
|
onRestartTeam,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
}: MessagesPanelProps): React.JSX.Element => {
|
}: MessagesPanelProps): React.JSX.Element {
|
||||||
const sendTeamMessage = useStore((s) => s.sendTeamMessage);
|
const sendTeamMessage = useStore((s) => s.sendTeamMessage);
|
||||||
const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage);
|
const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage);
|
||||||
const sendingMessage = useStore((s) => s.sendingMessage);
|
const sendingMessage = useStore((s) => s.sendingMessage);
|
||||||
const sendMessageError = useStore((s) => s.sendMessageError);
|
const sendMessageError = useStore((s) => s.sendMessageError);
|
||||||
const lastSendMessageResult = useStore((s) => s.lastSendMessageResult);
|
const lastSendMessageResult = useStore((s) => s.lastSendMessageResult);
|
||||||
|
const teams = useStore((s) => s.teams);
|
||||||
|
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||||
|
|
||||||
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
|
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
|
||||||
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
|
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
|
||||||
|
|
@ -129,6 +138,10 @@ export const MessagesPanel = ({
|
||||||
[markRead]
|
[markRead]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const readState = useMemo(() => ({ readSet, getMessageKey: toMessageKey }), [readSet]);
|
||||||
|
|
||||||
|
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
||||||
|
|
||||||
const handleMarkAllRead = useCallback(() => {
|
const handleMarkAllRead = useCallback(() => {
|
||||||
const keys = filteredMessages
|
const keys = filteredMessages
|
||||||
.filter((m) => !m.read && !readSet.has(toMessageKey(m)))
|
.filter((m) => !m.read && !readSet.has(toMessageKey(m)))
|
||||||
|
|
@ -290,12 +303,18 @@ export const MessagesPanel = ({
|
||||||
messages={filteredMessages}
|
messages={filteredMessages}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={members}
|
members={members}
|
||||||
readState={{ readSet, getMessageKey: toMessageKey }}
|
readState={readState}
|
||||||
allCollapsed={messagesCollapsed}
|
allCollapsed={messagesCollapsed}
|
||||||
expandOverrides={expandedSet}
|
expandOverrides={expandedSet}
|
||||||
onToggleExpandOverride={toggleExpandOverride}
|
onToggleExpandOverride={toggleExpandOverride}
|
||||||
teamSessionIds={teamSessionIds}
|
teamSessionIds={teamSessionIds}
|
||||||
currentLeadSessionId={currentLeadSessionId}
|
currentLeadSessionId={currentLeadSessionId}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
leadActivity={leadActivity}
|
||||||
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
|
teamNames={teamNames}
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={openTeamTab}
|
||||||
onMemberClick={onMemberClick}
|
onMemberClick={onMemberClick}
|
||||||
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
onCreateTaskFromMessage={onCreateTaskFromMessage}
|
||||||
onReplyToMessage={onReplyToMessage}
|
onReplyToMessage={onReplyToMessage}
|
||||||
|
|
@ -499,4 +518,4 @@ export const MessagesPanel = ({
|
||||||
{messagesContent}
|
{messagesContent}
|
||||||
</CollapsibleTeamSection>
|
</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 = [
|
const orbitIcons = [
|
||||||
{ Icon: FileText, delay: '0s', x: -40, y: -30 },
|
{ Icon: FileText, orbitRadius: 52, speed: 12, startAngle: 0, size: 15 },
|
||||||
{ Icon: FileDiff, delay: '0.4s', x: 35, y: -25 },
|
{ Icon: FileDiff, orbitRadius: 52, speed: 12, startAngle: 72, size: 14 },
|
||||||
{ Icon: FileCode, delay: '0.8s', x: -30, y: 20 },
|
{ Icon: FileCode, orbitRadius: 52, speed: 12, startAngle: 144, size: 15 },
|
||||||
{ Icon: GitCommit, delay: '1.2s', x: 40, y: 15 },
|
{ Icon: GitCommit, orbitRadius: 52, speed: 12, startAngle: 216, size: 13 },
|
||||||
{ Icon: RefreshCw, delay: '1.6s', x: 0, y: -40 },
|
{ Icon: GitBranch, orbitRadius: 52, speed: 12, startAngle: 288, size: 14 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ChangesLoadingAnimation = (): React.JSX.Element => {
|
const particles = Array.from({ length: 8 }, (_, i) => ({
|
||||||
return (
|
id: i,
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-6">
|
delay: i * 0.6,
|
||||||
{/* Animated icons cluster */}
|
duration: 3 + (i % 3) * 0.8,
|
||||||
<div className="relative flex size-28 items-center justify-center">
|
startAngle: i * 45,
|
||||||
{/* Central pulsing ring */}
|
radius: 34 + (i % 3) * 14,
|
||||||
<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' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Floating file icons */}
|
const messages = ['Analyzing files…', 'Computing diffs…', 'Loading changes…', 'Resolving hunks…'];
|
||||||
{floatingIcons.map(({ Icon, delay, x, y }, i) => (
|
|
||||||
|
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
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute text-[var(--color-text-muted)]"
|
className="absolute left-1/2 top-1/2"
|
||||||
style={{
|
style={
|
||||||
animation: `changesFloat 2.5s ease-in-out infinite ${delay}`,
|
{
|
||||||
left: `calc(50% + ${x}px)`,
|
animation: `changesOrbit ${speed}s linear infinite`,
|
||||||
top: `calc(50% + ${y}px)`,
|
'--orbit-radius': `${orbitRadius}px`,
|
||||||
transform: 'translate(-50%, -50%)',
|
'--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>
|
</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
|
<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)]"
|
className="absolute size-16 rounded-2xl bg-[var(--color-text-muted)] opacity-[0.03]"
|
||||||
style={{ animation: 'changesBreath 2s ease-in-out infinite' }}
|
style={{ animation: 'changesGlowPulse 3s ease-in-out infinite 0.5s' }}
|
||||||
>
|
/>
|
||||||
<FileDiff size={20} className="text-[var(--color-text-secondary)]" />
|
|
||||||
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Segmented progress */}
|
||||||
<div className="w-48 overflow-hidden rounded-full bg-[var(--color-surface-raised)]">
|
<div className="flex gap-1">
|
||||||
<div
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
className="h-0.5 rounded-full bg-[var(--color-text-muted)]"
|
<div
|
||||||
style={{ animation: 'changesProgress 2s ease-in-out infinite' }}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Text */}
|
{/* Rotating message */}
|
||||||
<p
|
<p
|
||||||
className="text-xs font-medium tracking-wide text-[var(--color-text-muted)]"
|
className="h-4 text-xs font-medium tracking-wide text-[var(--color-text-muted)] transition-opacity duration-300"
|
||||||
style={{ animation: 'changesFade 2s ease-in-out infinite' }}
|
style={{ opacity: isFading ? 0 : 0.8 }}
|
||||||
>
|
>
|
||||||
Loading changes...
|
{messages[msgIndex]}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes changesFloat {
|
.changes-orbit-ring {
|
||||||
0%, 100% { opacity: 0.3; transform: translate(-50%, -50%) scale(0.85); }
|
animation: changesRingSpin 20s linear infinite;
|
||||||
50% { opacity: 0.7; transform: translate(-50%, calc(-50% - 6px)) scale(1); }
|
|
||||||
}
|
}
|
||||||
@keyframes changesBreath {
|
.changes-orbit-ring-reverse {
|
||||||
0%, 100% { transform: scale(1); }
|
animation: changesRingSpin 15s linear infinite reverse;
|
||||||
50% { transform: scale(1.06); }
|
|
||||||
}
|
}
|
||||||
@keyframes changesProgress {
|
.changes-glow-pulse {
|
||||||
0% { width: 0%; margin-left: 0; }
|
animation: changesGlowPulse 3s ease-in-out infinite;
|
||||||
50% { width: 60%; margin-left: 20%; }
|
|
||||||
100% { width: 0%; margin-left: 100%; }
|
|
||||||
}
|
}
|
||||||
@keyframes changesFade {
|
.changes-center-morph {
|
||||||
0%, 100% { opacity: 0.5; }
|
animation: changesCenterMorph 4s ease-in-out infinite;
|
||||||
50% { opacity: 1; }
|
}
|
||||||
|
.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>
|
`}</style>
|
||||||
</div>
|
</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