From fa38b90f9cf20869a59734662b0d993bbc4cc707 Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 20:49:16 +0500 Subject: [PATCH] perf(renderer): memoize chat and sidebar list item components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap SessionItem, SubagentItem, ExecutionTrace, TextItem, ThinkingItem, and DisplayItemList in React.memo. These components render repeatedly in virtualized lists and AI chat groups — memoizing them eliminates redundant renders when their props have not changed, reducing CPU work in active sessions with many messages or long session sidebars. --- .../components/chat/DisplayItemList.tsx | 678 ++++++------ .../components/chat/items/ExecutionTrace.tsx | 453 ++++---- .../components/chat/items/SubagentItem.tsx | 966 +++++++++--------- .../components/chat/items/TextItem.tsx | 96 +- .../components/chat/items/ThinkingItem.tsx | 96 +- .../components/sidebar/SessionItem.tsx | 452 ++++---- 6 files changed, 1385 insertions(+), 1356 deletions(-) diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index ec0412d5..cd2c5754 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -87,345 +87,353 @@ function truncateText(text: string, maxLength: number): string { * * The list is completely flat with no nested toggles or hierarchies. */ -export const DisplayItemList = ({ - items, - onItemClick, - expandedItemIds, - aiGroupId, - order = 'chronological', - searchQueryOverride, - highlightToolUseId, - highlightColor, - notificationColorMap, - registerToolRef, - previewMaxLength, - timestampFormat, - showItemMetaTooltip = false, -}: Readonly): React.JSX.Element => { - // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair - const [replyLinkToolId, setReplyLinkToolId] = useState(null); +export const DisplayItemList = React.memo( + ({ + items, + onItemClick, + expandedItemIds, + aiGroupId, + order = 'chronological', + searchQueryOverride, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, + }: Readonly): React.JSX.Element => { + // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair + const [replyLinkToolId, setReplyLinkToolId] = useState(null); - const handleReplyHover = useCallback((toolId: string | null) => { - setReplyLinkToolId(toolId); - }, []); + 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; - }; + /** 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 ( +
+ No items to display +
+ ); + } - if (!items || items.length === 0) { return ( -
- No items to display +
+ {items.map((item, index) => { + let itemKey = ''; + let element: React.ReactNode = null; + + 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; + } + + 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} +
+ ); + })}
); } - - return ( -
- {items.map((item, index) => { - let itemKey = ''; - let element: React.ReactNode = null; - - 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; - } - - 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} -
- ); - })} -
- ); -}; +); 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/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/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/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index ab6b72a9..10477dc9 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'; @@ -156,238 +156,242 @@ const SessionRuntimeBadge = ({ ); }; -export const SessionItem = ({ - session, - isActive, - isPinned, - isHidden, - multiSelectActive, - isSelected, - onToggleSelect, -}: Readonly): React.JSX.Element => { - const { - openTab, - activeProjectId, - selectSession, - paneCount, - splitPane, - togglePinSession, - toggleHideSession, - } = useStore( - useShallow((s) => ({ - openTab: s.openTab, - activeProjectId: s.activeProjectId, - selectSession: s.selectSession, - paneCount: s.paneLayout.panes.length, - splitPane: s.splitPane, - togglePinSession: s.togglePinSession, - toggleHideSession: s.toggleHideSession, - })) - ); - - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - - const handleClick = (event: React.MouseEvent): void => { - if (!activeProjectId) return; - - // In multi-select mode, clicks toggle selection - if (multiSelectActive && onToggleSelect) { - onToggleSelect(); - return; - } - - // Cmd/Ctrl+click: open in new tab; plain click: replace current tab - const forceNewTab = event.ctrlKey || event.metaKey; - - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: formatSessionLabel(session.firstMessage), - }, - forceNewTab ? { forceNewTab } : { replaceActiveTab: true } +export const SessionItem = memo( + ({ + session, + isActive, + isPinned, + isHidden, + multiSelectActive, + isSelected, + onToggleSelect, + }: Readonly): React.JSX.Element => { + const { + openTab, + activeProjectId, + selectSession, + paneCount, + splitPane, + togglePinSession, + toggleHideSession, + } = useStore( + useShallow((s) => ({ + openTab: s.openTab, + activeProjectId: s.activeProjectId, + selectSession: s.selectSession, + paneCount: s.paneLayout.panes.length, + splitPane: s.splitPane, + togglePinSession: s.togglePinSession, + toggleHideSession: s.toggleHideSession, + })) ); - selectSession(session.id); - }; + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }, []); + const handleClick = (event: React.MouseEvent): void => { + if (!activeProjectId) return; - const sessionLabel = formatSessionLabel(session.firstMessage); + // In multi-select mode, clicks toggle selection + if (multiSelectActive && onToggleSelect) { + onToggleSelect(); + return; + } - const handleOpenInCurrentPane = useCallback(() => { - if (!activeProjectId) return; - openTab( - { + // Cmd/Ctrl+click: open in new tab; plain click: replace current tab + const forceNewTab = event.ctrlKey || event.metaKey; + + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: formatSessionLabel(session.firstMessage), + }, + forceNewTab ? { forceNewTab } : { replaceActiveTab: true } + ); + + selectSession(session.id); + }; + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); + + const sessionLabel = formatSessionLabel(session.firstMessage); + + const handleOpenInCurrentPane = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { replaceActiveTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleOpenInNewTab = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { forceNewTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleSplitRightAndOpen = useCallback(() => { + if (!activeProjectId) return; + // First open the tab in the focused pane + openTab({ type: 'session', sessionId: session.id, projectId: activeProjectId, label: sessionLabel, - }, - { replaceActiveTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + }); + selectSession(session.id); + // Then split it to the right + const state = useStore.getState(); + const focusedPaneId = state.paneLayout.focusedPaneId; + const activeTabId = state.activeTabId; + if (activeTabId) { + splitPane(focusedPaneId, activeTabId, 'right'); + } + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - const handleOpenInNewTab = useCallback(() => { - if (!activeProjectId) return; - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }, - { forceNewTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); - - const handleSplitRightAndOpen = useCallback(() => { - if (!activeProjectId) return; - // First open the tab in the focused pane - openTab({ - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }); - selectSession(session.id); - // Then split it to the right - const state = useStore.getState(); - const focusedPaneId = state.paneLayout.focusedPaneId; - const activeTabId = state.activeTabId; - if (activeTabId) { - splitPane(focusedPaneId, activeTabId, 'right'); - } - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - - // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll - return ( - <> - + )} + {session.isOngoing && } + {isPinned && } + {isHidden && } + {isTeam ? ( + + + {parsed.displayText} + + ) : ( + + {parsed.displayText} + + )} +
- {contextMenu && - activeProjectId && - createPortal( - setContextMenu(null)} - onOpenInCurrentPane={handleOpenInCurrentPane} - onOpenInNewTab={handleOpenInNewTab} - onSplitRightAndOpen={handleSplitRightAndOpen} - onTogglePin={() => void togglePinSession(session.id)} - onToggleHide={() => void toggleHideSession(session.id)} - />, - document.body - )} - - ); -}; + {/* Second line: metadata */} +
+ {isTeam && parsed.projectName && ( + <> + {parsed.projectName} + · + + )} + {isTeam && ( + <> + + {parsed.kind === 'team-resume' ? ( + + ) : ( + + )} + {parsed.kind === 'team-resume' ? 'resume' : 'new'} + + · + + )} + + + {session.messageCount} + + · + + {formatShortTime(new Date(session.createdAt))} + + {session.model && ( + <> + · + + + )} + {session.contextConsumption != null && session.contextConsumption > 0 && ( + <> + · + + + )} +
+ + ); + })()} + + + {contextMenu && + activeProjectId && + createPortal( + setContextMenu(null)} + onOpenInCurrentPane={handleOpenInCurrentPane} + onOpenInNewTab={handleOpenInNewTab} + onSplitRightAndOpen={handleSplitRightAndOpen} + onTogglePin={() => void togglePinSession(session.id)} + onToggleHide={() => void toggleHideSession(session.id)} + />, + document.body + )} + + ); + } +);