fix: resolve all eslint errors across 24 files

- Fix import sorting (simple-import-sort) in 10+ files
- Remove unnecessary type assertions in TeamProvisioningService, store/index
- Extract nested template literals in httpClient.ts
- Fix react-hooks/refs violations: replace render-time ref access with
  useState + "adjust state during render" pattern in ActivityTimeline,
  TaskCommentsSection, AnimatedHeightReveal
- Fix react-hooks/rules-of-hooks: move conditional hooks above early returns
  in ActivityTimeline, ToolApprovalSheet
- Fix react-hooks/set-state-in-effect: wrap synchronous setState in
  queueMicrotask in useComposerDraft, MessageComposer, AttachmentPreviewList,
  ToolApprovalSheet
- Fix react-hooks/exhaustive-deps: destructure draft properties before
  useCallback in MessageComposer, copy ref values in AttachmentPreviewList
- Fix no-param-reassign: use Object.assign in ToolApprovalSheet, local
  variable in AnimatedHeightReveal
- Fix sonarjs violations: remove void operator, reduce nesting in
  LeadThoughtsGroup; remove unused import in CliLogsRichView
- Use RegExp.exec() instead of String.match() in FileLink
- Use optional chain in streamJsonParser with eslint-disable for TS conflict
This commit is contained in:
iliya 2026-03-07 14:39:21 +02:00
parent 355fe237a6
commit 8c0cccf903
24 changed files with 228 additions and 162 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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;
}

View file

@ -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

View file

