diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index bcd9964c..3d5aa7c0 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -68,6 +68,8 @@ import * as path from 'path'; import { ConfigManager } from '../services/infrastructure/ConfigManager'; import { NotificationManager } from '../services/infrastructure/NotificationManager'; import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver'; +import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; +import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { validateFromField, @@ -77,9 +79,6 @@ import { validateTeamName, } from './guards'; -import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; -import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; - import type { MemberStatsComputer, TeamDataService, diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 413ee08f..22381588 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -31,6 +31,7 @@ import { buildDetectedErrorFromTeam, type TeamNotificationPayload, } from '@main/utils/teamNotificationBuilder'; + import { projectPathResolver } from '../discovery/ProjectPathResolver'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; @@ -39,7 +40,7 @@ import { ConfigManager } from './ConfigManager'; // Re-export DetectedError for backward compatibility export type { DetectedError }; // Re-export team notification types for callers -export type { TeamNotificationPayload, TeamEventType } from '@main/utils/teamNotificationBuilder'; +export type { TeamEventType, TeamNotificationPayload } from '@main/utils/teamNotificationBuilder'; /** * Stored notification with read status. diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d41c84e6..b7268270 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3355,7 +3355,7 @@ export class TeamProvisioningService { currentMembers = config.members .filter((m) => m?.agentType !== 'team-lead' && m?.name) .map((m) => ({ - name: m.name!, + name: m.name, role: m.role ?? undefined, })); } else { @@ -3403,7 +3403,7 @@ export class TeamProvisioningService { } if (run.leadActivityState !== 'idle') { logger.info( - `[${run.teamName}] post-compact reminder aborted — lead activity changed to ${run.leadActivityState}` + `[${run.teamName}] post-compact reminder aborted — lead activity changed to ${run.leadActivityState as string}` ); return; } diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index cacff342..6f0dc403 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -7,9 +7,8 @@ import { randomUUID } from 'crypto'; -import type { TriggerColor } from '@shared/constants/triggerColors'; - import type { DetectedError } from '../services/error/ErrorMessageBuilder'; +import type { TriggerColor } from '@shared/constants/triggerColors'; // ============================================================================= // Types diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 30cb0f65..cd3552b8 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -254,8 +254,9 @@ export class HttpAPIClient implements ElectronAPI { const params = new URLSearchParams(); if (options?.bypassCache) params.set('bypassCache', 'true'); const qs = params.toString(); + const suffix = qs ? `?${qs}` : ''; return this.get( - `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}` + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${suffix}` ); }; @@ -278,8 +279,9 @@ export class HttpAPIClient implements ElectronAPI { const params = new URLSearchParams(); if (options?.bypassCache) params.set('bypassCache', 'true'); const qs = params.toString(); + const suffix = qs ? `?${qs}` : ''; return this.get( - `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${qs ? `?${qs}` : ''}` + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${suffix}` ); }; diff --git a/src/renderer/components/chat/markdownComponents.tsx b/src/renderer/components/chat/markdownComponents.tsx index f093a572..1af9fd2a 100644 --- a/src/renderer/components/chat/markdownComponents.tsx +++ b/src/renderer/components/chat/markdownComponents.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { PROSE_BODY } from '@renderer/constants/cssVariables'; -import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils'; import { FileLink, isRelativeUrl } from './viewers/FileLink'; +import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils'; import type { Components } from 'react-markdown'; diff --git a/src/renderer/components/chat/viewers/FileLink.tsx b/src/renderer/components/chat/viewers/FileLink.tsx index 34dd9522..5a2c29c7 100644 --- a/src/renderer/components/chat/viewers/FileLink.tsx +++ b/src/renderer/components/chat/viewers/FileLink.tsx @@ -10,9 +10,10 @@ import React from 'react'; import { PROSE_LINK } from '@renderer/constants/cssVariables'; import { useStore } from '@renderer/store'; -import type { AppState } from '@renderer/store/types'; import { Check, FileCode } from 'lucide-react'; +import type { AppState } from '@renderer/store/types'; + // ============================================================================= // Exported utilities // ============================================================================= @@ -25,7 +26,7 @@ export function parsePathWithLine(href: string): { filePath: string; line: numbe } catch { decoded = href; } - const match = decoded.match(/^(.+?):(\d+)$/); + const match = /^(.+?):(\d+)$/.exec(decoded); if (match) return { filePath: match[1], line: parseInt(match[2], 10) }; return { filePath: decoded, line: null }; } diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index c5ccf6be..3abd0139 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -12,14 +12,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils'; import { cn } from '@renderer/lib/utils'; -import { parseStreamJsonToGroups, groupBySubagent } from '@renderer/utils/streamJsonParser'; +import { groupBySubagent, parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; import { Bot, ChevronRight } from 'lucide-react'; -import type { - StreamJsonGroup, - StreamJsonEntry, - SubagentSection, -} from '@renderer/utils/streamJsonParser'; +import type { StreamJsonGroup, SubagentSection } from '@renderer/utils/streamJsonParser'; type CliLogsOrder = 'oldest-first' | 'newest-first'; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 7ca55c13..575ed393 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -16,8 +16,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useTabUI } from '@renderer/hooks/useTabUI'; -import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; +import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 2268b088..3cbfa141 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -11,7 +11,7 @@ import type { ToolApprovalRequest } from '@shared/types'; // Tool icon mapping // --------------------------------------------------------------------------- -function getToolIcon(toolName: string): React.ReactNode { +function getToolIcon(toolName: string): React.JSX.Element { const cls = 'size-4 shrink-0'; switch (toolName) { case 'Bash': @@ -60,9 +60,11 @@ function useElapsed(receivedAt: string): number { ); useEffect(() => { - setElapsed(Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000))); + const computeElapsed = (): number => + Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000)); + queueMicrotask(() => setElapsed(computeElapsed())); const id = setInterval(() => { - setElapsed(Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000))); + setElapsed(computeElapsed()); }, 1000); return () => clearInterval(id); }, [receivedAt]); @@ -78,6 +80,7 @@ export const ToolApprovalSheet: React.FC = () => { const pendingApprovals = useStore((s) => s.pendingApprovals); const respondToToolApproval = useStore((s) => s.respondToToolApproval); const teams = useStore((s) => s.teams); + const { isLight } = useTheme(); const current: ToolApprovalRequest | undefined = pendingApprovals[0]; const containerRef = useRef(null); @@ -96,8 +99,8 @@ export const ToolApprovalSheet: React.FC = () => { [current, disabled, respondToToolApproval] ); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Enter') { e.preventDefault(); handleRespond(true); @@ -105,21 +108,20 @@ export const ToolApprovalSheet: React.FC = () => { e.preventDefault(); handleRespond(false); } - }, - [handleRespond] - ); + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleRespond]); if (!current) return null; const teamSummary = teams.find((t) => t.teamName === current.teamName); const teamColor = teamSummary?.color ? getTeamColorSet(teamSummary.color) : null; - const { isLight } = useTheme(); return (
{ className="rounded-md px-3.5 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50" style={{ backgroundColor: 'rgb(5, 150, 105)' }} onMouseEnter={(e) => { - if (!disabled) e.currentTarget.style.backgroundColor = 'rgb(16, 185, 129)'; + if (!disabled) + Object.assign(e.currentTarget.style, { backgroundColor: 'rgb(16, 185, 129)' }); }} onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'rgb(5, 150, 105)'; + Object.assign(e.currentTarget.style, { backgroundColor: 'rgb(5, 150, 105)' }); }} > Allow @@ -201,10 +204,13 @@ export const ToolApprovalSheet: React.FC = () => { color: 'rgb(248, 113, 113)', }} onMouseEnter={(e) => { - if (!disabled) e.currentTarget.style.backgroundColor = 'rgba(239, 68, 68, 0.1)'; + if (!disabled) + Object.assign(e.currentTarget.style, { + backgroundColor: 'rgba(239, 68, 68, 0.1)', + }); }} onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'transparent'; + Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' }); }} > Deny @@ -224,9 +230,9 @@ export const ToolApprovalSheet: React.FC = () => { // Elapsed display sub-component (uses hook) // --------------------------------------------------------------------------- -function ElapsedDisplay({ receivedAt }: { receivedAt: string }): React.JSX.Element { +const ElapsedDisplay = ({ receivedAt }: { receivedAt: string }): React.JSX.Element => { const elapsed = useElapsed(receivedAt); return ( {elapsed}s ); -} +}; diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index c546961c..5f3d965e 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -3,14 +3,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { AnimatedHeightReveal } from './AnimatedHeightReveal'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; +import { AnimatedHeightReveal } from './AnimatedHeightReveal'; import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState'; import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; import { useNewItemKeys } from './useNewItemKeys'; -import type { TimelineItem } from './LeadThoughtsGroup'; import type { ActivityCollapseState } from './collapseState'; +import type { TimelineItem } from './LeadThoughtsGroup'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; interface ActivityTimelineProps { @@ -253,15 +253,6 @@ export const ActivityTimeline = ({ setVisibleCount(Infinity); }; - if (messages.length === 0) { - return ( -
-

No messages

-

Send a message to a member to see activity.

-
- ); - } - const getItemSessionId = (item: TimelineItem): string | undefined => item.type === 'lead-thoughts' ? item.group.thoughts[0].leadSessionId @@ -296,6 +287,15 @@ export const ActivityTimeline = ({ [allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] ); + if (messages.length === 0) { + return ( +
+

No messages

+

Send a message to a member to see activity.

+
+ ); + } + return (
{/* Pinned (newest) thought group — always at top */} diff --git a/src/renderer/components/team/activity/AnimatedHeightReveal.tsx b/src/renderer/components/team/activity/AnimatedHeightReveal.tsx index e47b54a9..6c2bdc2e 100644 --- a/src/renderer/components/team/activity/AnimatedHeightReveal.tsx +++ b/src/renderer/components/team/activity/AnimatedHeightReveal.tsx @@ -18,7 +18,8 @@ function assignRef(ref: Ref | undefined, value: T | null): void { ref(value); return; } - (ref as MutableRefObject).current = value; + const mutableRef = ref as MutableRefObject; + mutableRef.current = value; } export const AnimatedHeightReveal = ({ @@ -28,11 +29,15 @@ export const AnimatedHeightReveal = ({ containerRef, children, }: AnimatedHeightRevealProps): JSX.Element => { - const shouldAnimateOnMountRef = useRef(Boolean(animate)); + const [shouldAnimateOnMount] = useState(() => Boolean(animate)); const wrapperRef = useRef(null); const animationFrameRef = useRef(null); - const prefersReducedMotionRef = useRef(false); - const [isExpanded, setIsExpanded] = useState(() => !shouldAnimateOnMountRef.current); + const [prefersReducedMotion] = useState( + () => window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); + const [isExpanded, setIsExpanded] = useState( + () => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); const setWrapperRef = useCallback( (node: HTMLDivElement | null) => { @@ -50,9 +55,7 @@ export const AnimatedHeightReveal = ({ }, []); useEffect(() => { - prefersReducedMotionRef.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - if (!shouldAnimateOnMountRef.current || prefersReducedMotionRef.current) { - setIsExpanded(true); + if (!shouldAnimateOnMount || prefersReducedMotion) { return; } @@ -66,7 +69,7 @@ export const AnimatedHeightReveal = ({ return () => { clearPendingAnimation(); }; - }, [clearPendingAnimation]); + }, [clearPendingAnimation, shouldAnimateOnMount, prefersReducedMotion]); useEffect( () => () => { @@ -75,8 +78,7 @@ export const AnimatedHeightReveal = ({ [clearPendingAnimation] ); - const shouldTransition = - shouldAnimateOnMountRef.current && !prefersReducedMotionRef.current && isExpanded; + const shouldTransition = shouldAnimateOnMount && !prefersReducedMotion && isExpanded; return (
{ + 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, @@ -258,17 +272,12 @@ const LeadThoughtItem = ({ wrapper.style.height = `${Math.max(startHeight, 0)}px`; wrapper.style.opacity = `${startOpacity}`; wrapper.style.willChange = 'height, opacity'; - void wrapper.offsetHeight; + // 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(() => { - animationFrameRef.current = requestAnimationFrame(() => { - 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'; - }); + scheduleTransition(targetHeight); }); cleanupTimerRef.current = window.setTimeout(() => { diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx index 91fd1f98..fbc3c402 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx @@ -49,10 +49,12 @@ export const AttachmentPreviewList = ({ if (newIds.size === 0) return; - setEnteringIds((prev) => { - const next = new Set(prev); - for (const id of newIds) next.add(id); - return next; + queueMicrotask(() => { + setEnteringIds((prev) => { + const next = new Set(prev); + for (const id of newIds) next.add(id); + return next; + }); }); // Clear entering state after animation completes @@ -71,9 +73,11 @@ export const AttachmentPreviewList = ({ // Cleanup timers on unmount useEffect(() => { + const exitTimers = exitTimersRef.current; + const enterTimers = enterTimersRef.current; return () => { - for (const t of exitTimersRef.current.values()) window.clearTimeout(t); - for (const t of enterTimersRef.current.values()) window.clearTimeout(t); + for (const t of exitTimers.values()) window.clearTimeout(t); + for (const t of enterTimers.values()) window.clearTimeout(t); }; }, []); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index e42cb0fa..2a58309a 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -23,9 +23,8 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; - import { MAX_TEXT_LENGTH } from '@shared/constants'; +import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 1aae6f50..9fa24aff 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -8,9 +8,8 @@ import { useStore } from '@renderer/store'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; - import { MAX_TEXT_LENGTH } from '@shared/constants'; +import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 411e6ffe..4302ae9b 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { CopyButton } from '@renderer/components/common/CopyButton'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { CopyButton } from '@renderer/components/common/CopyButton'; import { AnimatedHeightReveal } from '@renderer/components/team/activity/AnimatedHeightReveal'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { useNewItemKeys } from '@renderer/components/team/activity/useNewItemKeys'; @@ -17,8 +17,8 @@ import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessage import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { MAX_TEXT_LENGTH } from '@shared/constants'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react'; @@ -92,13 +92,17 @@ export const TaskCommentsSection = ({ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS); const [previewImageUrl, setPreviewImageUrl] = useState(null); - // Reset local UI state when team/task changes. - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change + // Reset local UI state when team/task changes using the + // "adjust state during render" pattern (no effect needed). + // See: https://react.dev/reference/react/useState#storing-information-from-previous-renders + const resetKey = `${teamName}:${taskId}`; + const [prevResetKey, setPrevResetKey] = useState(resetKey); + if (resetKey !== prevResetKey) { + setPrevResetKey(resetKey); setVisibleCount(INITIAL_VISIBLE_COMMENTS); setReplyTo(null); setPreviewImageUrl(null); - }, [teamName, taskId]); + } const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 74d03e15..5cafc18f 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; -import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; +import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index d3094d96..c6401b23 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,5 +1,5 @@ -import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { useStore } from '@renderer/store'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { MemberCard } from './MemberCard'; diff --git a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx index a49f2d15..504b5421 100644 --- a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx +++ b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; -import { ChevronDown, ChevronUp } from 'lucide-react'; import { format } from 'date-fns'; +import { ChevronDown, ChevronUp } from 'lucide-react'; export type SubagentPreviewMessageKind = | 'output' diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 41e598cb..d0f5e452 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -12,9 +12,8 @@ import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; - import { MAX_TEXT_LENGTH } from '@shared/constants'; +import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types'; @@ -112,7 +111,7 @@ export const MessageComposer = ({ const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead'); const next = lead?.name ?? members[0]?.name ?? ''; if (next && next !== recipient) { - setRecipient(next); + queueMicrotask(() => setRecipient(next)); } }, [members, recipient]); @@ -189,15 +188,16 @@ export const MessageComposer = ({ [handleSend] ); + const { addFiles: draftAddFiles } = draft; const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const input = e.target; if (input.files?.length) { - void draft.addFiles(input.files); + void draftAddFiles(input.files); } input.value = ''; }, - [draft.addFiles] + [draftAddFiles] ); const handleDragEnter = useCallback((e: React.DragEvent) => { @@ -219,20 +219,22 @@ export const MessageComposer = ({ e.preventDefault(); }, []); + const { handleDrop: draftHandleDrop } = draft; const handleDropWrapper = useCallback( (e: React.DragEvent) => { dragCounterRef.current = 0; setIsDragOver(false); - if (canAttach) draft.handleDrop(e); + if (canAttach) draftHandleDrop(e); }, - [canAttach, draft.handleDrop] + [canAttach, draftHandleDrop] ); + const { handlePaste: draftHandlePaste } = draft; const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { - if (canAttach) draft.handlePaste(e); + if (canAttach) draftHandlePaste(e); }, - [canAttach, draft.handlePaste] + [canAttach, draftHandlePaste] ); const remaining = MAX_TEXT_LENGTH - trimmed.length; diff --git a/src/renderer/hooks/useComposerDraft.ts b/src/renderer/hooks/useComposerDraft.ts index 5b05dbb6..ac22af8e 100644 --- a/src/renderer/hooks/useComposerDraft.ts +++ b/src/renderer/hooks/useComposerDraft.ts @@ -14,8 +14,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { - composerDraftStorage, type ComposerDraftSnapshot, + composerDraftStorage, } from '@renderer/services/composerDraftStorage'; import { fileToAttachmentPayload, @@ -190,12 +190,16 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult { flushPending(); userTouchedRef.current = false; - // Reset to empty immediately for the new teamName + // Reset to empty for the new teamName. + // Wrapped in queueMicrotask to avoid synchronous setState inside effect body. const empty = composerDraftStorage.emptySnapshot(teamName); - applySnapshot(empty); - setIsSaved(false); - setIsLoaded(false); - setAttachmentError(null); + queueMicrotask(() => { + if (cancelled) return; + applySnapshot(empty); + setIsSaved(false); + setIsLoaded(false); + setAttachmentError(null); + }); void (async () => { // Try loading unified snapshot first diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 62e5fedc..c6122b81 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -32,7 +32,6 @@ import type { CliInstallerProgress, LeadContextUsage, TeamChangeEvent, - ToolApprovalDismiss, ToolApprovalEvent, ToolApprovalRequest, UpdaterStatus, @@ -452,7 +451,7 @@ export function initializeNotificationListeners(): () => void { const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => { const event = data as ToolApprovalEvent; if ('dismissed' in event && event.dismissed) { - const dismiss = event as ToolApprovalDismiss; + const dismiss = event; useStore.setState((s) => ({ pendingApprovals: s.pendingApprovals.filter( (a) => !(a.teamName === dismiss.teamName && a.runId === dismiss.runId) diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index 040e8332..f5ec6d44 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -350,6 +350,7 @@ export function groupBySubagent(groups: StreamJsonGroup[]): StreamJsonEntry[] { agentDescMap.set(group.agentId, pendingDescriptions.shift() ?? 'Subagent'); } + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- optional chain narrows to `never` in loop body if (currentRun && currentRun.agentId === group.agentId) { currentRun.groups.push(group); } else {