Merge branch 'fix/lint-errors' into dev — resolve all eslint errors

Resolve conflicts in ActivityTimeline and TaskCommentsSection by keeping
the newer useNewItemKeys hook approach from dev over the reducer/useState
approach from the lint-fix branch.
This commit is contained in:
iliya 2026-03-07 14:50:50 +02:00
commit 00ca6698fa
24 changed files with 134 additions and 107 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

@ -3,14 +3,14 @@ import React, { useCallback, useEffect, useMemo, 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 { useNewItemKeys } from './useNewItemKeys'; import { useNewItemKeys } from './useNewItemKeys';
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 {
@ -253,15 +253,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
@ -296,6 +287,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, 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 { useNewItemKeys } from '@renderer/components/team/activity/useNewItemKeys'; 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 { 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';
@ -92,13 +92,17 @@ export const TaskCommentsSection = ({
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);
// 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);
}, [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]);

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 {