perf(renderer): memoize chat and sidebar list item components

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.
This commit is contained in:
Mike 2026-05-02 20:49:16 +05:00
parent f764af17d8
commit fa38b90f9c
6 changed files with 1385 additions and 1356 deletions

View file

@ -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<DisplayItemListProps>): React.JSX.Element => {
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
export const DisplayItemList = React.memo(
({
items,
onItemClick,
expandedItemIds,
aiGroupId,
order = 'chronological',
searchQueryOverride,
highlightToolUseId,
highlightColor,
notificationColorMap,
registerToolRef,
previewMaxLength,
timestampFormat,
showItemMetaTooltip = false,
}: Readonly<DisplayItemListProps>): React.JSX.Element => {
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(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 (
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
No items to display
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
No items to display
<div
className={
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
}
>
{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 = (
<ThinkingItem
step={thinkingStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={() => 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 = (
<TextItem
step={textStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={() => 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 = (
<LinkedToolItem
linkedTool={item.tool}
onClick={() => 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 = (
<SubagentItem
step={subagentStep}
subagent={item.subagent}
onClick={() => 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 = (
<SlashItem
slash={item.slash}
onClick={() => 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 = (
<TeammateMessageItem
teammateMessage={item.teammateMessage}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onReplyHover={handleReplyHover}
/>
);
break;
}
case 'subagent_input': {
itemKey = `input-${index}`;
const inputContent = item.content;
const inputTokenCount = item.tokenCount;
element = (
<BaseItem
icon={<MailOpen className="size-4" />}
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)}
>
<MarkdownViewer
content={inputContent}
copyable
itemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
break;
}
case 'compact_boundary': {
itemKey = `compact-${index}`;
const compactContent = item.content;
const compactExpanded = expandedItemIds.has(itemKey);
element = (
<div>
<button
onClick={() => onItemClick(itemKey)}
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
aria-expanded={compactExpanded}
>
<div
className="flex shrink-0 items-center gap-1.5"
style={{ color: TOOL_CALL_TEXT }}
>
<ChevronRight
size={14}
className={`transition-transform duration-200 ${compactExpanded ? 'rotate-90' : ''}`}
/>
<Layers size={14} />
</div>
<span
className="shrink-0 text-xs font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
Compacted
</span>
{item.tokenDelta && (
<span
className="min-w-0 truncate text-[11px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} {' '}
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
<span style={{ color: '#4ade80' }}>
{' '}
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
</span>
</span>
)}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: 'rgba(99, 102, 241, 0.15)',
color: '#818cf8',
}}
>
Phase {item.phaseNumber}
</span>
<span
className="ml-auto shrink-0 text-[11px]"
style={{ color: COLOR_TEXT_MUTED }}
>
{format(new Date(item.timestamp), 'h:mm:ss a')}
</span>
</button>
{compactExpanded && compactContent && (
<div
className="mt-1 overflow-hidden rounded-lg"
style={{
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}}
>
<div
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
style={{ borderColor: 'var(--chat-ai-border)' }}
>
<MarkdownViewer content={compactContent} copyable />
</div>
</div>
)}
</div>
);
break;
}
default:
return null;
}
// Apply reply-link spotlight: dim items not in the highlighted pair
const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item);
return (
<div
key={itemKey}
style={
replyLinkToolId !== null
? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' }
: undefined
}
>
{element}
</div>
);
})}
</div>
);
}
return (
<div
className={
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
}
>
{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 = (
<ThinkingItem
step={thinkingStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={() => 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 = (
<TextItem
step={textStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={() => 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 = (
<LinkedToolItem
linkedTool={item.tool}
onClick={() => 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 = (
<SubagentItem
step={subagentStep}
subagent={item.subagent}
onClick={() => 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 = (
<SlashItem
slash={item.slash}
onClick={() => 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 = (
<TeammateMessageItem
teammateMessage={item.teammateMessage}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onReplyHover={handleReplyHover}
/>
);
break;
}
case 'subagent_input': {
itemKey = `input-${index}`;
const inputContent = item.content;
const inputTokenCount = item.tokenCount;
element = (
<BaseItem
icon={<MailOpen className="size-4" />}
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)}
>
<MarkdownViewer
content={inputContent}
copyable
itemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
break;
}
case 'compact_boundary': {
itemKey = `compact-${index}`;
const compactContent = item.content;
const compactExpanded = expandedItemIds.has(itemKey);
element = (
<div>
<button
onClick={() => onItemClick(itemKey)}
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
aria-expanded={compactExpanded}
>
<div
className="flex shrink-0 items-center gap-1.5"
style={{ color: TOOL_CALL_TEXT }}
>
<ChevronRight
size={14}
className={`transition-transform duration-200 ${compactExpanded ? 'rotate-90' : ''}`}
/>
<Layers size={14} />
</div>
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
Compacted
</span>
{item.tokenDelta && (
<span
className="min-w-0 truncate text-[11px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} {' '}
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
<span style={{ color: '#4ade80' }}>
{' '}
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
</span>
</span>
)}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: 'rgba(99, 102, 241, 0.15)',
color: '#818cf8',
}}
>
Phase {item.phaseNumber}
</span>
<span
className="ml-auto shrink-0 text-[11px]"
style={{ color: COLOR_TEXT_MUTED }}
>
{format(new Date(item.timestamp), 'h:mm:ss a')}
</span>
</button>
{compactExpanded && compactContent && (
<div
className="mt-1 overflow-hidden rounded-lg"
style={{
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}}
>
<div
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
style={{ borderColor: 'var(--chat-ai-border)' }}
>
<MarkdownViewer content={compactContent} copyable />
</div>
</div>
)}
</div>
);
break;
}
default:
return null;
}
// Apply reply-link spotlight: dim items not in the highlighted pair
const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item);
return (
<div
key={itemKey}
style={
replyLinkToolId !== null
? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' }
: undefined
}
>
{element}
</div>
);
})}
</div>
);
};
);

View file

@ -46,234 +46,239 @@ interface ExecutionTraceProps {
// Execution Trace Component
// =============================================================================
export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
items,
aiGroupId: _aiGroupId,
highlightToolUseId,
highlightColor,
notificationColorMap,
searchExpandedItemId,
registerToolRef,
}): React.JSX.Element => {
const [manualExpandedItemId, setManualExpandedItemId] = useState<string | null>(null);
export const ExecutionTrace: React.FC<ExecutionTraceProps> = React.memo(
({
items,
aiGroupId: _aiGroupId,
highlightToolUseId,
highlightColor,
notificationColorMap,
searchExpandedItemId,
registerToolRef,
}): React.JSX.Element => {
const [manualExpandedItemId, setManualExpandedItemId] = useState<string | null>(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 (
<div className="px-3 py-2 text-xs" style={{ color: CARD_ICON_MUTED }}>
No execution items
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="px-3 py-2 text-xs" style={{ color: CARD_ICON_MUTED }}>
No execution items
<div className="space-y-1">
{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 (
<ThinkingItem
key={itemId}
step={thinkingStep}
preview={preview}
onClick={() => 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 (
<TextItem
key={itemId}
step={textStep}
preview={preview}
onClick={() => 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 (
<LinkedToolItem
key={itemId}
linkedTool={item.tool}
onClick={() => 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 (
<div
key={`nested-subagent-${index}`}
className="px-2 py-1 text-xs"
style={{ color: CARD_ICON_MUTED }}
>
Nested: {item.subagent.description ?? item.subagent.id}
</div>
);
case 'subagent_input': {
const itemId = `subagent-input-${index}`;
const isExpanded = expandedItemId === itemId;
return (
<BaseItem
key={itemId}
icon={<MailOpen className="size-4" />}
label="Input"
summary={truncateText(item.content, 80)}
tokenCount={item.tokenCount}
timestamp={item.timestamp}
onClick={() => handleItemClick(itemId)}
isExpanded={isExpanded}
>
<MarkdownViewer content={item.content} copyable />
</BaseItem>
);
}
case 'teammate_message': {
const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`;
const isExpanded = expandedItemId === itemId;
return (
<TeammateMessageItem
key={itemId}
teammateMessage={item.teammateMessage}
onClick={() => handleItemClick(itemId)}
isExpanded={isExpanded}
/>
);
}
case 'compact_boundary': {
const itemId = `subagent-compact-${index}`;
const isExpanded = expandedItemId === itemId;
return (
<div key={itemId}>
{/* Header — matches CompactBoundary.tsx amber styling */}
<button
onClick={() => handleItemClick(itemId)}
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
aria-expanded={isExpanded}
>
<div
className="flex shrink-0 items-center gap-1.5"
style={{ color: TOOL_CALL_TEXT }}
>
<ChevronRight
size={14}
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
<Layers size={14} />
</div>
<span
className="shrink-0 text-xs font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
Compacted
</span>
{item.tokenDelta && (
<span
className="min-w-0 truncate text-[11px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} {' '}
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
<span style={{ color: '#4ade80' }}>
{' '}
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
</span>
</span>
)}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: 'rgba(99, 102, 241, 0.15)',
color: '#818cf8',
}}
>
Phase {item.phaseNumber}
</span>
<span
className="ml-auto shrink-0 text-[11px]"
style={{ color: COLOR_TEXT_MUTED }}
>
{format(new Date(item.timestamp), 'h:mm:ss a')}
</span>
</button>
{/* Expanded content */}
{isExpanded && item.content && (
<div
className="mt-1 overflow-hidden rounded-lg"
style={{
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}}
>
<div
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
style={{ borderColor: 'var(--chat-ai-border)' }}
>
<MarkdownViewer content={item.content} copyable />
</div>
</div>
)}
</div>
);
}
default:
return null;
}
})}
</div>
);
}
return (
<div className="space-y-1">
{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 (
<ThinkingItem
key={itemId}
step={thinkingStep}
preview={preview}
onClick={() => 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 (
<TextItem
key={itemId}
step={textStep}
preview={preview}
onClick={() => 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 (
<LinkedToolItem
key={itemId}
linkedTool={item.tool}
onClick={() => 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 (
<div
key={`nested-subagent-${index}`}
className="px-2 py-1 text-xs"
style={{ color: CARD_ICON_MUTED }}
>
Nested: {item.subagent.description ?? item.subagent.id}
</div>
);
case 'subagent_input': {
const itemId = `subagent-input-${index}`;
const isExpanded = expandedItemId === itemId;
return (
<BaseItem
key={itemId}
icon={<MailOpen className="size-4" />}
label="Input"
summary={truncateText(item.content, 80)}
tokenCount={item.tokenCount}
timestamp={item.timestamp}
onClick={() => handleItemClick(itemId)}
isExpanded={isExpanded}
>
<MarkdownViewer content={item.content} copyable />
</BaseItem>
);
}
case 'teammate_message': {
const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`;
const isExpanded = expandedItemId === itemId;
return (
<TeammateMessageItem
key={itemId}
teammateMessage={item.teammateMessage}
onClick={() => handleItemClick(itemId)}
isExpanded={isExpanded}
/>
);
}
case 'compact_boundary': {
const itemId = `subagent-compact-${index}`;
const isExpanded = expandedItemId === itemId;
return (
<div key={itemId}>
{/* Header — matches CompactBoundary.tsx amber styling */}
<button
onClick={() => handleItemClick(itemId)}
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
aria-expanded={isExpanded}
>
<div
className="flex shrink-0 items-center gap-1.5"
style={{ color: TOOL_CALL_TEXT }}
>
<ChevronRight
size={14}
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
<Layers size={14} />
</div>
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
Compacted
</span>
{item.tokenDelta && (
<span
className="min-w-0 truncate text-[11px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} {' '}
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
<span style={{ color: '#4ade80' }}>
{' '}
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
</span>
</span>
)}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: 'rgba(99, 102, 241, 0.15)',
color: '#818cf8',
}}
>
Phase {item.phaseNumber}
</span>
<span
className="ml-auto shrink-0 text-[11px]"
style={{ color: COLOR_TEXT_MUTED }}
>
{format(new Date(item.timestamp), 'h:mm:ss a')}
</span>
</button>
{/* Expanded content */}
{isExpanded && item.content && (
<div
className="mt-1 overflow-hidden rounded-lg"
style={{
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}}
>
<div
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
style={{ borderColor: 'var(--chat-ai-border)' }}
>
<MarkdownViewer content={item.content} copyable />
</div>
</div>
)}
</div>
);
}
default:
return null;
}
})}
</div>
);
};
);

File diff suppressed because it is too large Load diff

View file

@ -31,52 +31,54 @@ interface TextItemProps {
titleText?: string;
}
export const TextItem: React.FC<TextItemProps> = ({
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<TextItemProps> = 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 (
<BaseItem
icon={<MessageSquare className="size-4" />}
label="Output"
summary={summary}
tokenCount={tokenCount}
timestamp={timestamp}
timestampFormat={timestampFormat}
titleText={titleText}
onClick={onClick}
isExpanded={isExpanded}
highlightClasses={highlightClasses}
highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor}
>
<MarkdownViewer
content={fullContent}
maxHeight="max-h-96"
copyable
itemId={markdownItemId}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
};
return (
<BaseItem
icon={<MessageSquare className="size-4" />}
label="Output"
summary={summary}
tokenCount={tokenCount}
timestamp={timestamp}
timestampFormat={timestampFormat}
titleText={titleText}
onClick={onClick}
isExpanded={isExpanded}
highlightClasses={highlightClasses}
highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor}
>
<MarkdownViewer
content={fullContent}
maxHeight="max-h-96"
copyable
itemId={markdownItemId}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
}
);

View file

@ -31,52 +31,54 @@ interface ThinkingItemProps {
titleText?: string;
}
export const ThinkingItem: React.FC<ThinkingItemProps> = ({
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<ThinkingItemProps> = 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 (
<BaseItem
icon={<Brain className="size-4" />}
label="Thinking"
summary={summary}
tokenCount={tokenCount}
timestamp={timestamp}
timestampFormat={timestampFormat}
titleText={titleText}
onClick={onClick}
isExpanded={isExpanded}
highlightClasses={highlightClasses}
highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor}
>
<MarkdownViewer
content={fullContent}
maxHeight="max-h-96"
copyable
itemId={markdownItemId}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
};
return (
<BaseItem
icon={<Brain className="size-4" />}
label="Thinking"
summary={summary}
tokenCount={tokenCount}
timestamp={timestamp}
timestampFormat={timestampFormat}
titleText={titleText}
onClick={onClick}
isExpanded={isExpanded}
highlightClasses={highlightClasses}
highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor}
>
<MarkdownViewer
content={fullContent}
maxHeight="max-h-96"
copyable
itemId={markdownItemId}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
}
);

View file

@ -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<SessionItemProps>): 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<SessionItemProps>): 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 (
<>
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`flex h-[54px] w-full flex-col justify-center overflow-hidden border-b px-2 py-1.5 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
style={{
borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
...(isHidden ? { opacity: 0.5 } : {}),
}}
>
{(() => {
const parsed = parseSessionTitle(session.firstMessage);
const isTeam = parsed.kind !== 'regular';
return (
<>
{/* First line: title + ongoing indicator + pin/hidden icons */}
<div className="flex items-center gap-1.5">
{multiSelectActive && (
<input
type="checkbox"
checked={isSelected ?? false}
onChange={() => onToggleSelect?.()}
onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-blue-500"
/>
)}
{session.isOngoing && <OngoingIndicator />}
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
{isTeam ? (
<span
className="flex items-center gap-1.5 truncate text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
<Users className="size-3 shrink-0 text-blue-400" />
<span className="truncate">{parsed.displayText}</span>
</span>
) : (
<span
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{parsed.displayText}
</span>
)}
</div>
{/* Second line: metadata */}
<div
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{isTeam && parsed.projectName && (
<>
<span className="truncate">{parsed.projectName}</span>
<span style={{ opacity: 0.5 }}>·</span>
</>
)}
{isTeam && (
<>
<span className="flex shrink-0 items-center gap-0.5">
{parsed.kind === 'team-resume' ? (
<RotateCw className="size-2.5" />
) : (
<Play className="size-2.5" />
)}
{parsed.kind === 'team-resume' ? 'resume' : 'new'}
</span>
<span style={{ opacity: 0.5 }}>·</span>
</>
)}
<span className="flex shrink-0 items-center gap-0.5">
<MessageSquare className="size-2.5" />
{session.messageCount}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
{session.model && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<SessionRuntimeBadge model={session.model} />
</>
)}
{session.contextConsumption != null && session.contextConsumption > 0 && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<ConsumptionBadge
contextConsumption={session.contextConsumption}
phaseBreakdown={session.phaseBreakdown}
// Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll
return (
<>
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`flex h-[54px] w-full flex-col justify-center overflow-hidden border-b px-2 py-1.5 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
style={{
borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
...(isHidden ? { opacity: 0.5 } : {}),
}}
>
{(() => {
const parsed = parseSessionTitle(session.firstMessage);
const isTeam = parsed.kind !== 'regular';
return (
<>
{/* First line: title + ongoing indicator + pin/hidden icons */}
<div className="flex items-center gap-1.5">
{multiSelectActive && (
<input
type="checkbox"
checked={isSelected ?? false}
onChange={() => onToggleSelect?.()}
onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-blue-500"
/>
</>
)}
</div>
</>
);
})()}
</button>
)}
{session.isOngoing && <OngoingIndicator />}
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
{isTeam ? (
<span
className="flex items-center gap-1.5 truncate text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
<Users className="size-3 shrink-0 text-blue-400" />
<span className="truncate">{parsed.displayText}</span>
</span>
) : (
<span
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{parsed.displayText}
</span>
)}
</div>
{contextMenu &&
activeProjectId &&
createPortal(
<SessionContextMenu
x={contextMenu.x}
y={contextMenu.y}
sessionId={session.id}
projectId={activeProjectId}
sessionLabel={sessionLabel}
paneCount={paneCount}
isPinned={isPinned ?? false}
isHidden={isHidden ?? false}
onClose={() => setContextMenu(null)}
onOpenInCurrentPane={handleOpenInCurrentPane}
onOpenInNewTab={handleOpenInNewTab}
onSplitRightAndOpen={handleSplitRightAndOpen}
onTogglePin={() => void togglePinSession(session.id)}
onToggleHide={() => void toggleHideSession(session.id)}
/>,
document.body
)}
</>
);
};
{/* Second line: metadata */}
<div
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{isTeam && parsed.projectName && (
<>
<span className="truncate">{parsed.projectName}</span>
<span style={{ opacity: 0.5 }}>·</span>
</>
)}
{isTeam && (
<>
<span className="flex shrink-0 items-center gap-0.5">
{parsed.kind === 'team-resume' ? (
<RotateCw className="size-2.5" />
) : (
<Play className="size-2.5" />
)}
{parsed.kind === 'team-resume' ? 'resume' : 'new'}
</span>
<span style={{ opacity: 0.5 }}>·</span>
</>
)}
<span className="flex shrink-0 items-center gap-0.5">
<MessageSquare className="size-2.5" />
{session.messageCount}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">
{formatShortTime(new Date(session.createdAt))}
</span>
{session.model && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<SessionRuntimeBadge model={session.model} />
</>
)}
{session.contextConsumption != null && session.contextConsumption > 0 && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<ConsumptionBadge
contextConsumption={session.contextConsumption}
phaseBreakdown={session.phaseBreakdown}
/>
</>
)}
</div>
</>
);
})()}
</button>
{contextMenu &&
activeProjectId &&
createPortal(
<SessionContextMenu
x={contextMenu.x}
y={contextMenu.y}
sessionId={session.id}
projectId={activeProjectId}
sessionLabel={sessionLabel}
paneCount={paneCount}
isPinned={isPinned ?? false}
isHidden={isHidden ?? false}
onClose={() => setContextMenu(null)}
onOpenInCurrentPane={handleOpenInCurrentPane}
onOpenInNewTab={handleOpenInNewTab}
onSplitRightAndOpen={handleSplitRightAndOpen}
onTogglePin={() => void togglePinSession(session.id)}
onToggleHide={() => void toggleHideSession(session.id)}
/>,
document.body
)}
</>
);
}
);