diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index 34cd0761..849250d5 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -178,7 +178,9 @@ export class ScheduledTaskExecutor { cwd: request.config.cwd, // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values // take precedence over the cached snapshot inside buildEnrichedEnv. - env, + // CLAUDECODE stripped last to prevent nested-session detection regardless + // of what buildProviderAwareCliEnv merges in. + env: { ...env, CLAUDECODE: undefined }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 21ec5c63..32c19341 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -154,6 +154,10 @@ async function hasValidServerCopy(dir: string): Promise { let _resolvedNodePath: string | undefined; +export function clearResolvedNodePathForTests(): void { + _resolvedNodePath = undefined; +} + /** * Find the real `node` binary path. In Electron, process.execPath is the * Electron binary — NOT node — so we must resolve node separately. diff --git a/src/renderer/components/chat/CompactBoundary.tsx b/src/renderer/components/chat/CompactBoundary.tsx index d23dc752..88f44216 100644 --- a/src/renderer/components/chat/CompactBoundary.tsx +++ b/src/renderer/components/chat/CompactBoundary.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { memo, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import { @@ -28,9 +28,9 @@ interface CompactBoundaryProps { * CompactBoundary displays a horizontal divider indicating where * the conversation was compacted. Click to expand the compacted summary. */ -export const CompactBoundary = ({ +export const CompactBoundary = memo(function CompactBoundary({ compactGroup, -}: Readonly): React.JSX.Element => { +}: Readonly): React.JSX.Element { const { timestamp, message } = compactGroup; const [isExpanded, setIsExpanded] = useState(false); @@ -166,4 +166,4 @@ export const CompactBoundary = ({ )} ); -}; +}); diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index ec0412d5..8ae4e8c9 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { CODE_BG, @@ -65,9 +65,6 @@ function buildItemMetaTooltip( return parts.length > 0 ? parts.join(' • ') : undefined; } -/** - * Truncates text to a maximum length and adds ellipsis if needed. - */ function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; @@ -75,6 +72,345 @@ function truncateText(text: string, maxLength: number): string { return text.substring(0, maxLength) + '...'; } +function getItemKey(item: AIGroupDisplayItem, index: number): string { + switch (item.type) { + case 'thinking': + return `thinking-${index}`; + case 'output': + return `output-${index}`; + case 'tool': + return `tool-${item.tool.id}-${index}`; + case 'subagent': + return `subagent-${item.subagent.id}-${index}`; + case 'slash': + return `slash-${item.slash.name}-${index}`; + case 'teammate_message': + return `teammate-${item.teammateMessage.id}-${index}`; + case 'subagent_input': + return `input-${index}`; + case 'compact_boundary': + return `compact-${index}`; + default: + return `unknown-${index}`; + } +} + +// ============================================================================= +// Per-item row — memoized to prevent re-renders from parent state changes +// ============================================================================= + +interface DisplayItemRowProps { + item: AIGroupDisplayItem; + index: number; + itemKey: string; + isExpanded: boolean; + isDimmed: boolean; + hasReplyLink: boolean; + onItemClick: (key: string) => void; + onReplyHover: (toolId: string | null) => void; + aiGroupId: string; + searchQueryOverride?: string; + highlightToolUseId?: string; + highlightColor?: TriggerColor; + notificationColorMap?: Map; + registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void; + previewMaxLength?: number; + timestampFormat?: string; + showItemMetaTooltip?: boolean; +} + +const DisplayItemRow = memo(function DisplayItemRow({ + item, + index: _index, + itemKey, + isExpanded, + isDimmed, + hasReplyLink, + onItemClick, + onReplyHover, + aiGroupId, + searchQueryOverride, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, +}: DisplayItemRowProps): React.JSX.Element | null { + const handleClick = useCallback(() => onItemClick(itemKey), [onItemClick, itemKey]); + + let element: React.ReactNode = null; + + switch (item.type) { + case 'thinking': { + const thinkingStep = { + id: itemKey, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + + ); + break; + } + + case 'output': { + const textStep = { + id: itemKey, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + + ); + break; + } + + case 'tool': { + element = ( + registerToolRef(item.tool.id, el) : undefined} + /> + ); + break; + } + + case 'subagent': { + const subagentStep = { + id: itemKey, + type: 'subagent' as const, + startTime: item.subagent.startTime, + endTime: item.subagent.endTime, + durationMs: item.subagent.durationMs, + content: { + subagentId: item.subagent.id, + subagentDescription: item.subagent.description, + }, + isParallel: item.subagent.isParallel, + context: 'main' as const, + }; + element = ( + + ); + break; + } + + case 'slash': { + element = ( + + ); + break; + } + + case 'teammate_message': { + element = ( + + ); + break; + } + + case 'subagent_input': { + const inputContent = item.content; + const inputTokenCount = item.tokenCount; + element = ( + } + label="Input" + summary={truncateText(inputContent, previewMaxLength ?? 80)} + tokenCount={inputTokenCount} + timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') + : undefined + } + onClick={handleClick} + isExpanded={isExpanded} + > + + + ); + break; + } + + case 'compact_boundary': { + const compactContent = item.content; + element = ( +
+ + {isExpanded && compactContent && ( +
+
+ +
+
+ )} +
+ ); + break; + } + + default: + return null; + } + + return ( +
+ {element} +
+ ); +}); + +// ============================================================================= +// Main component +// ============================================================================= + /** * Renders a flat list of AIGroupDisplayItem[] into the appropriate components. * @@ -87,7 +423,7 @@ function truncateText(text: string, maxLength: number): string { * * The list is completely flat with no nested toggles or hierarchies. */ -export const DisplayItemList = ({ +export const DisplayItemList = React.memo(function DisplayItemList({ items, onItemClick, expandedItemIds, @@ -101,23 +437,13 @@ export const DisplayItemList = ({ previewMaxLength, timestampFormat, showItemMetaTooltip = false, -}: Readonly): React.JSX.Element => { - // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair +}: Readonly): React.JSX.Element { const [replyLinkToolId, setReplyLinkToolId] = useState(null); const handleReplyHover = useCallback((toolId: string | null) => { setReplyLinkToolId(toolId); }, []); - /** Check if an item is part of the currently highlighted reply link */ - const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { - if (!replyLinkToolId) return false; - if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; - if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId) - return true; - return false; - }; - if (!items || items.length === 0) { return (
@@ -133,299 +459,39 @@ export const DisplayItemList = ({ } > {items.map((item, index) => { - let itemKey = ''; - let element: React.ReactNode = null; + const itemKey = getItemKey(item, index); + const isExpanded = expandedItemIds.has(itemKey); - switch (item.type) { - case 'thinking': { - itemKey = `thinking-${index}`; - const thinkingStep = { - id: itemKey, - type: 'thinking' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { thinkingText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } + const isInReplyLink = + replyLinkToolId !== null && + ((item.type === 'tool' && item.tool.id === replyLinkToolId) || + (item.type === 'teammate_message' && + item.teammateMessage.replyToToolId === replyLinkToolId)); + const isDimmed = replyLinkToolId !== null && !isInReplyLink; - case 'output': { - itemKey = `output-${index}`; - const textStep = { - id: itemKey, - type: 'output' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { outputText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } - - case 'tool': { - itemKey = `tool-${item.tool.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.tool.startTime} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.tool.startTime, - getToolContextTokens(item.tool), - 'tokens' - ) - : undefined - } - searchQueryOverride={searchQueryOverride} - isHighlighted={highlightToolUseId === item.tool.id} - highlightColor={highlightColor} - notificationDotColor={notificationColorMap?.get(item.tool.id)} - registerRef={ - registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined - } - /> - ); - break; - } - - case 'subagent': { - itemKey = `subagent-${item.subagent.id}-${index}`; - const subagentStep = { - id: itemKey, - type: 'subagent' as const, - startTime: item.subagent.startTime, - endTime: item.subagent.endTime, - durationMs: item.subagent.durationMs, - content: { - subagentId: item.subagent.id, - subagentDescription: item.subagent.description, - }, - isParallel: item.subagent.isParallel, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - aiGroupId={aiGroupId} - highlightToolUseId={highlightToolUseId} - highlightColor={highlightColor} - notificationColorMap={notificationColorMap} - registerToolRef={registerToolRef} - /> - ); - break; - } - - case 'slash': { - itemKey = `slash-${item.slash.name}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.slash.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.slash.timestamp, - item.slash.instructionsTokenCount, - 'tokens' - ) - : undefined - } - /> - ); - break; - } - - case 'teammate_message': { - itemKey = `teammate-${item.teammateMessage.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - onReplyHover={handleReplyHover} - /> - ); - break; - } - - case 'subagent_input': { - itemKey = `input-${index}`; - const inputContent = item.content; - const inputTokenCount = item.tokenCount; - element = ( - } - label="Input" - summary={truncateText(inputContent, previewMaxLength ?? 80)} - tokenCount={inputTokenCount} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') - : undefined - } - onClick={() => onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - > - - - ); - break; - } - - case 'compact_boundary': { - itemKey = `compact-${index}`; - const compactContent = item.content; - const compactExpanded = expandedItemIds.has(itemKey); - element = ( -
- - {compactExpanded && compactContent && ( -
-
- -
-
- )} -
- ); - break; - } - - default: - return null; - } - - // Apply reply-link spotlight: dim items not in the highlighted pair - const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); return ( -
- {element} -
+ item={item} + index={index} + itemKey={itemKey} + isExpanded={isExpanded} + isDimmed={isDimmed} + hasReplyLink={replyLinkToolId !== null} + onItemClick={onItemClick} + onReplyHover={handleReplyHover} + aiGroupId={aiGroupId} + searchQueryOverride={searchQueryOverride} + highlightToolUseId={highlightToolUseId} + highlightColor={highlightColor} + notificationColorMap={notificationColorMap} + registerToolRef={registerToolRef} + previewMaxLength={previewMaxLength} + timestampFormat={timestampFormat} + showItemMetaTooltip={showItemMetaTooltip} + /> ); })}
); -}; +}); diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index 375a832f..eb4795e3 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables'; import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors'; @@ -57,14 +57,14 @@ interface BaseItemProps { /** * Small status dot indicator. */ -export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => { +export const StatusDot = memo(function StatusDot({ status }: { status: ItemStatus }) { return ( ); -}; +}); // ============================================================================= // Main Component @@ -79,135 +79,140 @@ export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => { * * Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem */ -export const BaseItem: React.FC = ({ - icon, - label, - summary, - tokenCount, - tokenLabel = 'tokens', - status, - durationMs, - timestamp, - timestampFormat = 'HH:mm:ss', - titleText, - onClick, - isExpanded, - hasExpandableContent = true, - highlightClasses = '', - highlightStyle, - notificationDotColor, - children, -}) => { - return ( -
- {/* Clickable Header */} +export const BaseItem = memo( + ({ + icon, + label, + summary, + tokenCount, + tokenLabel = 'tokens', + status, + durationMs, + timestamp, + timestampFormat = 'HH:mm:ss', + titleText, + onClick, + isExpanded, + hasExpandableContent = true, + highlightClasses = '', + highlightStyle, + notificationDotColor, + children, + }: BaseItemProps): React.JSX.Element => { + return (
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} - className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5" - style={{ backgroundColor: 'transparent' }} - onMouseEnter={(e) => - Object.assign(e.currentTarget.style, { backgroundColor: 'var(--tool-item-hover-bg)' }) - } - onMouseLeave={(e) => - Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' }) - } + className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`} + style={highlightStyle} > - {/* Icon */} - - {icon} - + {/* Clickable Header */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5" + style={{ backgroundColor: 'transparent' }} + onMouseEnter={(e) => + Object.assign(e.currentTarget.style, { backgroundColor: 'var(--tool-item-hover-bg)' }) + } + onMouseLeave={(e) => + Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' }) + } + > + {/* Icon */} + + {icon} + - {/* Label */} - - {label} - + {/* Label */} + + {label} + - {/* Separator and Summary */} - {summary && ( - <> - - - + {/* Separator and Summary */} + {summary && ( + <> + + - + + + {summary} + + + )} + + {/* Spacer if no summary */} + {!summary && } + + {/* Token count badge */} + {tokenCount != null && tokenCount > 0 && ( + + ~{formatTokens(tokenCount)} {tokenLabel} - - {summary} + )} + + {/* Status indicator - hidden when notification dot replaces it */} + {status && !notificationDotColor && } + + {/* Notification dot (replaces status dot when present) */} + {notificationDotColor && ( + + )} + + {/* Duration */} + {durationMs !== undefined && ( + + {formatDuration(durationMs)} - - )} + )} - {/* Spacer if no summary */} - {!summary && } + {/* Timestamp — rightmost info element */} + {timestamp && ( + + {format(timestamp, timestampFormat)} + + )} - {/* Token count badge */} - {tokenCount != null && tokenCount > 0 && ( - + )} +
+ + {/* Expanded Content */} + {isExpanded && children && ( +
- ~{formatTokens(tokenCount)} {tokenLabel} - - )} - - {/* Status indicator - hidden when notification dot replaces it */} - {status && !notificationDotColor && } - - {/* Notification dot (replaces status dot when present) */} - {notificationDotColor && ( - - )} - - {/* Duration */} - {durationMs !== undefined && ( - - {formatDuration(durationMs)} - - )} - - {/* Timestamp — rightmost info element */} - {timestamp && ( - - {format(timestamp, timestampFormat)} - - )} - - {/* Expand/collapse chevron */} - {hasExpandableContent && ( - + {children} +
)}
- - {/* Expanded Content */} - {isExpanded && children && ( -
- {children} -
- )} -
- ); -}; + ); + } +); diff --git a/src/renderer/components/chat/items/ExecutionTrace.tsx b/src/renderer/components/chat/items/ExecutionTrace.tsx index f13241fd..e81ebe20 100644 --- a/src/renderer/components/chat/items/ExecutionTrace.tsx +++ b/src/renderer/components/chat/items/ExecutionTrace.tsx @@ -46,234 +46,239 @@ interface ExecutionTraceProps { // Execution Trace Component // ============================================================================= -export const ExecutionTrace: React.FC = ({ - items, - aiGroupId: _aiGroupId, - highlightToolUseId, - highlightColor, - notificationColorMap, - searchExpandedItemId, - registerToolRef, -}): React.JSX.Element => { - const [manualExpandedItemId, setManualExpandedItemId] = useState(null); +export const ExecutionTrace: React.FC = React.memo( + ({ + items, + aiGroupId: _aiGroupId, + highlightToolUseId, + highlightColor, + notificationColorMap, + searchExpandedItemId, + registerToolRef, + }): React.JSX.Element => { + const [manualExpandedItemId, setManualExpandedItemId] = useState(null); - // Use searchExpandedItemId if set, otherwise use manually expanded item - const expandedItemId = searchExpandedItemId ?? manualExpandedItemId; + // Use searchExpandedItemId if set, otherwise use manually expanded item + const expandedItemId = searchExpandedItemId ?? manualExpandedItemId; - const handleItemClick = (itemId: string): void => { - setManualExpandedItemId((prev) => (prev === itemId ? null : itemId)); - }; + const handleItemClick = (itemId: string): void => { + setManualExpandedItemId((prev) => (prev === itemId ? null : itemId)); + }; + + if (!items || items.length === 0) { + return ( +
+ No execution items +
+ ); + } - if (!items || items.length === 0) { return ( -
- No execution items +
+ {items.map((item, index) => { + switch (item.type) { + case 'thinking': { + const itemId = `subagent-thinking-${index}`; + const thinkingStep = { + id: itemId, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'subagent' as const, + }; + const preview = truncateText(item.content, 150); + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + timestamp={item.timestamp} + /> + ); + } + + case 'output': { + const itemId = `subagent-output-${index}`; + const textStep = { + id: itemId, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'subagent' as const, + }; + const preview = truncateText(item.content, 150); + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + timestamp={item.timestamp} + /> + ); + } + + case 'tool': { + const itemId = `subagent-tool-${item.tool.id}`; + const isExpanded = expandedItemId === itemId; + const isHighlighted = highlightToolUseId === item.tool.id; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + timestamp={item.tool.startTime} + isHighlighted={isHighlighted} + highlightColor={highlightColor} + notificationDotColor={notificationColorMap?.get(item.tool.id)} + registerRef={ + registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined + } + /> + ); + } + + case 'subagent': + return ( +
+ Nested: {item.subagent.description ?? item.subagent.id} +
+ ); + + case 'subagent_input': { + const itemId = `subagent-input-${index}`; + const isExpanded = expandedItemId === itemId; + return ( + } + label="Input" + summary={truncateText(item.content, 80)} + tokenCount={item.tokenCount} + timestamp={item.timestamp} + onClick={() => handleItemClick(itemId)} + isExpanded={isExpanded} + > + + + ); + } + + case 'teammate_message': { + const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`; + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + /> + ); + } + + case 'compact_boundary': { + const itemId = `subagent-compact-${index}`; + const isExpanded = expandedItemId === itemId; + return ( +
+ {/* Header — matches CompactBoundary.tsx amber styling */} + + {/* Expanded content */} + {isExpanded && item.content && ( +
+
+ +
+
+ )} +
+ ); + } + + default: + return null; + } + })}
); } - - return ( -
- {items.map((item, index) => { - switch (item.type) { - case 'thinking': { - const itemId = `subagent-thinking-${index}`; - const thinkingStep = { - id: itemId, - type: 'thinking' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { thinkingText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'subagent' as const, - }; - const preview = truncateText(item.content, 150); - const isExpanded = expandedItemId === itemId; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - timestamp={item.timestamp} - /> - ); - } - - case 'output': { - const itemId = `subagent-output-${index}`; - const textStep = { - id: itemId, - type: 'output' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { outputText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'subagent' as const, - }; - const preview = truncateText(item.content, 150); - const isExpanded = expandedItemId === itemId; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - timestamp={item.timestamp} - /> - ); - } - - case 'tool': { - const itemId = `subagent-tool-${item.tool.id}`; - const isExpanded = expandedItemId === itemId; - const isHighlighted = highlightToolUseId === item.tool.id; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - timestamp={item.tool.startTime} - isHighlighted={isHighlighted} - highlightColor={highlightColor} - notificationDotColor={notificationColorMap?.get(item.tool.id)} - registerRef={ - registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined - } - /> - ); - } - - case 'subagent': - return ( -
- Nested: {item.subagent.description ?? item.subagent.id} -
- ); - - case 'subagent_input': { - const itemId = `subagent-input-${index}`; - const isExpanded = expandedItemId === itemId; - return ( - } - label="Input" - summary={truncateText(item.content, 80)} - tokenCount={item.tokenCount} - timestamp={item.timestamp} - onClick={() => handleItemClick(itemId)} - isExpanded={isExpanded} - > - - - ); - } - - case 'teammate_message': { - const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`; - const isExpanded = expandedItemId === itemId; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - /> - ); - } - - case 'compact_boundary': { - const itemId = `subagent-compact-${index}`; - const isExpanded = expandedItemId === itemId; - return ( -
- {/* Header — matches CompactBoundary.tsx amber styling */} - - {/* Expanded content */} - {isExpanded && item.content && ( -
-
- -
-
- )} -
- ); - } - - default: - return null; - } - })} -
- ); -}; +); diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 336ce076..1dac357c 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -6,7 +6,7 @@ * for summary generation and token calculation. */ -import React, { useRef } from 'react'; +import React, { memo, useRef } from 'react'; import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; @@ -64,173 +64,175 @@ interface LinkedToolItemProps { titleText?: string; } -export const LinkedToolItem: React.FC = ({ - linkedTool, - onClick, - isExpanded, - timestamp, - timestampFormat, - searchQueryOverride, - isHighlighted, - highlightColor, - notificationDotColor, - registerRef, - titleText, -}) => { - const status = getToolStatus(linkedTool); - const { isLight } = useTheme(); - const summary = getToolSummary(linkedTool.name, linkedTool.input); - const normalizedToolName = linkedTool.name.toLowerCase(); - const summaryNode = - searchQueryOverride && searchQueryOverride.trim().length > 0 - ? highlightQueryInText( - summary, - searchQueryOverride, - `${linkedTool.id ?? linkedTool.name}:summary`, - { - forceAllActive: true, - } - ) - : summary; - const elementRef = useRef(null); +export const LinkedToolItem = memo( + ({ + linkedTool, + onClick, + isExpanded, + timestamp, + timestampFormat, + searchQueryOverride, + isHighlighted, + highlightColor, + notificationDotColor, + registerRef, + titleText, + }: LinkedToolItemProps): React.JSX.Element => { + const status = getToolStatus(linkedTool); + const { isLight } = useTheme(); + const summary = getToolSummary(linkedTool.name, linkedTool.input); + const normalizedToolName = linkedTool.name.toLowerCase(); + const summaryNode = + searchQueryOverride && searchQueryOverride.trim().length > 0 + ? highlightQueryInText( + summary, + searchQueryOverride, + `${linkedTool.id ?? linkedTool.name}:summary`, + { + forceAllActive: true, + } + ) + : summary; + const elementRef = useRef(null); - // Combined ref callback - handles both internal ref and external registration - const handleRef = (el: HTMLDivElement | null): void => { - // Update internal ref - (elementRef as React.MutableRefObject).current = el; - // Call external registration if provided - registerRef?.(el); - }; + // Combined ref callback - handles both internal ref and external registration + const handleRef = (el: HTMLDivElement | null): void => { + // Update internal ref + (elementRef as React.MutableRefObject).current = el; + // Call external registration if provided + registerRef?.(el); + }; - // Render teammate_spawned results as a minimal inline row - const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned'; - if (isTeammateSpawned) { - const teamResult = linkedTool.result!.toolUseResult!; - const name = (teamResult.name as string) || 'teammate'; - const color = (teamResult.color as string) || ''; - const colors = getTeamColorSet(color); - return ( -
- - - {name} - - - Teammate spawned - -
- ); - } - - // Render SendMessage shutdown_request as a minimal inline row - const isShutdownRequest = - linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request'; - if (isShutdownRequest) { - const target = (linkedTool.input?.recipient as string) || 'teammate'; - return ( -
- - - Shutdown requested →{' '} - {target} - -
- ); - } - - // Note: We no longer scroll locally - the navigation coordinator handles this - // via the registered ref. This prevents double-scroll issues. - - // Highlight animation for error deep linking (supports custom hex) - const effectiveColor = highlightColor ?? 'red'; - let highlightClasses = ''; - let highlightStyle: React.CSSProperties | undefined; - if (isHighlighted) { - if (isPresetColorKey(effectiveColor)) { - highlightClasses = TOOL_HIGHLIGHT_CLASSES[effectiveColor]; - } else { - const hp = getToolHighlightProps(effectiveColor); - highlightClasses = hp.className; - highlightStyle = hp.style; - } - } - - // Determine which specialized viewer to use - const useReadViewer = - normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError; - const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool); - const useWriteViewer = - normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; - const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool); - const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer; - - // Check if we should show error display for Read/Write tools - const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError; - const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError; - - return ( -
- - } - label={linkedTool.name} - summary={summaryNode} - tokenCount={getToolContextTokens(linkedTool)} - status={status} - durationMs={linkedTool.durationMs} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - {/* Read tool with CodeBlockViewer */} - {useReadViewer && } - - {/* Edit tool with DiffViewer */} - {useEditViewer && } - - {/* Write tool */} - {useWriteViewer && } - - {/* Skill tool with instructions */} - {useSkillViewer && } - - {/* Default rendering for other tools */} - {useDefaultViewer && } - - {/* Error output for Read tool */} - {showReadError && } - - {/* Error output for Write tool */} - {showWriteError && } - - {/* Orphaned indicator */} - {linkedTool.isOrphaned && ( -
+ + - - No result received -
- )} - - {/* Timing */} -
- Duration: {formatDuration(linkedTool.durationMs)} + {name} + + + Teammate spawned +
-
-
- ); -}; + ); + } + + // Render SendMessage shutdown_request as a minimal inline row + const isShutdownRequest = + linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request'; + if (isShutdownRequest) { + const target = (linkedTool.input?.recipient as string) || 'teammate'; + return ( +
+ + + Shutdown requested →{' '} + {target} + +
+ ); + } + + // Note: We no longer scroll locally - the navigation coordinator handles this + // via the registered ref. This prevents double-scroll issues. + + // Highlight animation for error deep linking (supports custom hex) + const effectiveColor = highlightColor ?? 'red'; + let highlightClasses = ''; + let highlightStyle: React.CSSProperties | undefined; + if (isHighlighted) { + if (isPresetColorKey(effectiveColor)) { + highlightClasses = TOOL_HIGHLIGHT_CLASSES[effectiveColor]; + } else { + const hp = getToolHighlightProps(effectiveColor); + highlightClasses = hp.className; + highlightStyle = hp.style; + } + } + + // Determine which specialized viewer to use + const useReadViewer = + normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError; + const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool); + const useWriteViewer = + normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; + const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool); + const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer; + + // Check if we should show error display for Read/Write tools + const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError; + const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError; + + return ( +
+ + } + label={linkedTool.name} + summary={summaryNode} + tokenCount={getToolContextTokens(linkedTool)} + status={status} + durationMs={linkedTool.durationMs} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + {/* Read tool with CodeBlockViewer */} + {useReadViewer && } + + {/* Edit tool with DiffViewer */} + {useEditViewer && } + + {/* Write tool */} + {useWriteViewer && } + + {/* Skill tool with instructions */} + {useSkillViewer && } + + {/* Default rendering for other tools */} + {useDefaultViewer && } + + {/* Error output for Read tool */} + {showReadError && } + + {/* Error output for Write tool */} + {showWriteError && } + + {/* Orphaned indicator */} + {linkedTool.isOrphaned && ( +
+ + No result received +
+ )} + + {/* Timing */} +
+ Duration: {formatDuration(linkedTool.durationMs)} +
+
+
+ ); + } +); diff --git a/src/renderer/components/chat/items/MetricsPill.tsx b/src/renderer/components/chat/items/MetricsPill.tsx index 22c083aa..9f276c45 100644 --- a/src/renderer/components/chat/items/MetricsPill.tsx +++ b/src/renderer/components/chat/items/MetricsPill.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { memo, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { @@ -41,175 +41,177 @@ interface MetricsPillProps { // Unified Metrics Pill - Compact monospace pill with tooltip // ============================================================================= -export const MetricsPill = ({ - mainSessionImpact, - lastUsage, - isolatedLabel, - isolatedOverride, - phaseBreakdown, -}: Readonly): React.ReactElement | null => { - const [showTooltip, setShowTooltip] = useState(false); - const [tooltipStyle, setTooltipStyle] = useState({}); - const containerRef = useRef(null); - const hideTimeoutRef = useRef | null>(null); +export const MetricsPill = memo( + ({ + mainSessionImpact, + lastUsage, + isolatedLabel, + isolatedOverride, + phaseBreakdown, + }: Readonly): React.ReactElement | null => { + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipStyle, setTooltipStyle] = useState({}); + const containerRef = useRef(null); + const hideTimeoutRef = useRef | null>(null); - const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0; - const hasIsolated = - isolatedOverride != null - ? isolatedOverride > 0 - : lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; + const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0; + const hasIsolated = + isolatedOverride != null + ? isolatedOverride > 0 + : lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; - const isolatedTotal = - isolatedOverride ?? - (lastUsage - ? lastUsage.input_tokens + - lastUsage.output_tokens + - (lastUsage.cache_read_input_tokens ?? 0) + - (lastUsage.cache_creation_input_tokens ?? 0) - : 0); + const isolatedTotal = + isolatedOverride ?? + (lastUsage + ? lastUsage.input_tokens + + lastUsage.output_tokens + + (lastUsage.cache_read_input_tokens ?? 0) + + (lastUsage.cache_creation_input_tokens ?? 0) + : 0); - const hasPhases = phaseBreakdown && phaseBreakdown.length > 1; + const hasPhases = phaseBreakdown && phaseBreakdown.length > 1; - const clearHideTimeout = (): void => { - if (hideTimeoutRef.current) { - clearTimeout(hideTimeoutRef.current); - hideTimeoutRef.current = null; - } - }; - - const handleMouseEnter = (): void => { - clearHideTimeout(); - setShowTooltip(true); - }; - - const handleMouseLeave = (): void => { - clearHideTimeout(); - hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 100); - }; - - useEffect(() => { - if (showTooltip && containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - const tooltipWidth = 220; - let left = rect.left + rect.width / 2 - tooltipWidth / 2; - if (left < 8) left = 8; - if (left + tooltipWidth > window.innerWidth - 8) { - left = window.innerWidth - tooltipWidth - 8; + const clearHideTimeout = (): void => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; } - setTooltipStyle({ - position: 'fixed', - bottom: window.innerHeight - rect.top + 6, - left, - width: tooltipWidth, - zIndex: 99999, - }); + }; + + const handleMouseEnter = (): void => { + clearHideTimeout(); + setShowTooltip(true); + }; + + const handleMouseLeave = (): void => { + clearHideTimeout(); + hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 100); + }; + + useEffect(() => { + if (showTooltip && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const tooltipWidth = 220; + let left = rect.left + rect.width / 2 - tooltipWidth / 2; + if (left < 8) left = 8; + if (left + tooltipWidth > window.innerWidth - 8) { + left = window.innerWidth - tooltipWidth - 8; + } + setTooltipStyle({ + position: 'fixed', + bottom: window.innerHeight - rect.top + 6, + left, + width: tooltipWidth, + zIndex: 99999, + }); + } + }, [showTooltip]); + + useEffect(() => { + if (!showTooltip) return; + const handleScroll = (): void => setShowTooltip(false); + window.addEventListener('scroll', handleScroll, true); + return () => window.removeEventListener('scroll', handleScroll, true); + }, [showTooltip]); + + useEffect(() => { + return () => clearHideTimeout(); + }, []); + + if (!hasMainImpact && !hasIsolated) { + return null; } - }, [showTooltip]); - useEffect(() => { - if (!showTooltip) return; - const handleScroll = (): void => setShowTooltip(false); - window.addEventListener('scroll', handleScroll, true); - return () => window.removeEventListener('scroll', handleScroll, true); - }, [showTooltip]); + const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null; + const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null; + const rightLabel = isolatedLabel ?? 'Subagent Context'; - useEffect(() => { - return () => clearHideTimeout(); - }, []); + return ( + <> +
+ {mainValue && {mainValue}} + {mainValue && isolatedValue && |} + {isolatedValue && {isolatedValue}} +
- if (!hasMainImpact && !hasIsolated) { - return null; - } - - const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null; - const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null; - const rightLabel = isolatedLabel ?? 'Subagent Context'; - - return ( - <> -
- {mainValue && {mainValue}} - {mainValue && isolatedValue && |} - {isolatedValue && {isolatedValue}} -
- - {showTooltip && - createPortal( -
-
- {hasMainImpact && ( -
- Main Context - - {mainSessionImpact.totalTokens.toLocaleString()} - -
- )} - {hasIsolated && ( -
- {rightLabel} - - {isolatedTotal.toLocaleString()} - -
- )} - {hasPhases && - phaseBreakdown.map((phase) => ( -
- - Phase {phase.phaseNumber} - - - {formatTokensCompact(phase.peakTokens)} - {phase.postCompaction != null && ( - - {' '} - → {formatTokensCompact(phase.postCompaction)} - - )} + {showTooltip && + createPortal( +
+
+ {hasMainImpact && ( +
+ Main Context + + {mainSessionImpact.totalTokens.toLocaleString()}
- ))} -
- {hasMainImpact && hasIsolated - ? 'Left: parent injection · Right: internal' - : hasMainImpact - ? 'Tokens injected to parent' - : 'Internal token usage'} + )} + {hasIsolated && ( +
+ {rightLabel} + + {isolatedTotal.toLocaleString()} + +
+ )} + {hasPhases && + phaseBreakdown.map((phase) => ( +
+ + Phase {phase.phaseNumber} + + + {formatTokensCompact(phase.peakTokens)} + {phase.postCompaction != null && ( + + {' '} + → {formatTokensCompact(phase.postCompaction)} + + )} + +
+ ))} +
+ {hasMainImpact && hasIsolated + ? 'Left: parent injection · Right: internal' + : hasMainImpact + ? 'Tokens injected to parent' + : 'Internal token usage'} +
-
-
, - document.body - )} - - ); -}; +
, + document.body + )} + + ); + } +); diff --git a/src/renderer/components/chat/items/SlashItem.tsx b/src/renderer/components/chat/items/SlashItem.tsx index c48ece0b..df24caf6 100644 --- a/src/renderer/components/chat/items/SlashItem.tsx +++ b/src/renderer/components/chat/items/SlashItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import { Slash } from 'lucide-react'; @@ -34,48 +34,50 @@ interface SlashItemProps { * - MCP commands * - User-defined commands */ -export const SlashItem: React.FC = ({ - slash, - onClick, - isExpanded, - timestamp, - timestampFormat, - highlightClasses, - highlightStyle, - notificationDotColor, - titleText, -}) => { - const hasInstructions = !!slash.instructions; +export const SlashItem = memo( + ({ + slash, + onClick, + isExpanded, + timestamp, + timestampFormat, + highlightClasses, + highlightStyle, + notificationDotColor, + titleText, + }: SlashItemProps): React.JSX.Element => { + const hasInstructions = !!slash.instructions; - // Display args or message as the description - const description = slash.args ?? slash.message; + // Display args or message as the description + const description = slash.args ?? slash.message; - return ( - } - label={`/${slash.name}`} - summary={description} - tokenCount={slash.instructionsTokenCount} - tokenLabel="tokens" - status={hasInstructions ? 'ok' : undefined} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - hasExpandableContent={hasInstructions} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - {hasInstructions && ( - - )} - - ); -}; + return ( + } + label={`/${slash.name}`} + summary={description} + tokenCount={slash.instructionsTokenCount} + tokenLabel="tokens" + status={hasInstructions ? 'ok' : undefined} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + hasExpandableContent={hasInstructions} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + {hasInstructions && ( + + )} + + ); + } +); diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index c2aeeca4..0c79394e 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -67,249 +67,178 @@ interface SubagentItemProps { // Main Component - Linear-style DevTools Card // ============================================================================= -export const SubagentItem: React.FC = ({ - step, - subagent, - onClick, - isExpanded, - aiGroupId, - highlightToolUseId, - highlightColor, - notificationColorMap, - registerToolRef, -}) => { - const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent'; - const subagentType = subagent.subagentType ?? 'Task'; - const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; +export const SubagentItem: React.FC = React.memo( + ({ + step, + subagent, + onClick, + isExpanded, + aiGroupId, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + }) => { + const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent'; + const subagentType = subagent.subagentType ?? 'Task'; + const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; - // Agent configs from .claude/agents/ for color lookup - const agentConfigs = useStore(useShallow((s) => s.agentConfigs)); + // Agent configs from .claude/agents/ for color lookup + const agentConfigs = useStore(useShallow((s) => s.agentConfigs)); - // Team member colors (when this subagent is a team member) - const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; - const { isLight } = useTheme(); - // Type-based colors for non-team subagents (from agent config or deterministic hash) - const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; + // Team member colors (when this subagent is a team member) + const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; + const { isLight } = useTheme(); + // Type-based colors for non-team subagents (from agent config or deterministic hash) + const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; - // Detect shutdown-only team activations (trivial: just a shutdown_response) - const isShutdownOnly = useMemo(() => { - if (!subagent.team || !subagent.messages?.length) return false; - const assistantMsgs = subagent.messages.filter((m) => m.type === 'assistant'); - if (assistantMsgs.length !== 1) return false; - const calls = assistantMsgs[0].toolCalls ?? []; - return ( - calls.length === 1 && - calls[0].name === 'SendMessage' && - calls[0].input?.type === 'shutdown_response' - ); - }, [subagent.team, subagent.messages]); + // Detect shutdown-only team activations (trivial: just a shutdown_response) + const isShutdownOnly = useMemo(() => { + if (!subagent.team || !subagent.messages?.length) return false; + const assistantMsgs = subagent.messages.filter((m) => m.type === 'assistant'); + if (assistantMsgs.length !== 1) return false; + const calls = assistantMsgs[0].toolCalls ?? []; + return ( + calls.length === 1 && + calls[0].name === 'SendMessage' && + calls[0].input?.type === 'shutdown_response' + ); + }, [subagent.team, subagent.messages]); - // Per-tab trace expansion state (replaces local useState for true per-tab isolation) - const { isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); - const isTraceManuallyExpanded = isSubagentTraceExpanded(subagent.id); + // Per-tab trace expansion state (replaces local useState for true per-tab isolation) + const { isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); + const isTraceManuallyExpanded = isSubagentTraceExpanded(subagent.id); - // Check if contains highlighted error - // Also matches when the highlight targets the parent Task tool_use that spawned this subagent - const containsHighlightedError = useMemo(() => { - if (!highlightToolUseId) return false; - // Match parent Task tool_use ID (trigger matched the Task call itself) - if (subagent.parentTaskId === highlightToolUseId) return true; - // Match inner tool calls/results within the subagent - if (!subagent.messages) return false; - for (const msg of subagent.messages) { - if (msg.toolCalls?.some((tc) => tc.id === highlightToolUseId)) return true; - if (msg.toolResults?.some((tr) => tr.toolUseId === highlightToolUseId)) return true; - } - return false; - }, [highlightToolUseId, subagent.parentTaskId, subagent.messages]); - - // Build display items - const displayItems = useMemo(() => { - if ((!isExpanded && !containsHighlightedError) || !subagent.messages?.length) { - return []; - } - return buildDisplayItemsFromMessages(subagent.messages, []); - }, [isExpanded, containsHighlightedError, subagent.messages]); - - // Build summary - const itemsSummary = useMemo(() => { - if (!isExpanded && !containsHighlightedError) { - const toolCount = - subagent.messages?.filter( - (m) => - m.type === 'assistant' && - Array.isArray(m.content) && - m.content.some((b) => b.type === 'tool_use') - ).length ?? 0; - return toolCount > 0 ? `${toolCount} tools` : ''; - } - return buildSummary(displayItems); - }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]); - - // Model info - const modelInfo = useMemo(() => { - const msg = subagent.messages?.find( - (m) => m.type === 'assistant' && m.model && m.model !== '' - ); - return msg?.model ? parseModelString(msg.model) : null; - }, [subagent.messages]); - - // Last usage - const lastUsage = useMemo(() => { - const messages = subagent.messages ?? []; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].type === 'assistant' && messages[i].usage) { - return messages[i].usage; + // Check if contains highlighted error + // Also matches when the highlight targets the parent Task tool_use that spawned this subagent + const containsHighlightedError = useMemo(() => { + if (!highlightToolUseId) return false; + // Match parent Task tool_use ID (trigger matched the Task call itself) + if (subagent.parentTaskId === highlightToolUseId) return true; + // Match inner tool calls/results within the subagent + if (!subagent.messages) return false; + for (const msg of subagent.messages) { + if (msg.toolCalls?.some((tc) => tc.id === highlightToolUseId)) return true; + if (msg.toolResults?.some((tr) => tr.toolUseId === highlightToolUseId)) return true; } - } - return null; - }, [subagent.messages]); + return false; + }, [highlightToolUseId, subagent.parentTaskId, subagent.messages]); - // Multi-phase context breakdown (for subagents with compaction) - const phaseData = useMemo(() => { - if (!subagent.messages?.length) return null; - return computeSubagentPhaseBreakdown(subagent.messages); - }, [subagent.messages]); - - // Search expansion - const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds)); - const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); - const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); - - // Combine manual expansion with auto-expansion for errors/search - const isTraceExpanded = - isTraceManuallyExpanded || containsHighlightedError || shouldExpandForSearch; - const [isTraceHeaderHovered, setIsTraceHeaderHovered] = useState(false); - - // Outer card highlight when this subagent contains the highlighted tool - const outerHighlight = useMemo(() => { - if (!containsHighlightedError) - return { className: '', style: undefined as React.CSSProperties | undefined }; - return getHighlightProps(highlightColor); - }, [containsHighlightedError, highlightColor]); - - // Register outer card as a tool ref target for the parent Task tool_use ID - // so the navigation controller can scroll directly to this SubagentItem - const outerCardRef = useCallback( - (el: HTMLDivElement | null) => { - if (subagent.parentTaskId && registerToolRef) { - registerToolRef(subagent.parentTaskId, el); + // Build display items + const displayItems = useMemo(() => { + if ((!isExpanded && !containsHighlightedError) || !subagent.messages?.length) { + return []; } - }, - [subagent.parentTaskId, registerToolRef] - ); + return buildDisplayItemsFromMessages(subagent.messages, []); + }, [isExpanded, containsHighlightedError, subagent.messages]); - // Cumulative metrics for team members — show total output generated - const cumulativeMetrics = useMemo(() => { - if (!subagent.team || !subagent.metrics) return undefined; - const turnCount = - subagent.messages?.filter((m) => m.type === 'assistant' && m.usage).length ?? 0; - return { - outputTokens: subagent.metrics.outputTokens, - turnCount, - }; - }, [subagent.team, subagent.metrics, subagent.messages]); + // Build summary + const itemsSummary = useMemo(() => { + if (!isExpanded && !containsHighlightedError) { + const toolCount = + subagent.messages?.filter( + (m) => + m.type === 'assistant' && + Array.isArray(m.content) && + m.content.some((b) => b.type === 'tool_use') + ).length ?? 0; + return toolCount > 0 ? `${toolCount} tools` : ''; + } + return buildSummary(displayItems); + }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]); - // Computed values for metrics - const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0; - const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; - const isMultiPhase = phaseData != null && phaseData.compactionCount > 0; - const isolatedTotal = isMultiPhase - ? phaseData.totalConsumption - : lastUsage - ? lastUsage.input_tokens + - lastUsage.output_tokens + - (lastUsage.cache_read_input_tokens ?? 0) + - (lastUsage.cache_creation_input_tokens ?? 0) - : 0; + // Model info + const modelInfo = useMemo(() => { + const msg = subagent.messages?.find( + (m) => m.type === 'assistant' && m.model && m.model !== '' + ); + return msg?.model ? parseModelString(msg.model) : null; + }, [subagent.messages]); - // Shutdown-only team activations: minimal inline row (no metrics, no expand) - if (isShutdownOnly && teamColors && subagent.team) { - return ( -
- - { + const messages = subagent.messages ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'assistant' && messages[i].usage) { + return messages[i].usage; + } + } + return null; + }, [subagent.messages]); + + // Multi-phase context breakdown (for subagents with compaction) + const phaseData = useMemo(() => { + if (!subagent.messages?.length) return null; + return computeSubagentPhaseBreakdown(subagent.messages); + }, [subagent.messages]); + + // Search expansion + const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds)); + const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); + const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); + + // Combine manual expansion with auto-expansion for errors/search + const isTraceExpanded = + isTraceManuallyExpanded || containsHighlightedError || shouldExpandForSearch; + const [isTraceHeaderHovered, setIsTraceHeaderHovered] = useState(false); + + // Outer card highlight when this subagent contains the highlighted tool + const outerHighlight = useMemo(() => { + if (!containsHighlightedError) + return { className: '', style: undefined as React.CSSProperties | undefined }; + return getHighlightProps(highlightColor); + }, [containsHighlightedError, highlightColor]); + + // Register outer card as a tool ref target for the parent Task tool_use ID + // so the navigation controller can scroll directly to this SubagentItem + const outerCardRef = useCallback( + (el: HTMLDivElement | null) => { + if (subagent.parentTaskId && registerToolRef) { + registerToolRef(subagent.parentTaskId, el); + } + }, + [subagent.parentTaskId, registerToolRef] + ); + + // Cumulative metrics for team members — show total output generated + const cumulativeMetrics = useMemo(() => { + if (!subagent.team || !subagent.metrics) return undefined; + const turnCount = + subagent.messages?.filter((m) => m.type === 'assistant' && m.usage).length ?? 0; + return { + outputTokens: subagent.metrics.outputTokens, + turnCount, + }; + }, [subagent.team, subagent.metrics, subagent.messages]); + + // Computed values for metrics + const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0; + const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; + const isMultiPhase = phaseData != null && phaseData.compactionCount > 0; + const isolatedTotal = isMultiPhase + ? phaseData.totalConsumption + : lastUsage + ? lastUsage.input_tokens + + lastUsage.output_tokens + + (lastUsage.cache_read_input_tokens ?? 0) + + (lastUsage.cache_creation_input_tokens ?? 0) + : 0; + + // Shutdown-only team activations: minimal inline row (no metrics, no expand) + if (isShutdownOnly && teamColors && subagent.team) { + return ( +
- {subagent.team.memberName} - - - Shutdown confirmed - - - - {formatDuration(subagent.durationMs)} - -
- ); - } - - return ( -
- {/* ========== Level 1: Clickable Header ========== */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} - className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" - style={{ - backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', - borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', - }} - > - {/* Expand chevron */} - - - {/* Icon - colored dot for team members/typed subagents, Bot icon for generic */} - {teamColors || typeColors ? ( - ) : ( - - )} - - {/* Type badge - team member name or typed subagent */} - {teamColors && subagent.team ? ( = ({ > {subagent.team.memberName} - ) : ( + + Shutdown confirmed + + - {subagentType} + {formatDuration(subagent.durationMs)} - )} +
+ ); + } - {/* Model */} - {modelInfo && ( - - {modelInfo.name} - - )} - - {/* Description */} - - {truncatedDesc} - - - {/* Status indicator */} - {subagent.isOngoing ? ( - - ) : ( - - )} - - {/* Unified Metrics Pill — team members don't show mainSessionImpact - (spawn cost only; real main impact comes from teammate messages) */} - 0 ? phaseData.totalConsumption : undefined - } - phaseBreakdown={phaseData?.phases} - /> - - {/* Duration */} - + {/* ========== Level 1: Clickable Header ========== */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + style={{ + backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', + borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', + }} > - {formatDuration(subagent.durationMs)} - + {/* Expand chevron */} + - {/* Timestamp — rightmost info element */} - - {format(subagent.startTime, 'HH:mm:ss')} - -
- - {/* ========== Level 1 Expanded: Dashboard Content ========== */} - {isExpanded && ( -
- {/* ========== Row 1: Meta Info (Horizontal Flow) ========== */} -
- - Type{' '} - - {subagentType} - - - - - Duration{' '} - - {formatDuration(subagent.durationMs)} - - - {modelInfo && ( - <> - - - Model{' '} - - {modelInfo.name} - - - - )} - - - ID{' '} - - {subagent.id.slice(0, 8)} - - -
- - {/* ========== Row 2: Context Usage (Clean List) ========== */} - {(hasMainImpact ?? hasIsolated) && ( -
- {/* Overline title */} -
- Context Usage -
- - {/* Token rows - floating alignment */} -
- {hasMainImpact && !subagent.team && ( -
-
- - - Main Context - -
- - {subagent.mainSessionImpact!.totalTokens.toLocaleString()} - -
- )} - - {cumulativeMetrics && ( -
-
- - - Total Output - -
- - {cumulativeMetrics.outputTokens.toLocaleString()} - - {' '} - ({cumulativeMetrics.turnCount} turns) - - -
- )} - - {hasIsolated && ( -
-
- - - {subagent.team ? 'Context Window' : 'Subagent Context'} - -
- - {isolatedTotal.toLocaleString()} - -
- )} - - {/* Per-phase breakdown when multi-phase */} - {isMultiPhase && - phaseData.phases.map((phase) => ( -
- - Phase {phase.phaseNumber} - - - {formatTokensCompact(phase.peakTokens)} - {phase.postCompaction != null && ( - - {' '} - → {formatTokensCompact(phase.postCompaction)} - - )} - -
- ))} -
-
+ {/* Icon - colored dot for team members/typed subagents, Bot icon for generic */} + {teamColors || typeColors ? ( + + ) : ( + )} - {/* ========== Level 2: Execution Trace Toggle ========== */} - {displayItems.length > 0 && ( -
- {/* Trace Header (clickable) */} + {subagent.team.memberName} + + ) : ( + + {subagentType} + + )} + + {/* Model */} + {modelInfo && ( + + {modelInfo.name} + + )} + + {/* Description */} + + {truncatedDesc} + + + {/* Status indicator */} + {subagent.isOngoing ? ( + + ) : ( + + )} + + {/* Unified Metrics Pill — team members don't show mainSessionImpact + (spawn cost only; real main impact comes from teammate messages) */} + 0 ? phaseData.totalConsumption : undefined + } + phaseBreakdown={phaseData?.phases} + /> + + {/* Duration */} + + {formatDuration(subagent.durationMs)} + + + {/* Timestamp — rightmost info element */} + + {format(subagent.startTime, 'HH:mm:ss')} + +
+ + {/* ========== Level 1 Expanded: Dashboard Content ========== */} + {isExpanded && ( +
+ {/* ========== Row 1: Meta Info (Horizontal Flow) ========== */} +
+ + Type{' '} + + {subagentType} + + + + + Duration{' '} + + {formatDuration(subagent.durationMs)} + + + {modelInfo && ( + <> + + + Model{' '} + + {modelInfo.name} + + + + )} + + + ID{' '} + + {subagent.id.slice(0, 8)} + + +
+ + {/* ========== Row 2: Context Usage (Clean List) ========== */} + {(hasMainImpact ?? hasIsolated) && ( +
+ {/* Overline title */} +
+ Context Usage +
+ + {/* Token rows - floating alignment */} +
+ {hasMainImpact && !subagent.team && ( +
+
+ + + Main Context + +
+ + {subagent.mainSessionImpact!.totalTokens.toLocaleString()} + +
+ )} + + {cumulativeMetrics && ( +
+
+ + + Total Output + +
+ + {cumulativeMetrics.outputTokens.toLocaleString()} + + {' '} + ({cumulativeMetrics.turnCount} turns) + + +
+ )} + + {hasIsolated && ( +
+
+ + + {subagent.team ? 'Context Window' : 'Subagent Context'} + +
+ + {isolatedTotal.toLocaleString()} + +
+ )} + + {/* Per-phase breakdown when multi-phase */} + {isMultiPhase && + phaseData.phases.map((phase) => ( +
+ + Phase {phase.phaseNumber} + + + {formatTokensCompact(phase.peakTokens)} + {phase.postCompaction != null && ( + + {' '} + → {formatTokensCompact(phase.postCompaction)} + + )} + +
+ ))} +
+
+ )} + + {/* ========== Level 2: Execution Trace Toggle ========== */} + {displayItems.length > 0 && (
{ - e.stopPropagation(); - toggleSubagentTraceExpansion(subagent.id); + className="overflow-hidden rounded-md" + style={{ + border: CARD_BORDER_STYLE, + backgroundColor: CARD_HEADER_BG, }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); + > + {/* Trace Header (clickable) */} +
{ e.stopPropagation(); toggleSubagentTraceExpansion(subagent.id); - } - }} - className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" - style={{ - borderBottom: isTraceExpanded ? CARD_BORDER_STYLE : 'none', - backgroundColor: isTraceHeaderHovered ? CARD_HEADER_HOVER : 'transparent', - }} - onMouseEnter={() => setIsTraceHeaderHovered(true)} - onMouseLeave={() => setIsTraceHeaderHovered(false)} - > - - - - Execution Trace - - - · {itemsSummary} - -
- - {/* Trace Content */} - {isTraceExpanded && ( -
- { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleSubagentTraceExpansion(subagent.id); } - registerToolRef={registerToolRef} + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + style={{ + borderBottom: isTraceExpanded ? CARD_BORDER_STYLE : 'none', + backgroundColor: isTraceHeaderHovered ? CARD_HEADER_HOVER : 'transparent', + }} + onMouseEnter={() => setIsTraceHeaderHovered(true)} + onMouseLeave={() => setIsTraceHeaderHovered(false)} + > + + + + Execution Trace + + + · {itemsSummary} +
- )} -
- )} -
- )} -
- ); -}; + + {/* Trace Content */} + {isTraceExpanded && ( +
+ +
+ )} +
+ )} +
+ )} +
+ ); + } +); diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index bd5bc904..6e2af8e4 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { CARD_BG, @@ -75,187 +75,192 @@ function isResendMessage(message: TeammateMessage): boolean { * * Operational noise (idle/shutdown/terminated) renders as minimal inline text. */ -export const TeammateMessageItem: React.FC = ({ - teammateMessage, - onClick, - isExpanded, - onReplyHover, - highlightClasses = '', - highlightStyle, -}) => { - const colors = getTeamColorSet(teammateMessage.color); - const { isLight } = useTheme(); +export const TeammateMessageItem = memo( + ({ + teammateMessage, + onClick, + isExpanded, + onReplyHover, + highlightClasses = '', + highlightStyle, + }: TeammateMessageItemProps): React.JSX.Element => { + const colors = getTeamColorSet(teammateMessage.color); + const { isLight } = useTheme(); - // Get team members for @mention highlighting - const members = useStore( - useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)) - ); - const memberColorMap = useMemo( - () => (members ? buildMemberColorMap(members) : new Map()), - [members] - ); - - // Get team names for @team linkification - const teams = useStore(useShallow((s) => s.teams)); - const teamNames = useMemo( - () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), - [teams] - ); - - // Detect operational noise - const noiseLabel = useMemo( - () => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId), - [teammateMessage.content, teammateMessage.teammateId] - ); - - // Detect resent/duplicate messages - const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]); - - const plainSummary = useMemo( - () => extractMarkdownPlainText(teammateMessage.summary), - [teammateMessage.summary] - ); - const plainReplyToSummary = useMemo( - () => - teammateMessage.replyToSummary - ? extractMarkdownPlainText(teammateMessage.replyToSummary) - : undefined, - [teammateMessage.replyToSummary] - ); - - const displayContent = useMemo(() => { - const stripped = stripAgentBlocks(teammateMessage.content); - return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames); - }, [teammateMessage.content, memberColorMap, teamNames]); - - // Noise: minimal inline row (no card, no expand) - if (noiseLabel) { - return ( -
- - - {teammateMessage.teammateId} - - - {noiseLabel} - -
+ // Get team members for @mention highlighting + const members = useStore( + useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)) + ); + const memberColorMap = useMemo( + () => (members ? buildMemberColorMap(members) : new Map()), + [members] ); - } - const truncatedSummary = - plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary; + // Get team names for @team linkification + const teams = useStore(useShallow((s) => s.teams)); + const teamNames = useMemo( + () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), + [teams] + ); - return ( -
- {/* Header */} + // Detect operational noise + const noiseLabel = useMemo( + () => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId), + [teammateMessage.content, teammateMessage.teammateId] + ); + + // Detect resent/duplicate messages + const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]); + + const plainSummary = useMemo( + () => extractMarkdownPlainText(teammateMessage.summary), + [teammateMessage.summary] + ); + const plainReplyToSummary = useMemo( + () => + teammateMessage.replyToSummary + ? extractMarkdownPlainText(teammateMessage.replyToSummary) + : undefined, + [teammateMessage.replyToSummary] + ); + + const displayContent = useMemo(() => { + const stripped = stripAgentBlocks(teammateMessage.content); + return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames); + }, [teammateMessage.content, memberColorMap, teamNames]); + + // Noise: minimal inline row (no card, no expand) + if (noiseLabel) { + return ( +
+ + + {teammateMessage.teammateId} + + + {noiseLabel} + +
+ ); + } + + const truncatedSummary = + plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary; + + return (
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} - className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${highlightClasses}`} style={{ - backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', - borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', + backgroundColor: CARD_BG, + border: CARD_BORDER_STYLE, + borderLeft: `3px solid ${colors.border}`, + opacity: isResend ? 0.6 : undefined, + ...highlightStyle, }} > - - - {/* Message icon — distinguishes from SubagentItem's Bot/dot icon */} - - - {/* Teammate name badge */} - { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" style={{ - backgroundColor: getThemedBadge(colors, isLight), - color: colors.text, - border: `1px solid ${colors.border}40`, + backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', + borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', }} > - {teammateMessage.teammateId} - - - {/* "Message" type label — parallels SubagentItem's model info */} - - Message - - - {/* Reply indicator — shows which SendMessage triggered this response */} - {plainReplyToSummary && ( - onReplyHover?.(teammateMessage.replyToToolId ?? null)} - onMouseLeave={() => onReplyHover?.(null)} + /> + + {/* Message icon — distinguishes from SubagentItem's Bot/dot icon */} + + + {/* Teammate name badge */} + - - - {plainReplyToSummary} + {teammateMessage.teammateId} + + + {/* "Message" type label — parallels SubagentItem's model info */} + + Message + + + {/* Reply indicator — shows which SendMessage triggered this response */} + {plainReplyToSummary && ( + onReplyHover?.(teammateMessage.replyToToolId ?? null)} + onMouseLeave={() => onReplyHover?.(null)} + > + + + {plainReplyToSummary} + + )} + + {/* Resend badge — marks duplicate/resent messages */} + {isResend && ( + + + Resent + + )} + + {/* Summary */} + + {truncatedSummary || 'Teammate message'} - )} - {/* Resend badge — marks duplicate/resent messages */} - {isResend && ( - - - Resent - - )} + {/* Context impact — tokens injected into main session */} + {teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && ( + + ~{formatTokensCompact(teammateMessage.tokenCount)} tokens + + )} - {/* Summary */} - - {truncatedSummary || 'Teammate message'} - - - {/* Context impact — tokens injected into main session */} - {teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && ( + {/* Timestamp — rightmost info element */} - ~{formatTokensCompact(teammateMessage.tokenCount)} tokens + {format(teammateMessage.timestamp, 'HH:mm:ss')} - )} - - {/* Timestamp — rightmost info element */} - - {format(teammateMessage.timestamp, 'HH:mm:ss')} - -
- - {/* Expanded content */} - {isExpanded && ( -
-
- )} -
- ); -}; + + {/* Expanded content */} + {isExpanded && ( +
+ +
+ )} +
+ ); + } +); diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index 9e94e566..ed53418d 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -31,52 +31,54 @@ interface TextItemProps { titleText?: string; } -export const TextItem: React.FC = ({ - step, - preview, - onClick, - isExpanded, - timestamp, - timestampFormat, - searchQueryOverride, - markdownItemId, - highlightClasses, - highlightStyle, - notificationDotColor, - titleText, -}) => { - const fullContent = step.content.outputText ?? preview; - const summary = searchQueryOverride - ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) - : preview; +export const TextItem: React.FC = React.memo( + ({ + step, + preview, + onClick, + isExpanded, + timestamp, + timestampFormat, + searchQueryOverride, + markdownItemId, + highlightClasses, + highlightStyle, + notificationDotColor, + titleText, + }) => { + const fullContent = step.content.outputText ?? preview; + const summary = searchQueryOverride + ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : preview; - // Get token count from step.tokens.output or step.content.tokenCount - const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; + // Get token count from step.tokens.output or step.content.tokenCount + const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; - return ( - } - label="Output" - summary={summary} - tokenCount={tokenCount} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - - - ); -}; + return ( + } + label="Output" + summary={summary} + tokenCount={tokenCount} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + + + ); + } +); diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index 116a9680..5a681ff5 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -31,52 +31,54 @@ interface ThinkingItemProps { titleText?: string; } -export const ThinkingItem: React.FC = ({ - step, - preview, - onClick, - isExpanded, - timestamp, - timestampFormat, - searchQueryOverride, - markdownItemId, - highlightClasses, - highlightStyle, - notificationDotColor, - titleText, -}) => { - const fullContent = step.content.thinkingText ?? preview; - const summary = searchQueryOverride - ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) - : preview; +export const ThinkingItem: React.FC = React.memo( + ({ + step, + preview, + onClick, + isExpanded, + timestamp, + timestampFormat, + searchQueryOverride, + markdownItemId, + highlightClasses, + highlightStyle, + notificationDotColor, + titleText, + }) => { + const fullContent = step.content.thinkingText ?? preview; + const summary = searchQueryOverride + ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : preview; - // Get token count from step.tokens.output or step.content.tokenCount - const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; + // Get token count from step.tokens.output or step.content.tokenCount + const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; - return ( - } - label="Thinking" - summary={summary} - tokenCount={tokenCount} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - - - ); -}; + return ( + } + label="Thinking" + summary={summary} + tokenCount={tokenCount} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + + + ); + } +); diff --git a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx index de6fb699..f0d9bc9b 100644 --- a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx +++ b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx @@ -5,7 +5,7 @@ * Shows a clickable header with label, StatusDot, and chevron toggle. */ -import React, { useState } from 'react'; +import React, { memo, useState } from 'react'; import { ChevronDown, ChevronRight } from 'lucide-react'; @@ -18,40 +18,44 @@ interface CollapsibleOutputSectionProps { label?: string; } -export const CollapsibleOutputSection: React.FC = ({ - status, - children, - label = 'Output', -}) => { - const [isExpanded, setIsExpanded] = useState(false); +export const CollapsibleOutputSection = memo( + ({ status, children, label = 'Output' }: CollapsibleOutputSectionProps): React.JSX.Element => { + const [isExpanded, setIsExpanded] = useState(false); - return ( -
- - {isExpanded && ( -
+
- )} -
- ); -}; + {isExpanded ? : } + {label} + + + {isExpanded && ( +
+ {children} +
+ )} +
+ ); + } +); diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index fc9ae282..8c4ba316 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -4,7 +4,7 @@ * Default rendering for tools that don't have specialized viewers. */ -import React from 'react'; +import React, { memo } from 'react'; import { type ItemStatus } from '../BaseItem'; @@ -23,7 +23,10 @@ interface DefaultToolViewerProps { status: ItemStatus; } -export const DefaultToolViewer: React.FC = ({ linkedTool, status }) => { +export const DefaultToolViewer = memo(function DefaultToolViewer({ + linkedTool, + status, +}: DefaultToolViewerProps) { const displayOutputContent = linkedTool.result ? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content) : null; @@ -64,4 +67,4 @@ export const DefaultToolViewer: React.FC = ({ linkedTool )} ); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx index b8a8ef7d..c537eb04 100644 --- a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx @@ -4,7 +4,7 @@ * Renders the Edit tool with DiffViewer. */ -import React from 'react'; +import React, { memo } from 'react'; import { DiffViewer } from '@renderer/components/chat/viewers'; @@ -20,7 +20,10 @@ interface EditToolViewerProps { status: ItemStatus; } -export const EditToolViewer: React.FC = ({ linkedTool, status }) => { +export const EditToolViewer = memo(function EditToolViewer({ + linkedTool, + status, +}: EditToolViewerProps) { const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string); @@ -71,4 +74,4 @@ export const EditToolViewer: React.FC = ({ linkedTool, stat )} ); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx index 4edd8c93..c2d14b6b 100644 --- a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx @@ -4,7 +4,7 @@ * Renders the Read tool result using CodeBlockViewer. */ -import React from 'react'; +import React, { memo } from 'react'; import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; @@ -14,7 +14,7 @@ interface ReadToolViewerProps { linkedTool: LinkedToolItem; } -export const ReadToolViewer: React.FC = ({ linkedTool }) => { +export const ReadToolViewer = memo(function ReadToolViewer({ linkedTool }: ReadToolViewerProps) { const filePath = linkedTool.input.file_path as string; // Prefer enriched toolUseResult data @@ -55,7 +55,9 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => : undefined; const isMarkdownFile = /\.mdx?$/i.test(filePath); - const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>( + isMarkdownFile ? 'preview' : 'code' + ); return (
@@ -99,4 +101,4 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => )}
); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx index c6447d79..9c180b28 100644 --- a/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx @@ -4,7 +4,7 @@ * Renders the Skill tool with its instructions in a code block viewer style. */ -import React from 'react'; +import React, { memo } from 'react'; import { CodeBlockViewer } from '@renderer/components/chat/viewers'; @@ -14,7 +14,7 @@ interface SkillToolViewerProps { linkedTool: LinkedToolItem; } -export const SkillToolViewer: React.FC = ({ linkedTool }) => { +export const SkillToolViewer = memo(function SkillToolViewer({ linkedTool }: SkillToolViewerProps) { const skillInstructions = linkedTool.skillInstructions; const skillName = (linkedTool.input.skill as string) || 'Unknown Skill'; @@ -64,4 +64,4 @@ export const SkillToolViewer: React.FC = ({ linkedTool }) )} ); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx b/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx index 13f3fd76..c52456ef 100644 --- a/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx +++ b/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx @@ -4,7 +4,7 @@ * Displays error output for tool results. */ -import React from 'react'; +import React, { memo } from 'react'; import { StatusDot } from '../BaseItem'; @@ -16,7 +16,9 @@ interface ToolErrorDisplayProps { linkedTool: LinkedToolItem; } -export const ToolErrorDisplay: React.FC = ({ linkedTool }) => { +export const ToolErrorDisplay = memo(function ToolErrorDisplay({ + linkedTool, +}: ToolErrorDisplayProps) { if (!linkedTool.result?.isError) return null; return ( @@ -40,4 +42,4 @@ export const ToolErrorDisplay: React.FC = ({ linkedTool } ); -}; +}); diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx index c2931cff..6402a880 100644 --- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -4,7 +4,7 @@ * Renders the Write tool result. */ -import React from 'react'; +import React, { memo } from 'react'; import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; @@ -14,7 +14,7 @@ interface WriteToolViewerProps { linkedTool: LinkedToolItem; } -export const WriteToolViewer: React.FC = ({ linkedTool }) => { +export const WriteToolViewer = memo(function WriteToolViewer({ linkedTool }: WriteToolViewerProps) { const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; const filePath = @@ -74,4 +74,4 @@ export const WriteToolViewer: React.FC = ({ linkedTool }) )} ); -}; +}); diff --git a/src/renderer/components/chat/viewers/CodeBlockViewer.tsx b/src/renderer/components/chat/viewers/CodeBlockViewer.tsx index d694f3f1..9edb7a64 100644 --- a/src/renderer/components/chat/viewers/CodeBlockViewer.tsx +++ b/src/renderer/components/chat/viewers/CodeBlockViewer.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { memo, useMemo, useState } from 'react'; import { getBaseName } from '@renderer/utils/pathUtils'; import { createLogger } from '@shared/utils/logger'; @@ -117,14 +117,14 @@ function inferLanguage(fileName: string): string { // Component // ============================================================================= -export const CodeBlockViewer: React.FC = ({ +export const CodeBlockViewer = memo(function CodeBlockViewer({ fileName, content, language, startLine = 1, endLine, maxHeight = 'max-h-96', -}): React.JSX.Element => { +}: CodeBlockViewerProps): React.JSX.Element { const [isCopied, setIsCopied] = useState(false); // Infer language from file extension if not provided @@ -241,4 +241,4 @@ export const CodeBlockViewer: React.FC = ({ ); -}; +}); diff --git a/src/renderer/components/chat/viewers/DiffViewer.tsx b/src/renderer/components/chat/viewers/DiffViewer.tsx index 3400051d..efb74cd6 100644 --- a/src/renderer/components/chat/viewers/DiffViewer.tsx +++ b/src/renderer/components/chat/viewers/DiffViewer.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { CODE_BG, @@ -349,14 +349,14 @@ const DiffLineRow: React.FC = ({ line, highlightedHtml }): Rea // Main Component // ============================================================================= -export const DiffViewer: React.FC = ({ +export const DiffViewer = memo(function DiffViewer({ fileName, oldString, newString, maxHeight = 'max-h-96', tokenCount, syntaxHighlight = false, -}): React.JSX.Element => { +}: DiffViewerProps): React.JSX.Element { // Compute diff const oldLines = oldString.split(/\r?\n/); const newLines = newString.split(/\r?\n/); @@ -456,4 +456,4 @@ export const DiffViewer: React.FC = ({ ); -}; +}); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 5bd3b977..90faaf38 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -946,7 +946,7 @@ export const CompactMarkdownPreview: React.FC = Rea } ); -export const MarkdownViewer: React.FC = ({ +export const MarkdownViewer: React.FC = React.memo(function MarkdownViewer({ content, maxHeight = 'max-h-96', className = '', @@ -958,7 +958,7 @@ export const MarkdownViewer: React.FC = ({ baseDir, teamColorByName: providedTeamColorByName, onTeamClick: providedOnTeamClick, -}) => { +}) { const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const { isLight } = useTheme(); @@ -1169,12 +1169,36 @@ export const MarkdownViewer: React.FC = ({ {label} - {copyable && ( - <> - - - - )} + + + {copyable && } + + )} + + {/* Show raw toggle for no-label path (skip in bare mode) */} + {!label && !bare && ( +
+ +
)} @@ -1195,4 +1219,4 @@ export const MarkdownViewer: React.FC = ({ ); -}; +}); diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx index 4ee09057..c8a2b2f0 100644 --- a/src/renderer/components/schedules/SchedulesView.tsx +++ b/src/renderer/components/schedules/SchedulesView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; @@ -24,13 +24,18 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog'; import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog'; import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types'; +const LaunchTeamDialog = lazy(() => + import('@renderer/components/team/dialogs/LaunchTeamDialog').then((m) => ({ + default: m.LaunchTeamDialog, + })) +); + // ============================================================================= // Constants // ============================================================================= @@ -562,13 +567,17 @@ export const SchedulesView = (): React.JSX.Element => { {/* Create/Edit Dialog */} - + {dialogOpen && ( + + + + )} ); }; diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index fe578e07..5efd93bf 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -4,7 +4,7 @@ * Supports multi-select with bulk actions and hidden session filtering. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; @@ -184,7 +184,7 @@ function matchesSessionSearch(session: Session, query: string): boolean { return haystack.includes(query); } -export const DateGroupedSessions = (): React.JSX.Element => { +export const DateGroupedSessions = memo((): React.JSX.Element => { const { sessions, selectedSessionId, @@ -202,7 +202,6 @@ export const DateGroupedSessions = (): React.JSX.Element => { toggleShowHiddenSessions, sidebarSelectedSessionIds, sidebarMultiSelectActive, - toggleSidebarSessionSelection, clearSidebarSelection, toggleSidebarMultiSelect, hideMultipleSessions, @@ -239,7 +238,6 @@ export const DateGroupedSessions = (): React.JSX.Element => { toggleShowHiddenSessions: s.toggleShowHiddenSessions, sidebarSelectedSessionIds: s.sidebarSelectedSessionIds, sidebarMultiSelectActive: s.sidebarMultiSelectActive, - toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, clearSidebarSelection: s.clearSidebarSelection, toggleSidebarMultiSelect: s.toggleSidebarMultiSelect, hideMultipleSessions: s.hideMultipleSessions, @@ -1104,7 +1102,6 @@ export const DateGroupedSessions = (): React.JSX.Element => { isHidden={item.isHidden} multiSelectActive={sidebarMultiSelectActive} isSelected={selectedSet.has(item.session.id)} - onToggleSelect={() => toggleSidebarSessionSelection(item.session.id)} /> )} @@ -1114,4 +1111,4 @@ export const DateGroupedSessions = (): React.JSX.Element => { ); -}; +}); diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 14dcc6c3..f236f190 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -173,13 +173,13 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized); } -export const GlobalTaskList = ({ +export const GlobalTaskList = memo(function GlobalTaskList({ hideHeader = false, filters: externalFilters, onFiltersChange: externalOnFiltersChange, filtersPopoverOpen: externalFiltersPopoverOpen, onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, -}: GlobalTaskListProps = {}): React.JSX.Element => { +}: GlobalTaskListProps = {}): React.JSX.Element { const { globalTasks, globalTasksLoading, @@ -271,37 +271,43 @@ export const GlobalTaskList = ({ saveSortMode(mode); }; - const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => { - taskLocalState.renameTask(teamName, taskId, newSubject); - setRenamingTaskKey(null); - }; + const handleRenameComplete = useCallback( + (teamName: string, taskId: string, newSubject: string): void => { + taskLocalState.renameTask(teamName, taskId, newSubject); + setRenamingTaskKey(null); + }, + [taskLocalState] + ); - const handleRenameCancel = (): void => { + const handleRenameCancel = useCallback((): void => { setRenamingTaskKey(null); - }; + }, []); - const handleDeleteTask = async (teamName: string, taskId: string): Promise => { - const confirmed = await confirm({ - title: 'Delete task', - message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, - confirmLabel: 'Delete', - cancelLabel: 'Cancel', - variant: 'danger', - }); - if (confirmed) { - try { - await softDeleteTask(teamName, taskId); - await fetchAllTasks(); - } catch (err) { - void confirm({ - title: 'Failed to delete task', - message: err instanceof Error ? err.message : 'An unexpected error occurred', - confirmLabel: 'OK', - variant: 'danger', - }); + const handleDeleteTask = useCallback( + async (teamName: string, taskId: string): Promise => { + const confirmed = await confirm({ + title: 'Delete task', + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (confirmed) { + try { + await softDeleteTask(teamName, taskId); + await fetchAllTasks(); + } catch (err) { + void confirm({ + title: 'Failed to delete task', + message: err instanceof Error ? err.message : 'An unexpected error occurred', + confirmLabel: 'OK', + variant: 'danger', + }); + } } - } - }; + }, + [fetchAllTasks, softDeleteTask] + ); // Fetch tasks on mount — loading guard in the store action prevents // duplicate IPC calls when the centralized init chain is already fetching. @@ -850,4 +856,4 @@ export const GlobalTaskList = ({ ); -}; +}); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index ab6b72a9..f9726a75 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -4,7 +4,7 @@ * Supports right-click context menu for pane management. */ -import { useCallback, useRef, useState } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; @@ -30,7 +30,6 @@ interface SessionItemProps { isHidden?: boolean; multiSelectActive?: boolean; isSelected?: boolean; - onToggleSelect?: () => void; } /** @@ -156,15 +155,14 @@ const SessionRuntimeBadge = ({ ); }; -export const SessionItem = ({ +export const SessionItem = memo(function SessionItem({ session, isActive, isPinned, isHidden, multiSelectActive, isSelected, - onToggleSelect, -}: Readonly): React.JSX.Element => { +}: Readonly): React.JSX.Element { const { openTab, activeProjectId, @@ -173,6 +171,7 @@ export const SessionItem = ({ splitPane, togglePinSession, toggleHideSession, + toggleSidebarSessionSelection, } = useStore( useShallow((s) => ({ openTab: s.openTab, @@ -182,6 +181,7 @@ export const SessionItem = ({ splitPane: s.splitPane, togglePinSession: s.togglePinSession, toggleHideSession: s.toggleHideSession, + toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, })) ); @@ -191,8 +191,8 @@ export const SessionItem = ({ if (!activeProjectId) return; // In multi-select mode, clicks toggle selection - if (multiSelectActive && onToggleSelect) { - onToggleSelect(); + if (multiSelectActive) { + toggleSidebarSessionSelection(session.id); return; } @@ -290,7 +290,7 @@ export const SessionItem = ({ onToggleSelect?.()} + onChange={() => toggleSidebarSessionSelection(session.id)} onClick={(e) => e.stopPropagation()} className="size-3.5 shrink-0 accent-blue-500" /> @@ -390,4 +390,4 @@ export const SessionItem = ({ )} ); -}; +}); diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 688aa958..ef6d959b 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; @@ -69,7 +69,7 @@ interface SidebarTaskItemProps { getDisplaySubject?: (task: GlobalTask) => string | undefined; } -export const SidebarTaskItem = ({ +export const SidebarTaskItem = memo(function SidebarTaskItem({ task, hideTeamName, showTeamName, @@ -77,7 +77,7 @@ export const SidebarTaskItem = ({ onRenameComplete, onRenameCancel, getDisplaySubject, -}: SidebarTaskItemProps): React.JSX.Element => { +}: SidebarTaskItemProps): React.JSX.Element { const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); @@ -283,4 +283,4 @@ export const SidebarTaskItem = ({ )} ); -}; +}); diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 3782434e..9b78b3a4 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { cn } from '@renderer/lib/utils'; @@ -44,7 +44,7 @@ interface CollapsibleTeamSectionProps { children: React.ReactNode; } -export const CollapsibleTeamSection = ({ +export const CollapsibleTeamSection = memo(function CollapsibleTeamSection({ title, icon, badge, @@ -63,7 +63,7 @@ export const CollapsibleTeamSection = ({ headerSurfaceClassName, keepMounted, children, -}: CollapsibleTeamSectionProps): React.JSX.Element => { +}: CollapsibleTeamSectionProps): React.JSX.Element { const [open, setOpen] = useState(defaultOpen); const isOpen = forceOpen ? true : open; const sectionRef = useRef(null); @@ -174,4 +174,4 @@ export const CollapsibleTeamSection = ({ )} ); -}; +}); diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index 7f29c0ff..15d8d479 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { getTeamColorSet, @@ -37,81 +37,83 @@ interface MemberBadgeProps { * When onClick is provided, both avatar and badge are clickable as one unit. * Wrapped in MemberHoverCard to show member info on hover. */ -export const MemberBadge = ({ - name, - color, - teamName, - size = 'sm', - hideAvatar, - onClick, - disableHoverCard, -}: MemberBadgeProps): React.JSX.Element => { - const colors = getTeamColorSet(color ?? ''); - const { isLight } = useTheme(); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const effectiveTeamName = teamName ?? selectedTeamName; - const teamMembers = useStore((s) => - effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : [] - ); - const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); - const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18; - const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4'; - const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]'; - const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5'; +export const MemberBadge = memo( + ({ + name, + color, + teamName, + size = 'sm', + hideAvatar, + onClick, + disableHoverCard, + }: MemberBadgeProps): React.JSX.Element => { + const colors = getTeamColorSet(color ?? ''); + const { isLight } = useTheme(); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const effectiveTeamName = teamName ?? selectedTeamName; + const teamMembers = useStore((s) => + effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : [] + ); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18; + const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4'; + const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]'; + const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5'; - const badgeStyle = { - backgroundColor: getThemedBadge(colors, isLight), - color: getThemedText(colors, isLight), - border: `1px solid ${getThemedBorder(colors, isLight)}40`, - }; + const badgeStyle = { + backgroundColor: getThemedBadge(colors, isLight), + color: getThemedText(colors, isLight), + border: `1px solid ${getThemedBorder(colors, isLight)}40`, + }; - const avatar = ( - - ); + const avatar = ( + + ); - const badge = ( - - {displayMemberName(name)} - - ); + const badge = ( + + {displayMemberName(name)} + + ); - // Skip hover card for "user" and "system" pseudo-members - const skipHoverCard = disableHoverCard || name === 'user' || name === 'system'; + // Skip hover card for "user" and "system" pseudo-members + const skipHoverCard = disableHoverCard || name === 'user' || name === 'system'; - const content = onClick ? ( - - ) : ( - - {!hideAvatar && avatar} - {badge} - - ); + const content = onClick ? ( + + ) : ( + + {!hideAvatar && avatar} + {badge} + + ); - if (skipHoverCard) { - return content; + if (skipHoverCard) { + return content; + } + + return ( + + {content} + + ); } - - return ( - - {content} - - ); -}; +); diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index 97471ad5..b777c22f 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -65,12 +65,12 @@ interface TaskTooltipProps { * Tooltip that shows task summary on hover over any #taskId link. * Reads task data from the current team in the store. */ -export const TaskTooltip = ({ +export const TaskTooltip = memo(function TaskTooltip({ taskId, teamName, children, side = 'top', -}: TaskTooltipProps): React.JSX.Element => { +}: TaskTooltipProps): React.JSX.Element { const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } = useStore( useShallow((s) => ({ @@ -194,4 +194,4 @@ export const TaskTooltip = ({ ); -}; +}); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 45af604f..341485f5 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -78,12 +78,8 @@ import { import { useShallow } from 'zustand/react/shallow'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; -import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; -import { LaunchTeamDialog, type TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import { ReviewDialog } from './dialogs/ReviewDialog'; -import { SendMessageDialog } from './dialogs/SendMessageDialog'; -import { TaskDetailDialog } from './dialogs/TaskDetailDialog'; import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow'; import { KanbanBoard } from './kanban/KanbanBoard'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; @@ -93,9 +89,13 @@ import { MemberDetailDialog } from './members/MemberDetailDialog'; import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; +import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { ComponentProps, CSSProperties } from 'react'; +const LaunchTeamDialog = lazy(() => + import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); @@ -104,9 +104,20 @@ const TeamGraphOverlay = lazy(() => default: m.TeamGraphOverlay, })) ); +const TaskDetailDialog = lazy(() => + import('./dialogs/TaskDetailDialog').then((m) => ({ default: m.TaskDetailDialog })) +); +const SendMessageDialog = lazy(() => + import('./dialogs/SendMessageDialog').then((m) => ({ default: m.SendMessageDialog })) +); +const CreateTaskDialog = lazy(() => + import('./dialogs/CreateTaskDialog').then((m) => ({ default: m.CreateTaskDialog })) +); +const ChangeReviewDialog = lazy(() => + import('./review/ChangeReviewDialog').then((m) => ({ default: m.ChangeReviewDialog })) +); import { MemberList } from './members/MemberList'; import { MessagesPanel } from './messages/MessagesPanel'; -import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { ScheduleSection } from './schedule/ScheduleSection'; import { TeamSidebarHost } from './sidebar/TeamSidebarHost'; import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource'; @@ -874,10 +885,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( ); }); -export const TeamDetailView = ({ +export const TeamDetailView = memo(function TeamDetailView({ teamName, isPaneFocused = false, -}: TeamDetailViewProps): React.JSX.Element => { +}: TeamDetailViewProps): React.JSX.Element { const { isLight } = useTheme(); const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); @@ -2077,18 +2088,22 @@ export const TeamDetailView = ({ - + {launchDialogOpen && ( + + + + )} ); } @@ -2744,21 +2759,25 @@ export const TeamDetailView = ({ }} /> - + {createTaskDialog.open && ( + + + + )} - + {launchDialogOpen && ( + + + + )} - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - const result = await sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - taskRefs, - }); - if ( - result?.runtimeDelivery?.attempted === true && - result.runtimeDelivery.delivered === false - ) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - return result; - } catch (error) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - throw error; - } - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - }} - /> + {sendDialogOpen && ( + + { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + const result = await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + if ( + result?.runtimeDelivery?.attempted === true && + result.runtimeDelivery.delivered === false + ) { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + return result; + } catch (error) { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + throw error; + } + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + )} - setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store + {selectedTask !== null && ( + + { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> + taskMap={taskMap} + members={activeMembers} + onClose={() => setSelectedTask(null)} + onScrollToTask={(taskId) => { + setSelectedTask(null); + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener( + 'animationend', + () => el.classList.remove('kanban-card-focus-pulse'), + { once: true } + ); + } + }} + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store + } + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} + onDeleteTask={handleDeleteTask} + /> + + )} - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open - ? {} - : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} - projectPath={data.config.projectPath} - onEditorAction={handleEditorAction} - /> + {reviewDialogState.open && ( + + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open + ? {} + : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), + })) + } + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} + /> + + )}
); -}; +}); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 357ee15a..b3ab27f7 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { api, isElectronMode } from '@renderer/api'; @@ -45,8 +45,6 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; -import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import { @@ -67,6 +65,13 @@ import type { TeamSummaryMember, } from '@shared/types'; +const CreateTeamDialog = lazy(() => + import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) +); +const LaunchTeamDialog = lazy(() => + import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); + function generateUniqueName(sourceName: string, existingNames: string[]): string { const base = sourceName.replace(/-\d+$/, ''); const existing = new Set(existingNames); @@ -233,7 +238,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { } }; -export const TeamListView = (): React.JSX.Element => { +export const TeamListView = memo(function TeamListView(): React.JSX.Element { const { isLight } = useTheme(); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -731,36 +736,40 @@ export const TeamListView = (): React.JSX.Element => { ); } - const createDialogElement = ( - t.teamName)} - provisioningTeamNames={provisioningTeamNames} - activeTeams={activeTeams} - initialData={copyData ?? undefined} - defaultProjectPath={currentProjectPath} - onClose={handleCreateDialogClose} - onCreate={handleCreateSubmit} - onOpenTeam={openTeamTab} - /> + const createDialogElement = showCreateDialog && ( + + t.teamName)} + provisioningTeamNames={provisioningTeamNames} + activeTeams={activeTeams} + initialData={copyData ?? undefined} + defaultProjectPath={currentProjectPath} + onClose={handleCreateDialogClose} + onCreate={handleCreateSubmit} + onOpenTeam={openTeamTab} + /> + ); - const launchDialogElement = ( - setLaunchDialogOpen(false)} - onLaunch={handleLaunchSubmit} - /> + const launchDialogElement = launchDialogOpen && ( + + setLaunchDialogOpen(false)} + onLaunch={handleLaunchSubmit} + /> + ); const renderHeader = (): React.JSX.Element => ( @@ -1177,4 +1186,4 @@ export const TeamListView = (): React.JSX.Element => {
); -}; +}); diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index 64398b5d..ac55ed8c 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useState } from 'react'; +import { memo, type ReactNode, useState } from 'react'; import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; @@ -32,14 +32,14 @@ interface ActivityEntry { kind: 'working' | 'reviewing'; } -export const ActiveTasksBlock = ({ +export const ActiveTasksBlock = memo(function ActiveTasksBlock({ members, tasks, defaultCollapsed = false, headerRight, onMemberClick, onTaskClick, -}: ActiveTasksBlockProps): React.JSX.Element | null => { +}: ActiveTasksBlockProps): React.JSX.Element | null { const { isLight } = useTheme(); const [collapsed, setCollapsed] = useState(defaultCollapsed); const colorMap = buildMemberColorMap(members); @@ -188,4 +188,4 @@ export const ActiveTasksBlock = ({ })} ); -}; +}); diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index fd521447..8e266ddb 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; @@ -32,13 +34,13 @@ interface PendingRepliesBlockProps { onMemberClick?: (member: ResolvedTeamMember) => void; } -export const PendingRepliesBlock = ({ +export const PendingRepliesBlock = memo(function PendingRepliesBlock({ members, pendingRepliesByMember, pendingCrossTeamReplies = [], headerRight, onMemberClick, -}: PendingRepliesBlockProps): React.JSX.Element | null => { +}: PendingRepliesBlockProps): React.JSX.Element | null { const { isLight } = useTheme(); const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals)); const colorMap = buildMemberColorMap(members); @@ -270,4 +272,4 @@ export const PendingRepliesBlock = ({ })} ); -}; +}); diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index 208291a2..417fe683 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -20,60 +20,62 @@ interface ReplyQuoteBlockProps { /** Threshold (characters) above which the "more/less" toggle is shown. */ const LONG_QUOTE_THRESHOLD = 200; -export const ReplyQuoteBlock = ({ - reply, - memberColor, - bodyMaxHeight = 'max-h-56', - replyTaskRefs, -}: ReplyQuoteBlockProps): React.JSX.Element => { - const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; - const [expanded, setExpanded] = useState(false); +export const ReplyQuoteBlock = memo( + ({ + reply, + memberColor, + bodyMaxHeight = 'max-h-56', + replyTaskRefs, + }: ReplyQuoteBlockProps): React.JSX.Element => { + const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; + const [expanded, setExpanded] = useState(false); - const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]'; + const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]'; - return ( -
- {/* Quote block — styled like SendMessageDialog */} -
- {/* Decorative quotation mark */} - - “ - + return ( +
+ {/* Quote block — styled like SendMessageDialog */} +
+ {/* Decorative quotation mark */} + + “ + - {/* "Replying to" + MemberBadge */} -
- Replying to - + {/* "Replying to" + MemberBadge */} +
+ Replying to + +
+ + {/* Quote text */} +
+ +
+ + {/* More/less toggle */} + {isLong ? ( + + ) : null}
- {/* Quote text */} -
- -
- - {/* More/less toggle */} - {isLong ? ( - - ) : null} + {/* Reply text */} +
- - {/* Reply text */} - -
- ); -}; + ); + } +); diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 62d5759e..e5241390 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; @@ -311,7 +311,7 @@ const SortableKanbanTaskCard = ({ ); }; -export const KanbanBoard = ({ +export const KanbanBoard = memo(function KanbanBoard({ tasks, teamName, kanbanState, @@ -338,7 +338,7 @@ export const KanbanBoard = ({ onDeleteTask, deletedTaskCount, onOpenTrash, -}: KanbanBoardProps): React.JSX.Element => { +}: KanbanBoardProps): React.JSX.Element { const boardRef = useRef(null); const scrollRestoreTimeoutsRef = useRef([]); const [viewMode, setViewMode] = useState('grid'); @@ -422,101 +422,119 @@ export const KanbanBoard = ({ [onColumnOrderChange, groupedOrdered] ); - const renderCards = ( - columnId: KanbanColumnId, - columnTasks: TeamTask[], - compact?: boolean - ): React.JSX.Element => { - const addHandler = - onAddTask && columnId === 'todo' - ? () => onAddTask(false) - : onAddTask && columnId === 'in_progress' - ? () => onAddTask(true) - : undefined; + const renderCards = useCallback( + (columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean): React.JSX.Element => { + const addHandler = + onAddTask && columnId === 'todo' + ? () => onAddTask(false) + : onAddTask && columnId === 'in_progress' + ? () => onAddTask(true) + : undefined; - const addButton = addHandler ? ( - - ) : null; + const addButton = addHandler ? ( + + ) : null; - if (columnTasks.length === 0) { - return ( - addButton ?? ( -
- No tasks -
- ) - ); - } - if (enableTaskSorting) { - const itemIds = columnTasks.map((t) => t.id); + if (columnTasks.length === 0) { + return ( + addButton ?? ( +
+ No tasks +
+ ) + ); + } + if (enableTaskSorting) { + const itemIds = columnTasks.map((t) => t.id); + return ( + <> + + {columnTasks.map((task) => ( + + ))} + + {addButton} + + ); + } return ( <> - - {columnTasks.map((task) => ( - - ))} - + {columnTasks.map((task) => ( + + ))} {addButton} ); - } - return ( - <> - {columnTasks.map((task) => ( - - ))} - {addButton} - - ); - }; + }, + [ + enableTaskSorting, + hasReviewers, + kanbanState, + memberColorMap, + onAddTask, + onApprove, + onCancelTask, + onCompleteTask, + onDeleteTask, + onMoveBackToDone, + onRequestChanges, + onRequestReview, + onScrollToTask, + onStartTask, + onTaskClick, + onViewChanges, + taskMap, + teamName, + ] + ); const visibleColumns = useMemo( () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), @@ -591,6 +609,29 @@ export const KanbanBoard = ({ [scheduleScrollRestore, viewMode] ); + const gridColumns = useMemo( + () => + visibleColumns.map((column) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + return { + id: column.id, + title: column.title, + count: columnTasks.length, + icon: accent.icon, + headerBg: accent.headerBg, + bodyBg: accent.bodyBg, + content: renderCards(column.id, columnTasks), + showAddButton: columnSupportsAddButton(column.id, onAddTask), + skeletonCards: columnTasks.map((task) => ({ + key: task.id, + height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), + })), + }; + }), + [visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers] + ); + const boardContent = (
{ - const columnTasks = groupedOrdered.get(column.id) ?? []; - const accent = COLUMN_ACCENTS[column.id]; - - return { - id: column.id, - title: column.title, - count: columnTasks.length, - icon: accent.icon, - headerBg: accent.headerBg, - bodyBg: accent.bodyBg, - content: renderCards(column.id, columnTasks), - showAddButton: columnSupportsAddButton(column.id, onAddTask), - skeletonCards: columnTasks.map((task) => ({ - key: task.id, - height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), - })), - }; - })} + columns={gridColumns} /> ) : (
@@ -752,4 +775,4 @@ export const KanbanBoard = ({ } return boardContent; -}; +}); diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx index 6b5f868e..9b30de1a 100644 --- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -1,5 +1,5 @@ /* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; @@ -387,74 +387,76 @@ const LoadedKanbanGridLayout = ({ ); }; -export const KanbanGridLayout = ({ - columns, - allColumnIds, - primaryColumnId, - onPrimaryColumnWidthChange, - skeletonDelayMs = SKELETON_HIDE_DELAY_MS, -}: KanbanGridLayoutProps): React.JSX.Element => { - const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); - const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ - scopeKey: GRID_SCOPE_KEY, - allItemIds: allColumnIds, - visibleItemIds: visibleColumnIds, - cols: GRID_COLS, - repository: browserGridLayoutRepository, - buildDefaultItems, - }); - const [showResolvedLayout, setShowResolvedLayout] = useState(false); +export const KanbanGridLayout = memo( + ({ + columns, + allColumnIds, + primaryColumnId, + onPrimaryColumnWidthChange, + skeletonDelayMs = SKELETON_HIDE_DELAY_MS, + }: KanbanGridLayoutProps): React.JSX.Element => { + const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); + const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ + scopeKey: GRID_SCOPE_KEY, + allItemIds: allColumnIds, + visibleItemIds: visibleColumnIds, + cols: GRID_COLS, + repository: browserGridLayoutRepository, + buildDefaultItems, + }); + const [showResolvedLayout, setShowResolvedLayout] = useState(false); - useEffect(() => { - if (showResolvedLayout) return; + useEffect(() => { + if (showResolvedLayout) return; - const timeoutId = window.setTimeout(() => { - setShowResolvedLayout(true); - }, skeletonDelayMs); + const timeoutId = window.setTimeout(() => { + setShowResolvedLayout(true); + }, skeletonDelayMs); - return () => { - window.clearTimeout(timeoutId); - }; - }, [showResolvedLayout, skeletonDelayMs]); + return () => { + window.clearTimeout(timeoutId); + }; + }, [showResolvedLayout, skeletonDelayMs]); - const applyReactGridLayout = useCallback( - (layout: Layout, options?: { persist?: boolean }) => { - if (options?.persist) { - applyVisibleItems(fromReactGridLayout(layout), options); - } - }, - [applyVisibleItems] - ); - const showSkeletonOverlay = !showResolvedLayout || !isLoaded; + const applyReactGridLayout = useCallback( + (layout: Layout, options?: { persist?: boolean }) => { + if (options?.persist) { + applyVisibleItems(fromReactGridLayout(layout), options); + } + }, + [applyVisibleItems] + ); + const showSkeletonOverlay = !showResolvedLayout || !isLoaded; - const gridKey = visibleItems.map((item) => item.id).join('|'); + const gridKey = visibleItems.map((item) => item.id).join('|'); - return ( -
- - {showSkeletonOverlay ? ( - + - ) : null} -
- ); -}; + {showSkeletonOverlay ? ( + + ) : null} +
+ ); + } +); export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH }; /* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */ diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index 4781c199..4a8394f3 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -15,42 +17,44 @@ interface CurrentTaskIndicatorProps { * Inline indicator showing a spinning loader + "working on" + task label button. * Shared between MemberCard and MemberHoverCard. */ -export const CurrentTaskIndicator = ({ - task, - borderColor, - maxSubjectLength, - activityLabel = 'working on', - onOpenTask, -}: CurrentTaskIndicatorProps): React.JSX.Element => { - const subjectText = - typeof maxSubjectLength === 'number' && - maxSubjectLength > 0 && - task.subject.length > maxSubjectLength - ? `${task.subject.slice(0, maxSubjectLength)}…` - : task.subject; +export const CurrentTaskIndicator = memo( + ({ + task, + borderColor, + maxSubjectLength, + activityLabel = 'working on', + onOpenTask, + }: CurrentTaskIndicatorProps): React.JSX.Element => { + const subjectText = + typeof maxSubjectLength === 'number' && + maxSubjectLength > 0 && + task.subject.length > maxSubjectLength + ? `${task.subject.slice(0, maxSubjectLength)}…` + : task.subject; - return ( -
- - {activityLabel} - -
- ); -}; + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + e.stopPropagation(); + onOpenTask?.(); + } + }} + > + {formatTaskDisplayLabel(task)} {subjectText} + +
+ ); + } +); diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 4f66def2..19d99dfa 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; @@ -91,7 +91,7 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): { }; } -export const MemberCard = ({ +export const MemberCard = memo(function MemberCard({ member, memberColor, runtimeSummary, @@ -119,7 +119,7 @@ export const MemberCard = ({ onAssignTask, onRestartMember, onSkipMemberForLaunch, -}: MemberCardProps): React.JSX.Element => { +}: MemberCardProps): React.JSX.Element { // NOTE: lead context display disabled — usage formula is inaccurate // const teamName = useStore((s) => s.selectedTeamName); // const leadContext = useStore((s) => @@ -711,4 +711,4 @@ export const MemberCard = ({
); -}; +}); diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index e701ea27..d5ce4def 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import { Badge } from '@renderer/components/ui/badge'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'; import { @@ -57,13 +59,13 @@ interface MemberHoverCardProps { * Reads member data from the team snapshot + resolved member selectors. * Falls back to a simple wrapper when member data is unavailable. */ -export const MemberHoverCard = ({ +export const MemberHoverCard = memo(function MemberHoverCard({ name, color, teamName, onOpenTask, children, -}: MemberHoverCardProps): React.JSX.Element => { +}: MemberHoverCardProps): React.JSX.Element { const { isLight } = useTheme(); const selectedTeamName = useStore((s) => s.selectedTeamName); const effectiveTeamName = teamName ?? selectedTeamName; @@ -289,4 +291,4 @@ export const MemberHoverCard = ({ ); -}; +}); diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 8b3f6140..115a298b 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -10,6 +10,9 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, + MemberLaunchState, + MemberSpawnLivenessSource, + MemberSpawnStatus, MemberSpawnStatusEntry, ResolvedTeamMember, TeamAgentRuntimeEntry, @@ -255,6 +258,115 @@ function areMemberListPropsEqual( ); } +// --------------------------------------------------------------------------- +// Per-member row wrapper — creates stable callbacks so MemberCard memo holds +// --------------------------------------------------------------------------- + +interface MemberCardRowProps { + member: ResolvedTeamMember; + isRemoved: boolean; + memberColor: string; + currentTask: TeamTaskWithKanban | null; + reviewTask: TeamTaskWithKanban | null; + awaitingReply: boolean; + taskCounts?: TaskStatusCounts | null; + runtimeSummary?: string; + runtimeEntry?: TeamAgentRuntimeEntry; + runtimeRunId?: string | null; + spawnStatus?: MemberSpawnStatus; + spawnEntry?: MemberSpawnStatusEntry; + spawnError?: string; + spawnLivenessSource?: MemberSpawnLivenessSource; + spawnLaunchState?: MemberLaunchState; + spawnRuntimeAlive?: boolean; + isTeamAlive?: boolean; + isTeamProvisioning?: boolean; + leadActivity?: LeadActivityState; + isLaunchSettling?: boolean; + onOpenTask?: (taskId: string) => void; + onMemberClick?: (member: ResolvedTeamMember) => void; + onSendMessage?: (member: ResolvedTeamMember) => void; + onAssignTask?: (member: ResolvedTeamMember) => void; + onRestartMember?: (memberName: string) => Promise | void; + onSkipMemberForLaunch?: (memberName: string) => Promise | void; +} + +const MemberCardRow = memo(function MemberCardRow({ + member, + isRemoved, + memberColor, + currentTask, + reviewTask, + awaitingReply, + taskCounts, + runtimeSummary, + runtimeEntry, + runtimeRunId, + spawnStatus, + spawnEntry, + spawnError, + spawnLivenessSource, + spawnLaunchState, + spawnRuntimeAlive, + isTeamAlive, + isTeamProvisioning, + leadActivity, + isLaunchSettling, + onOpenTask, + onMemberClick, + onSendMessage, + onAssignTask, + onRestartMember, + onSkipMemberForLaunch, +}: MemberCardRowProps): React.JSX.Element { + const currentTaskId = currentTask?.id; + const reviewTaskId = reviewTask?.id; + + const handleOpenTask = useCallback(() => { + if (currentTaskId) onOpenTask?.(currentTaskId); + }, [onOpenTask, currentTaskId]); + + const handleOpenReviewTask = useCallback(() => { + if (reviewTaskId) onOpenTask?.(reviewTaskId); + }, [onOpenTask, reviewTaskId]); + + const handleClick = useCallback(() => onMemberClick?.(member), [onMemberClick, member]); + const handleSendMessage = useCallback(() => onSendMessage?.(member), [onSendMessage, member]); + const handleAssignTask = useCallback(() => onAssignTask?.(member), [onAssignTask, member]); + + return ( + + ); +}); + export const MemberList = memo(function MemberList({ members, memberTaskCounts, @@ -340,63 +452,89 @@ export const MemberList = memo(function MemberList({ return result; }, [taskMap]); - const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => { - const currentTask = - member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; - const reviewCandidate = reviewTaskByMember.get(member.name) ?? null; - const reviewTask = - reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null; - const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]); - const spawnEntry = memberSpawnStatuses?.get(member.name); - const runtimeEntry = memberRuntimeEntries?.get(member.name); - return ( - onOpenTask?.(currentTask.id) : undefined} - onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined} - onClick={() => onMemberClick?.(member)} - onSendMessage={() => onSendMessage?.(member)} - onAssignTask={() => onAssignTask?.(member)} - onRestartMember={isRemoved ? undefined : onRestartMember} - onSkipMemberForLaunch={isRemoved ? undefined : onSkipMemberForLaunch} - /> - ); - }; - return (
-
{activeMembers.map((member) => renderCard(member, false))}
+
+ {activeMembers.map((member) => { + const currentTask = + member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; + const reviewCandidate = reviewTaskByMember.get(member.name) ?? null; + const reviewTask = + reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null; + const spawnEntry = memberSpawnStatuses?.get(member.name); + const runtimeEntry = memberRuntimeEntries?.get(member.name); + return ( + + ); + })} +
{removedMembers.length > 0 && ( <>
Removed ({removedMembers.length})
- {removedMembers.map((member) => renderCard(member, true))} + {removedMembers.map((member) => ( + + ))}
)} diff --git a/src/renderer/components/team/members/MemberPresenceDot.tsx b/src/renderer/components/team/members/MemberPresenceDot.tsx index aab1f38b..bb2f8a3f 100644 --- a/src/renderer/components/team/members/MemberPresenceDot.tsx +++ b/src/renderer/components/team/members/MemberPresenceDot.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle'; import { cn } from '@renderer/lib/utils'; @@ -8,21 +10,20 @@ interface MemberPresenceDotProps { label: string; } -export const MemberPresenceDot = ({ - className, - label, -}: MemberPresenceDotProps): React.JSX.Element => { - const shouldSyncPulse = className?.includes('animate-pulse') === true; - const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS); +export const MemberPresenceDot = memo( + ({ className, label }: MemberPresenceDotProps): React.JSX.Element => { + const shouldSyncPulse = className?.includes('animate-pulse') === true; + const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS); - return ( - - ); -}; + return ( + + ); + } +); diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx index b8f44bd4..2a755123 100644 --- a/src/renderer/components/team/schedule/ScheduleSection.tsx +++ b/src/renderer/components/team/schedule/ScheduleSection.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; @@ -18,13 +18,15 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog'; - import { ScheduleEmptyState } from './ScheduleEmptyState'; import { ScheduleRunLogDialog } from './ScheduleRunLogDialog'; import { ScheduleRunRow } from './ScheduleRunRow'; import { ScheduleStatusBadge } from './ScheduleStatusBadge'; +const LaunchTeamDialog = lazy(() => + import('../dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); + import type { Schedule, ScheduleRun } from '@shared/types'; // ============================================================================= @@ -305,13 +307,17 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E )} {/* Create/Edit Dialog */} - + {dialogOpen && ( + + + + )}
); }; diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index 17f72124..5e188cda 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import { KANBAN_COLUMN_DISPLAY, REVIEW_STATE_DISPLAY, @@ -12,7 +14,7 @@ interface TaskRowProps { task: TeamTaskWithKanban; } -export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { +export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element { const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const kanbanColumn = getTaskKanbanColumn(task); @@ -62,4 +64,4 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { ); -}; +}); diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 01b50283..cf98ab04 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -46,7 +46,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { }); import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; -import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; +import { + TeamMcpConfigBuilder, + clearResolvedNodePathForTests, +} from '@main/services/team/TeamMcpConfigBuilder'; describe('TeamMcpConfigBuilder', () => { const createdPaths: string[] = []; @@ -93,7 +96,7 @@ describe('TeamMcpConfigBuilder', () => { entry: string ): void { expect(server?.args).toEqual([entry]); - expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); + expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function expectNodeTsxSourceEntry( @@ -102,7 +105,7 @@ describe('TeamMcpConfigBuilder', () => { sourceEntry: string ): void { expect(server?.args).toEqual([tsxCli, sourceEntry]); - expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); + expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function getBuiltWorkspaceEntry(): string { @@ -165,6 +168,7 @@ describe('TeamMcpConfigBuilder', () => { } beforeEach(() => { + clearResolvedNodePathForTests(); originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-')); createdDirs.push(tempAppData); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 4773a70e..28718cf1 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -16,9 +16,10 @@ const hoisted = vi.hoisted(() => { error.code = 'ENOENT'; throw error; } + const size = Buffer.byteLength(data, 'utf8'); return { isFile: () => true, - size: Buffer.byteLength(data, 'utf8'), + size, mode: 0o100644, dev: 1, ino: 1, @@ -63,22 +64,20 @@ const hoisted = vi.hoisted(() => { files.set(sentMessagesPath, JSON.stringify(rows)); return message; }), - sendInboxMessage: vi.fn( - (teamName: string, message: Record) => { - const member = - typeof message.member === 'string' - ? message.member - : typeof message.to === 'string' - ? message.to - : 'unknown'; - const p = `/mock/teams/${teamName}/inboxes/${member}.json`; - const current = files.get(p); - const rows = current ? (JSON.parse(current) as unknown[]) : []; - rows.push(message); - files.set(p, JSON.stringify(rows)); - return { deliveredToInbox: true, messageId: 'mock-id', message }; - } - ), + sendInboxMessage: vi.fn((teamName: string, message: Record) => { + const member = + typeof message.member === 'string' + ? message.member + : typeof message.to === 'string' + ? message.to + : 'unknown'; + const p = `/mock/teams/${teamName}/inboxes/${member}.json`; + const current = files.get(p); + const rows = current ? (JSON.parse(current) as unknown[]) : []; + rows.push(message); + files.set(p, JSON.stringify(rows)); + return { deliveredToInbox: true, messageId: 'mock-id', message }; + }), setAtomicWriteShouldFail: (next: boolean) => { atomicWriteShouldFail = next; }, @@ -419,7 +418,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('Source: system_notification'); expect(payload).toContain('summary looks like \\"Comment on #...\\"'); - expect(payload).toContain('reply via task_add_comment only when you have a substantive board update'); + expect(payload).toContain( + 'reply via task_add_comment only when you have a substantive board update' + ); expect(payload).toContain('Do NOT post acknowledgement-only task comments'); (service as any).handleStreamJsonMessage(run, { @@ -540,9 +541,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { runId: 'run-old', }); const inboxDeferred = createDeferred(); - const inboxReader = (service as unknown as { - inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -586,14 +591,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' }); const inboxDeferred = createDeferred<[typeof permissionMessage]>(); - const inboxReader = (service as unknown as { - inboxReader: { - getMessagesFor: ( - team: string, - member: string - ) => Promise<[typeof permissionMessage]>; - }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise<[typeof permissionMessage]>; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -702,7 +706,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('Source: cross_team'); expect(payload).toContain('Cross-team conversationId: conv-explicit'); - expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"'); + expect(payload).toContain( + 'Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"' + ); expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"'); expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"'); @@ -953,7 +959,11 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { attachAliveRun(service, teamName); const run = (service as unknown as { runs: Map }).runs.get('run-1') as { - silentUserDmForward: { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' } | null; + silentUserDmForward: { + target: string; + startedAt: string; + mode: 'user_dm' | 'member_inbox_relay'; + } | null; }; run.silentUserDmForward = { target: 'alice', @@ -1120,9 +1130,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { runId: 'run-old', }); const inboxDeferred = createDeferred(); - const inboxReader = (service as unknown as { - inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -1332,11 +1346,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { await (service as any).markInboxMessagesRead(teamName, 'alice', [ { - messageId: buildLegacyInboxMessageId( - legacyRow.from, - legacyRow.timestamp, - legacyRow.text - ), + messageId: buildLegacyInboxMessageId(legacyRow.from, legacyRow.timestamp, legacyRow.text), }, ]); @@ -1732,9 +1742,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(true); }); @@ -1780,9 +1788,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { failed: 0, lastDelivery: { delivered: true, responsePending: true }, }); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); @@ -1914,9 +1920,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { teamName, expect.objectContaining({ messageId: 'opencode-terminal-new' }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]); }); @@ -2000,9 +2004,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'opencode_attachments_not_supported_for_secondary_runtime' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); expect(records[0]).toMatchObject({ inboxMessageId: 'opencode-attachment-1', @@ -2027,7 +2029,10 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ], }) ); - const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack'); + const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity( + teamName, + 'jack' + ); expect(identity.ok).toBe(true); const laneId = identity.laneId; const records: any[] = []; @@ -2048,7 +2053,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { return record; }), markAcceptanceUnknown: vi.fn( - async (input: { id: string; reason: string; nextAttemptAt: string; markedAt: string }) => { + async (input: { + id: string; + reason: string; + nextAttemptAt: string; + markedAt: string; + }) => { const record = records.find((candidate) => candidate.id === input.id); Object.assign(record, { status: 'failed_retryable', @@ -2180,9 +2190,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { teamName, expect.objectContaining({ messageId: 'opencode-inflight-new' }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, true]); }); @@ -2353,9 +2361,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'OpenCode inbox relay failed for jack/opencode-relay-failed-1' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); @@ -2387,9 +2393,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { delivered: true, diagnostics: [], }); - vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue( - new Error('write failed') - ); + vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue(new Error('write failed')); const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); @@ -2410,9 +2414,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'opencode_inbox_mark_read_failed_after_delivery' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); });