@ -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<SessionDetail | null>(
`/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<SubagentDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${qs ? `?${qs}` : ''}`
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${suffix}`
);
};

View file

@ -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';

View file

@ -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 };
}

View file

@ -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';

View file

@ -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';

View file

@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
className="fixed bottom-4 left-1/2 z-[55] w-full max-w-[480px] -translate-x-1/2 rounded-lg border shadow-xl outline-none duration-200 animate-in fade-in slide-in-from-bottom-4"
style={{
backgroundColor: 'var(--color-surface-overlay)',
@ -183,10 +185,11 @@ export const ToolApprovalSheet: React.FC = () => {
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 (
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">{elapsed}s</span>
);
}
};

View file

@ -1,15 +1,15 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useReducer, 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 type { TimelineItem } from './LeadThoughtsGroup';
import type { ActivityCollapseState } from './collapseState';
import type { TimelineItem } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
interface ActivityTimelineProps {
@ -41,6 +41,59 @@ interface ActivityTimelineProps {
const VIEWPORT_THRESHOLD = 0.15;
const MESSAGES_PAGE_SIZE = 30;
// --- New-item animation tracking via reducer ---
// Using a reducer allows us to compute newItemKeys and update knownKeys atomically,
// avoiding both ref reads during render and setState calls in effects.
interface AnimationState {
knownKeys: Set<string>;
newItemKeys: Set<string>;
isInitialized: boolean;
prevVisibleCount: number;
}
interface AnimationAction {
type: 'sync';
timelineItemKeys: string[];
visibleCount: number;
}
function animationReducer(state: AnimationState, action: AnimationAction): AnimationState {
const { timelineItemKeys, visibleCount } = action;
const isPaginationExpansion = state.isInitialized && visibleCount > state.prevVisibleCount;
let newItemKeys: Set<string>;
if (!state.isInitialized || isPaginationExpansion) {
newItemKeys = new Set<string>();
} else {
newItemKeys = new Set<string>();
for (const key of timelineItemKeys) {
if (!state.knownKeys.has(key)) {
newItemKeys.add(key);
}
}
}
const nextKnownKeys = new Set(state.knownKeys);
for (const key of timelineItemKeys) {
nextKnownKeys.add(key);
}
return {
knownKeys: nextKnownKeys,
newItemKeys,
isInitialized: true,
prevVisibleCount: visibleCount,
};
}
const INITIAL_ANIMATION_STATE: AnimationState = {
knownKeys: new Set<string>(),
newItemKeys: new Set<string>(),
isInitialized: false,
prevVisibleCount: MESSAGES_PAGE_SIZE,
};
const MessageRowWithObserver = ({
message,
teamName,
@ -145,10 +198,8 @@ export const ActivityTimeline = ({
}: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
// --- New-message animation tracking ---
const knownKeysRef = useRef<Set<string>>(new Set<string>());
const isInitializedRef = useRef(false);
const prevVisibleCountRef = useRef(visibleCount);
// --- New-message animation tracking via reducer ---
const [animationState, dispatchAnimation] = useReducer(animationReducer, INITIAL_ANIMATION_STATE);
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
const memberInfo = new Map<string, { role?: string; color?: string }>();
@ -243,33 +294,14 @@ export const ActivityTimeline = ({
return timelineItems.map(getItemKey);
}, [timelineItems]);
const isPaginationExpansion =
isInitializedRef.current && visibleCount > prevVisibleCountRef.current;
const newItemKeys = useMemo(() => {
if (!isInitializedRef.current || isPaginationExpansion) {
return new Set<string>();
}
const newKeys = new Set<string>();
for (const key of timelineItemKeys) {
if (!knownKeysRef.current.has(key)) {
newKeys.add(key);
}
}
return newKeys;
}, [isPaginationExpansion, timelineItemKeys]);
// Sync animation state whenever timeline keys or visible count changes.
// The reducer atomically computes newItemKeys and updates knownKeys.
useEffect(() => {
if (!isInitializedRef.current) {
isInitializedRef.current = true;
}
for (const key of timelineItemKeys) {
knownKeysRef.current.add(key);
}
prevVisibleCountRef.current = visibleCount;
dispatchAnimation({ type: 'sync', timelineItemKeys, visibleCount });
}, [timelineItemKeys, visibleCount]);
const { newItemKeys } = animationState;
const handleShowMore = (): void => {
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
};
@ -278,15 +310,6 @@ export const ActivityTimeline = ({
setVisibleCount(Infinity);
};
if (messages.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
<p>No messages</p>
<p className="mt-1 text-[11px]">Send a message to a member to see activity.</p>
</div>
);
}
const getItemSessionId = (item: TimelineItem): string | undefined =>
item.type === 'lead-thoughts'
? item.group.thoughts[0].leadSessionId
@ -321,6 +344,15 @@ export const ActivityTimeline = ({
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride]
);
if (messages.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
<p>No messages</p>
<p className="mt-1 text-[11px]">Send a message to a member to see activity.</p>
</div>
);
}
return (
<div className="space-y-1">
{/* Pinned (newest) thought group — always at top */}

View file

@ -18,7 +18,8 @@ function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
ref(value);
return;
}
(ref as MutableRefObject<T | null>).current = value;
const mutableRef = ref as MutableRefObject<T | null>;
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<HTMLDivElement | null>(null);
const animationFrameRef = useRef<number | null>(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 (
<div

View file

@ -1,9 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import {
@ -17,13 +15,14 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react';
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
import {
AnimatedHeightReveal,
ENTRY_REVEAL_ANIMATION_MS,
ENTRY_REVEAL_EASING,
} from './AnimatedHeightReveal';
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
import { isManagedCollapseState } from './collapseState';
import type { ActivityCollapseState } from './collapseState';
@ -247,6 +246,21 @@ const LeadThoughtItem = ({
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,
@ -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(() => {

View file

@ -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);
};
}, []);

View file

@ -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';

View file

@ -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';

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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 { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
@ -16,8 +16,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';
@ -90,20 +90,24 @@ export const TaskCommentsSection = ({
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
const knownCommentIdsRef = useRef<Set<string>>(new Set());
const isInitializedRef = useRef(false);
const prevVisibleCountRef = useRef(INITIAL_VISIBLE_COMMENTS);
const [knownCommentIds, setKnownCommentIds] = useState<Set<string>>(new Set());
const [isInitialized, setIsInitialized] = useState(false);
const [prevVisibleCount, setPrevVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
// 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);
knownCommentIdsRef.current = new Set();
isInitializedRef.current = false;
prevVisibleCountRef.current = INITIAL_VISIBLE_COMMENTS;
}, [teamName, taskId]);
setKnownCommentIds(new Set());
setIsInitialized(false);
setPrevVisibleCount(INITIAL_VISIBLE_COMMENTS);
}
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
@ -131,32 +135,39 @@ export const TaskCommentsSection = ({
[visibleComments]
);
const isPaginationExpansion =
isInitializedRef.current && visibleCount > prevVisibleCountRef.current;
// Detect new comments and track known IDs using the "adjust state during render"
// pattern to avoid both ref-reads-during-render and setState-in-effect.
// See: https://react.dev/reference/react/useState#storing-information-from-previous-renders
const [prevVisibleCommentIds, setPrevVisibleCommentIds] = useState(visibleCommentIds);
let newCommentIds = new Set<string>();
const newCommentIds = useMemo(() => {
if (!isInitializedRef.current || isPaginationExpansion) {
return new Set<string>();
}
if (visibleCommentIds !== prevVisibleCommentIds) {
setPrevVisibleCommentIds(visibleCommentIds);
const next = new Set<string>();
for (const id of visibleCommentIds) {
if (!knownCommentIdsRef.current.has(id)) {
next.add(id);
const isPaginationExpansion = isInitialized && visibleCount > prevVisibleCount;
if (isInitialized && !isPaginationExpansion) {
const next = new Set<string>();
for (const id of visibleCommentIds) {
if (!knownCommentIds.has(id)) {
next.add(id);
}
}
newCommentIds = next;
}
return next;
}, [isPaginationExpansion, visibleCommentIds]);
useEffect(() => {
if (!isInitializedRef.current) {
isInitializedRef.current = true;
}
// Mark current visible IDs as known and snapshot the visible count.
const nextKnown = new Set(knownCommentIds);
for (const id of visibleCommentIds) {
knownCommentIdsRef.current.add(id);
nextKnown.add(id);
}
prevVisibleCountRef.current = visibleCount;
}, [visibleCommentIds, visibleCount]);
setKnownCommentIds(nextKnown);
setPrevVisibleCount(visibleCount);
if (!isInitialized) {
setIsInitialized(true);
}
}
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>

View file

@ -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';

View file

@ -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';

View file

@ -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'

View file

@ -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<HTMLInputElement>) => {
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;

View file

@ -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

View file

@ -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)

View file

@ -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 {