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 { ConfigManager } from '../services/infrastructure/ConfigManager';
import { NotificationManager } from '../services/infrastructure/NotificationManager'; import { NotificationManager } from '../services/infrastructure/NotificationManager';
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver'; import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { import {
validateFromField, validateFromField,
@ -77,9 +79,6 @@ import {
validateTeamName, validateTeamName,
} from './guards'; } from './guards';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import type { import type {
MemberStatsComputer, MemberStatsComputer,
TeamDataService, TeamDataService,

View file

@ -31,6 +31,7 @@ import {
buildDetectedErrorFromTeam, buildDetectedErrorFromTeam,
type TeamNotificationPayload, type TeamNotificationPayload,
} from '@main/utils/teamNotificationBuilder'; } from '@main/utils/teamNotificationBuilder';
import { projectPathResolver } from '../discovery/ProjectPathResolver'; import { projectPathResolver } from '../discovery/ProjectPathResolver';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
@ -39,7 +40,7 @@ import { ConfigManager } from './ConfigManager';
// Re-export DetectedError for backward compatibility // Re-export DetectedError for backward compatibility
export type { DetectedError }; export type { DetectedError };
// Re-export team notification types for callers // 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. * Stored notification with read status.

View file

@ -3355,7 +3355,7 @@ export class TeamProvisioningService {
currentMembers = config.members currentMembers = config.members
.filter((m) => m?.agentType !== 'team-lead' && m?.name) .filter((m) => m?.agentType !== 'team-lead' && m?.name)
.map((m) => ({ .map((m) => ({
name: m.name!, name: m.name,
role: m.role ?? undefined, role: m.role ?? undefined,
})); }));
} else { } else {
@ -3403,7 +3403,7 @@ export class TeamProvisioningService {
} }
if (run.leadActivityState !== 'idle') { if (run.leadActivityState !== 'idle') {
logger.info( 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; return;
} }

View file

@ -7,9 +7,8 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import type { TriggerColor } from '@shared/constants/triggerColors';
import type { DetectedError } from '../services/error/ErrorMessageBuilder'; import type { DetectedError } from '../services/error/ErrorMessageBuilder';
import type { TriggerColor } from '@shared/constants/triggerColors';
// ============================================================================= // =============================================================================
// Types // Types

View file

@ -254,8 +254,9 @@ export class HttpAPIClient implements ElectronAPI {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.bypassCache) params.set('bypassCache', 'true'); if (options?.bypassCache) params.set('bypassCache', 'true');
const qs = params.toString(); const qs = params.toString();
const suffix = qs ? `?${qs}` : '';
return this.get<SessionDetail | null>( 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(); const params = new URLSearchParams();
if (options?.bypassCache) params.set('bypassCache', 'true'); if (options?.bypassCache) params.set('bypassCache', 'true');
const qs = params.toString(); const qs = params.toString();
const suffix = qs ? `?${qs}` : '';
return this.get<SubagentDetail | null>( 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 { PROSE_BODY } from '@renderer/constants/cssVariables';
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
import { FileLink, isRelativeUrl } from './viewers/FileLink'; import { FileLink, isRelativeUrl } from './viewers/FileLink';
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
import type { Components } from 'react-markdown'; import type { Components } from 'react-markdown';

View file

@ -10,9 +10,10 @@ import React from 'react';
import { PROSE_LINK } from '@renderer/constants/cssVariables'; import { PROSE_LINK } from '@renderer/constants/cssVariables';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import type { AppState } from '@renderer/store/types';
import { Check, FileCode } from 'lucide-react'; import { Check, FileCode } from 'lucide-react';
import type { AppState } from '@renderer/store/types';
// ============================================================================= // =============================================================================
// Exported utilities // Exported utilities
// ============================================================================= // =============================================================================
@ -25,7 +26,7 @@ export function parsePathWithLine(href: string): { filePath: string; line: numbe
} catch { } catch {
decoded = href; decoded = href;
} }
const match = decoded.match(/^(.+?):(\d+)$/); const match = /^(.+?):(\d+)$/.exec(decoded);
if (match) return { filePath: match[1], line: parseInt(match[2], 10) }; if (match) return { filePath: match[1], line: parseInt(match[2], 10) };
return { filePath: decoded, line: null }; 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 { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils'; import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils';
import { cn } from '@renderer/lib/utils'; 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 { Bot, ChevronRight } from 'lucide-react';
import type { import type { StreamJsonGroup, SubagentSection } from '@renderer/utils/streamJsonParser';
StreamJsonGroup,
StreamJsonEntry,
SubagentSection,
} from '@renderer/utils/streamJsonParser';
type CliLogsOrder = 'oldest-first' | 'newest-first'; 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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';

View file

@ -11,7 +11,7 @@ import type { ToolApprovalRequest } from '@shared/types';
// Tool icon mapping // Tool icon mapping
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getToolIcon(toolName: string): React.ReactNode { function getToolIcon(toolName: string): React.JSX.Element {
const cls = 'size-4 shrink-0'; const cls = 'size-4 shrink-0';
switch (toolName) { switch (toolName) {
case 'Bash': case 'Bash':
@ -60,9 +60,11 @@ function useElapsed(receivedAt: string): number {
); );
useEffect(() => { 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(() => { const id = setInterval(() => {
setElapsed(Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000))); setElapsed(computeElapsed());
}, 1000); }, 1000);
return () => clearInterval(id); return () => clearInterval(id);
}, [receivedAt]); }, [receivedAt]);
@ -78,6 +80,7 @@ export const ToolApprovalSheet: React.FC = () => {
const pendingApprovals = useStore((s) => s.pendingApprovals); const pendingApprovals = useStore((s) => s.pendingApprovals);
const respondToToolApproval = useStore((s) => s.respondToToolApproval); const respondToToolApproval = useStore((s) => s.respondToToolApproval);
const teams = useStore((s) => s.teams); const teams = useStore((s) => s.teams);
const { isLight } = useTheme();
const current: ToolApprovalRequest | undefined = pendingApprovals[0]; const current: ToolApprovalRequest | undefined = pendingApprovals[0];
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -96,8 +99,8 @@ export const ToolApprovalSheet: React.FC = () => {
[current, disabled, respondToToolApproval] [current, disabled, respondToToolApproval]
); );
const handleKeyDown = useCallback( useEffect(() => {
(e: React.KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
handleRespond(true); handleRespond(true);
@ -105,21 +108,20 @@ export const ToolApprovalSheet: React.FC = () => {
e.preventDefault(); e.preventDefault();
handleRespond(false); handleRespond(false);
} }
}, };
[handleRespond]
); document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleRespond]);
if (!current) return null; if (!current) return null;
const teamSummary = teams.find((t) => t.teamName === current.teamName); const teamSummary = teams.find((t) => t.teamName === current.teamName);
const teamColor = teamSummary?.color ? getTeamColorSet(teamSummary.color) : null; const teamColor = teamSummary?.color ? getTeamColorSet(teamSummary.color) : null;
const { isLight } = useTheme();
return ( return (
<div <div
ref={containerRef} 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" 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={{ style={{
backgroundColor: 'var(--color-surface-overlay)', 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" 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)' }} style={{ backgroundColor: 'rgb(5, 150, 105)' }}
onMouseEnter={(e) => { 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) => { onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgb(5, 150, 105)'; Object.assign(e.currentTarget.style, { backgroundColor: 'rgb(5, 150, 105)' });
}} }}
> >
Allow Allow
@ -201,10 +204,13 @@ export const ToolApprovalSheet: React.FC = () => {
color: 'rgb(248, 113, 113)', color: 'rgb(248, 113, 113)',
}} }}
onMouseEnter={(e) => { 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) => { onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'; Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
}} }}
> >
Deny Deny
@ -224,9 +230,9 @@ export const ToolApprovalSheet: React.FC = () => {
// Elapsed display sub-component (uses hook) // 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); const elapsed = useElapsed(receivedAt);
return ( return (
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">{elapsed}s</span> <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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { AnimatedHeightReveal } from './AnimatedHeightReveal';
import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { AnimatedHeightReveal } from './AnimatedHeightReveal';
import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState'; import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState';
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
import type { TimelineItem } from './LeadThoughtsGroup';
import type { ActivityCollapseState } from './collapseState'; import type { ActivityCollapseState } from './collapseState';
import type { TimelineItem } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
interface ActivityTimelineProps { interface ActivityTimelineProps {
@ -41,6 +41,59 @@ interface ActivityTimelineProps {
const VIEWPORT_THRESHOLD = 0.15; const VIEWPORT_THRESHOLD = 0.15;
const MESSAGES_PAGE_SIZE = 30; 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 = ({ const MessageRowWithObserver = ({
message, message,
teamName, teamName,
@ -145,10 +198,8 @@ export const ActivityTimeline = ({
}: ActivityTimelineProps): React.JSX.Element => { }: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
// --- New-message animation tracking --- // --- New-message animation tracking via reducer ---
const knownKeysRef = useRef<Set<string>>(new Set<string>()); const [animationState, dispatchAnimation] = useReducer(animationReducer, INITIAL_ANIMATION_STATE);
const isInitializedRef = useRef(false);
const prevVisibleCountRef = useRef(visibleCount);
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>(); const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
const memberInfo = new Map<string, { role?: string; color?: string }>(); const memberInfo = new Map<string, { role?: string; color?: string }>();
@ -243,33 +294,14 @@ export const ActivityTimeline = ({
return timelineItems.map(getItemKey); return timelineItems.map(getItemKey);
}, [timelineItems]); }, [timelineItems]);
const isPaginationExpansion = // Sync animation state whenever timeline keys or visible count changes.
isInitializedRef.current && visibleCount > prevVisibleCountRef.current; // The reducer atomically computes newItemKeys and updates knownKeys.
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]);
useEffect(() => { useEffect(() => {
if (!isInitializedRef.current) { dispatchAnimation({ type: 'sync', timelineItemKeys, visibleCount });
isInitializedRef.current = true;
}
for (const key of timelineItemKeys) {
knownKeysRef.current.add(key);
}
prevVisibleCountRef.current = visibleCount;
}, [timelineItemKeys, visibleCount]); }, [timelineItemKeys, visibleCount]);
const { newItemKeys } = animationState;
const handleShowMore = (): void => { const handleShowMore = (): void => {
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE); setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
}; };
@ -278,15 +310,6 @@ export const ActivityTimeline = ({
setVisibleCount(Infinity); 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 => const getItemSessionId = (item: TimelineItem): string | undefined =>
item.type === 'lead-thoughts' item.type === 'lead-thoughts'
? item.group.thoughts[0].leadSessionId ? item.group.thoughts[0].leadSessionId
@ -321,6 +344,15 @@ export const ActivityTimeline = ({
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] [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 ( return (
<div className="space-y-1"> <div className="space-y-1">
{/* Pinned (newest) thought group — always at top */} {/* 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); ref(value);
return; return;
} }
(ref as MutableRefObject<T | null>).current = value; const mutableRef = ref as MutableRefObject<T | null>;
mutableRef.current = value;
} }
export const AnimatedHeightReveal = ({ export const AnimatedHeightReveal = ({
@ -28,11 +29,15 @@ export const AnimatedHeightReveal = ({
containerRef, containerRef,
children, children,
}: AnimatedHeightRevealProps): JSX.Element => { }: AnimatedHeightRevealProps): JSX.Element => {
const shouldAnimateOnMountRef = useRef(Boolean(animate)); const [shouldAnimateOnMount] = useState(() => Boolean(animate));
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
const animationFrameRef = useRef<number | null>(null); const animationFrameRef = useRef<number | null>(null);
const prefersReducedMotionRef = useRef(false); const [prefersReducedMotion] = useState(
const [isExpanded, setIsExpanded] = useState(() => !shouldAnimateOnMountRef.current); () => window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
const [isExpanded, setIsExpanded] = useState(
() => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
const setWrapperRef = useCallback( const setWrapperRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
@ -50,9 +55,7 @@ export const AnimatedHeightReveal = ({
}, []); }, []);
useEffect(() => { useEffect(() => {
prefersReducedMotionRef.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!shouldAnimateOnMount || prefersReducedMotion) {
if (!shouldAnimateOnMountRef.current || prefersReducedMotionRef.current) {
setIsExpanded(true);
return; return;
} }
@ -66,7 +69,7 @@ export const AnimatedHeightReveal = ({
return () => { return () => {
clearPendingAnimation(); clearPendingAnimation();
}; };
}, [clearPendingAnimation]); }, [clearPendingAnimation, shouldAnimateOnMount, prefersReducedMotion]);
useEffect( useEffect(
() => () => { () => () => {
@ -75,8 +78,7 @@ export const AnimatedHeightReveal = ({
[clearPendingAnimation] [clearPendingAnimation]
); );
const shouldTransition = const shouldTransition = shouldAnimateOnMount && !prefersReducedMotion && isExpanded;
shouldAnimateOnMountRef.current && !prefersReducedMotionRef.current && isExpanded;
return ( return (
<div <div

View file

@ -1,9 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; 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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { import {
@ -17,13 +15,14 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react';
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
import { import {
AnimatedHeightReveal, AnimatedHeightReveal,
ENTRY_REVEAL_ANIMATION_MS, ENTRY_REVEAL_ANIMATION_MS,
ENTRY_REVEAL_EASING, ENTRY_REVEAL_EASING,
} from './AnimatedHeightReveal'; } from './AnimatedHeightReveal';
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
import { isManagedCollapseState } from './collapseState'; import { isManagedCollapseState } from './collapseState';
import type { ActivityCollapseState } from './collapseState'; import type { ActivityCollapseState } from './collapseState';
@ -247,6 +246,21 @@ const LeadThoughtItem = ({
const content = contentRef.current; const content = contentRef.current;
if (!wrapper || !content) return; 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 = ( const animateHeight = (
targetHeight: number, targetHeight: number,
startHeight: number, startHeight: number,
@ -258,17 +272,12 @@ const LeadThoughtItem = ({
wrapper.style.height = `${Math.max(startHeight, 0)}px`; wrapper.style.height = `${Math.max(startHeight, 0)}px`;
wrapper.style.opacity = `${startOpacity}`; wrapper.style.opacity = `${startOpacity}`;
wrapper.style.willChange = 'height, opacity'; 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(() => {
animationFrameRef.current = requestAnimationFrame(() => { scheduleTransition(targetHeight);
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';
});
}); });
cleanupTimerRef.current = window.setTimeout(() => { cleanupTimerRef.current = window.setTimeout(() => {

View file

@ -49,10 +49,12 @@ export const AttachmentPreviewList = ({
if (newIds.size === 0) return; if (newIds.size === 0) return;
setEnteringIds((prev) => { queueMicrotask(() => {
const next = new Set(prev); setEnteringIds((prev) => {
for (const id of newIds) next.add(id); const next = new Set(prev);
return next; for (const id of newIds) next.add(id);
return next;
});
}); });
// Clear entering state after animation completes // Clear entering state after animation completes
@ -71,9 +73,11 @@ export const AttachmentPreviewList = ({
// Cleanup timers on unmount // Cleanup timers on unmount
useEffect(() => { useEffect(() => {
const exitTimers = exitTimersRef.current;
const enterTimers = enterTimersRef.current;
return () => { return () => {
for (const t of exitTimersRef.current.values()) window.clearTimeout(t); for (const t of exitTimers.values()) window.clearTimeout(t);
for (const t of enterTimersRef.current.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 { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { MAX_TEXT_LENGTH } from '@shared/constants'; import { MAX_TEXT_LENGTH } from '@shared/constants';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge'; import { MemberBadge } from '../MemberBadge';

View file

@ -8,9 +8,8 @@ import { useStore } from '@renderer/store';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
import { MAX_TEXT_LENGTH } from '@shared/constants'; 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 { MentionSuggestion } from '@renderer/types/mention';
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; 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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { AnimatedHeightReveal } from '@renderer/components/team/activity/AnimatedHeightReveal'; import { AnimatedHeightReveal } from '@renderer/components/team/activity/AnimatedHeightReveal';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; 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 { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { MAX_TEXT_LENGTH } from '@shared/constants'; import { MAX_TEXT_LENGTH } from '@shared/constants';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react'; 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 [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null); const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
const knownCommentIdsRef = useRef<Set<string>>(new Set()); const [knownCommentIds, setKnownCommentIds] = useState<Set<string>>(new Set());
const isInitializedRef = useRef(false); const [isInitialized, setIsInitialized] = useState(false);
const prevVisibleCountRef = useRef(INITIAL_VISIBLE_COMMENTS); const [prevVisibleCount, setPrevVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
// Reset local UI state when team/task changes. // Reset local UI state when team/task changes using the
useEffect(() => { // "adjust state during render" pattern (no effect needed).
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change // 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); setVisibleCount(INITIAL_VISIBLE_COMMENTS);
setReplyTo(null); setReplyTo(null);
setPreviewImageUrl(null); setPreviewImageUrl(null);
knownCommentIdsRef.current = new Set(); setKnownCommentIds(new Set());
isInitializedRef.current = false; setIsInitialized(false);
prevVisibleCountRef.current = INITIAL_VISIBLE_COMMENTS; setPrevVisibleCount(INITIAL_VISIBLE_COMMENTS);
}, [teamName, taskId]); }
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
@ -131,32 +135,39 @@ export const TaskCommentsSection = ({
[visibleComments] [visibleComments]
); );
const isPaginationExpansion = // Detect new comments and track known IDs using the "adjust state during render"
isInitializedRef.current && visibleCount > prevVisibleCountRef.current; // 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 (visibleCommentIds !== prevVisibleCommentIds) {
if (!isInitializedRef.current || isPaginationExpansion) { setPrevVisibleCommentIds(visibleCommentIds);
return new Set<string>();
}
const next = new Set<string>(); const isPaginationExpansion = isInitialized && visibleCount > prevVisibleCount;
for (const id of visibleCommentIds) {
if (!knownCommentIdsRef.current.has(id)) { if (isInitialized && !isPaginationExpansion) {
next.add(id); const next = new Set<string>();
for (const id of visibleCommentIds) {
if (!knownCommentIds.has(id)) {
next.add(id);
}
} }
newCommentIds = next;
} }
return next;
}, [isPaginationExpansion, visibleCommentIds]);
useEffect(() => { // Mark current visible IDs as known and snapshot the visible count.
if (!isInitializedRef.current) { const nextKnown = new Set(knownCommentIds);
isInitializedRef.current = true;
}
for (const id of visibleCommentIds) { for (const id of visibleCommentIds) {
knownCommentIdsRef.current.add(id); nextKnown.add(id);
} }
prevVisibleCountRef.current = visibleCount; setKnownCommentIds(nextKnown);
}, [visibleCommentIds, visibleCount]); setPrevVisibleCount(visibleCount);
if (!isInitialized) {
setIsInitialized(true);
}
}
const mentionSuggestions = useMemo<MentionSuggestion[]>( const mentionSuggestions = useMemo<MentionSuggestion[]>(
() => () =>

View file

@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; 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 { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { MemberCard } from './MemberCard'; import { MemberCard } from './MemberCard';

View file

@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ChevronDown, ChevronUp } from 'lucide-react';
export type SubagentPreviewMessageKind = export type SubagentPreviewMessageKind =
| 'output' | 'output'

View file

@ -12,9 +12,8 @@ import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; 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 { 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 { MentionSuggestion } from '@renderer/types/mention';
import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types'; 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 lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead');
const next = lead?.name ?? members[0]?.name ?? ''; const next = lead?.name ?? members[0]?.name ?? '';
if (next && next !== recipient) { if (next && next !== recipient) {
setRecipient(next); queueMicrotask(() => setRecipient(next));
} }
}, [members, recipient]); }, [members, recipient]);
@ -189,15 +188,16 @@ export const MessageComposer = ({
[handleSend] [handleSend]
); );
const { addFiles: draftAddFiles } = draft;
const handleFileInputChange = useCallback( const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target; const input = e.target;
if (input.files?.length) { if (input.files?.length) {
void draft.addFiles(input.files); void draftAddFiles(input.files);
} }
input.value = ''; input.value = '';
}, },
[draft.addFiles] [draftAddFiles]
); );
const handleDragEnter = useCallback((e: React.DragEvent) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
@ -219,20 +219,22 @@ export const MessageComposer = ({
e.preventDefault(); e.preventDefault();
}, []); }, []);
const { handleDrop: draftHandleDrop } = draft;
const handleDropWrapper = useCallback( const handleDropWrapper = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
dragCounterRef.current = 0; dragCounterRef.current = 0;
setIsDragOver(false); setIsDragOver(false);
if (canAttach) draft.handleDrop(e); if (canAttach) draftHandleDrop(e);
}, },
[canAttach, draft.handleDrop] [canAttach, draftHandleDrop]
); );
const { handlePaste: draftHandlePaste } = draft;
const handlePasteWrapper = useCallback( const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => { (e: React.ClipboardEvent) => {
if (canAttach) draft.handlePaste(e); if (canAttach) draftHandlePaste(e);
}, },
[canAttach, draft.handlePaste] [canAttach, draftHandlePaste]
); );
const remaining = MAX_TEXT_LENGTH - trimmed.length; const remaining = MAX_TEXT_LENGTH - trimmed.length;

View file

@ -14,8 +14,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
composerDraftStorage,
type ComposerDraftSnapshot, type ComposerDraftSnapshot,
composerDraftStorage,
} from '@renderer/services/composerDraftStorage'; } from '@renderer/services/composerDraftStorage';
import { import {
fileToAttachmentPayload, fileToAttachmentPayload,
@ -190,12 +190,16 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
flushPending(); flushPending();
userTouchedRef.current = false; 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); const empty = composerDraftStorage.emptySnapshot(teamName);
applySnapshot(empty); queueMicrotask(() => {
setIsSaved(false); if (cancelled) return;
setIsLoaded(false); applySnapshot(empty);
setAttachmentError(null); setIsSaved(false);
setIsLoaded(false);
setAttachmentError(null);
});
void (async () => { void (async () => {
// Try loading unified snapshot first // Try loading unified snapshot first

View file

@ -32,7 +32,6 @@ import type {
CliInstallerProgress, CliInstallerProgress,
LeadContextUsage, LeadContextUsage,
TeamChangeEvent, TeamChangeEvent,
ToolApprovalDismiss,
ToolApprovalEvent, ToolApprovalEvent,
ToolApprovalRequest, ToolApprovalRequest,
UpdaterStatus, UpdaterStatus,
@ -452,7 +451,7 @@ export function initializeNotificationListeners(): () => void {
const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => { const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => {
const event = data as ToolApprovalEvent; const event = data as ToolApprovalEvent;
if ('dismissed' in event && event.dismissed) { if ('dismissed' in event && event.dismissed) {
const dismiss = event as ToolApprovalDismiss; const dismiss = event;
useStore.setState((s) => ({ useStore.setState((s) => ({
pendingApprovals: s.pendingApprovals.filter( pendingApprovals: s.pendingApprovals.filter(
(a) => !(a.teamName === dismiss.teamName && a.runId === dismiss.runId) (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'); 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) { if (currentRun && currentRun.agentId === group.agentId) {
currentRun.groups.push(group); currentRun.groups.push(group);
} else { } else {