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:
parent
f764af17d8
commit
fa38b90f9c
6 changed files with 1385 additions and 1356 deletions
|
|
@ -87,345 +87,353 @@ function truncateText(text: string, maxLength: number): string {
|
||||||
*
|
*
|
||||||
* The list is completely flat with no nested toggles or hierarchies.
|
* The list is completely flat with no nested toggles or hierarchies.
|
||||||
*/
|
*/
|
||||||
export const DisplayItemList = ({
|
export const DisplayItemList = React.memo(
|
||||||
items,
|
({
|
||||||
onItemClick,
|
items,
|
||||||
expandedItemIds,
|
onItemClick,
|
||||||
aiGroupId,
|
expandedItemIds,
|
||||||
order = 'chronological',
|
aiGroupId,
|
||||||
searchQueryOverride,
|
order = 'chronological',
|
||||||
highlightToolUseId,
|
searchQueryOverride,
|
||||||
highlightColor,
|
highlightToolUseId,
|
||||||
notificationColorMap,
|
highlightColor,
|
||||||
registerToolRef,
|
notificationColorMap,
|
||||||
previewMaxLength,
|
registerToolRef,
|
||||||
timestampFormat,
|
previewMaxLength,
|
||||||
showItemMetaTooltip = false,
|
timestampFormat,
|
||||||
}: Readonly<DisplayItemListProps>): React.JSX.Element => {
|
showItemMetaTooltip = false,
|
||||||
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
|
}: Readonly<DisplayItemListProps>): React.JSX.Element => {
|
||||||
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
|
// 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) => {
|
const handleReplyHover = useCallback((toolId: string | null) => {
|
||||||
setReplyLinkToolId(toolId);
|
setReplyLinkToolId(toolId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Check if an item is part of the currently highlighted reply link */
|
/** Check if an item is part of the currently highlighted reply link */
|
||||||
const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => {
|
const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => {
|
||||||
if (!replyLinkToolId) return false;
|
if (!replyLinkToolId) return false;
|
||||||
if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true;
|
if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true;
|
||||||
if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId)
|
if (
|
||||||
return true;
|
item.type === 'teammate_message' &&
|
||||||
return false;
|
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 (
|
return (
|
||||||
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
|
<div
|
||||||
No items to display
|
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>
|
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -46,234 +46,239 @@ interface ExecutionTraceProps {
|
||||||
// Execution Trace Component
|
// Execution Trace Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
export const ExecutionTrace: React.FC<ExecutionTraceProps> = React.memo(
|
||||||
items,
|
({
|
||||||
aiGroupId: _aiGroupId,
|
items,
|
||||||
highlightToolUseId,
|
aiGroupId: _aiGroupId,
|
||||||
highlightColor,
|
highlightToolUseId,
|
||||||
notificationColorMap,
|
highlightColor,
|
||||||
searchExpandedItemId,
|
notificationColorMap,
|
||||||
registerToolRef,
|
searchExpandedItemId,
|
||||||
}): React.JSX.Element => {
|
registerToolRef,
|
||||||
const [manualExpandedItemId, setManualExpandedItemId] = useState<string | null>(null);
|
}): React.JSX.Element => {
|
||||||
|
const [manualExpandedItemId, setManualExpandedItemId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Use searchExpandedItemId if set, otherwise use manually expanded item
|
// Use searchExpandedItemId if set, otherwise use manually expanded item
|
||||||
const expandedItemId = searchExpandedItemId ?? manualExpandedItemId;
|
const expandedItemId = searchExpandedItemId ?? manualExpandedItemId;
|
||||||
|
|
||||||
const handleItemClick = (itemId: string): void => {
|
const handleItemClick = (itemId: string): void => {
|
||||||
setManualExpandedItemId((prev) => (prev === itemId ? null : itemId));
|
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 (
|
return (
|
||||||
<div className="px-3 py-2 text-xs" style={{ color: CARD_ICON_MUTED }}>
|
<div className="space-y-1">
|
||||||
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 (
|
||||||
|
<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>
|
</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
|
|
@ -31,52 +31,54 @@ interface TextItemProps {
|
||||||
titleText?: string;
|
titleText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextItem: React.FC<TextItemProps> = ({
|
export const TextItem: React.FC<TextItemProps> = React.memo(
|
||||||
step,
|
({
|
||||||
preview,
|
step,
|
||||||
onClick,
|
preview,
|
||||||
isExpanded,
|
onClick,
|
||||||
timestamp,
|
isExpanded,
|
||||||
timestampFormat,
|
timestamp,
|
||||||
searchQueryOverride,
|
timestampFormat,
|
||||||
markdownItemId,
|
searchQueryOverride,
|
||||||
highlightClasses,
|
markdownItemId,
|
||||||
highlightStyle,
|
highlightClasses,
|
||||||
notificationDotColor,
|
highlightStyle,
|
||||||
titleText,
|
notificationDotColor,
|
||||||
}) => {
|
titleText,
|
||||||
const fullContent = step.content.outputText ?? preview;
|
}) => {
|
||||||
const summary = searchQueryOverride
|
const fullContent = step.content.outputText ?? preview;
|
||||||
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
const summary = searchQueryOverride
|
||||||
forceAllActive: true,
|
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
||||||
})
|
forceAllActive: true,
|
||||||
: preview;
|
})
|
||||||
|
: preview;
|
||||||
|
|
||||||
// Get token count from step.tokens.output or step.content.tokenCount
|
// Get token count from step.tokens.output or step.content.tokenCount
|
||||||
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseItem
|
<BaseItem
|
||||||
icon={<MessageSquare className="size-4" />}
|
icon={<MessageSquare className="size-4" />}
|
||||||
label="Output"
|
label="Output"
|
||||||
summary={summary}
|
summary={summary}
|
||||||
tokenCount={tokenCount}
|
tokenCount={tokenCount}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
timestampFormat={timestampFormat}
|
timestampFormat={timestampFormat}
|
||||||
titleText={titleText}
|
titleText={titleText}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
highlightClasses={highlightClasses}
|
highlightClasses={highlightClasses}
|
||||||
highlightStyle={highlightStyle}
|
highlightStyle={highlightStyle}
|
||||||
notificationDotColor={notificationDotColor}
|
notificationDotColor={notificationDotColor}
|
||||||
>
|
>
|
||||||
<MarkdownViewer
|
<MarkdownViewer
|
||||||
content={fullContent}
|
content={fullContent}
|
||||||
maxHeight="max-h-96"
|
maxHeight="max-h-96"
|
||||||
copyable
|
copyable
|
||||||
itemId={markdownItemId}
|
itemId={markdownItemId}
|
||||||
searchQueryOverride={searchQueryOverride}
|
searchQueryOverride={searchQueryOverride}
|
||||||
/>
|
/>
|
||||||
</BaseItem>
|
</BaseItem>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,52 +31,54 @@ interface ThinkingItemProps {
|
||||||
titleText?: string;
|
titleText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
export const ThinkingItem: React.FC<ThinkingItemProps> = React.memo(
|
||||||
step,
|
({
|
||||||
preview,
|
step,
|
||||||
onClick,
|
preview,
|
||||||
isExpanded,
|
onClick,
|
||||||
timestamp,
|
isExpanded,
|
||||||
timestampFormat,
|
timestamp,
|
||||||
searchQueryOverride,
|
timestampFormat,
|
||||||
markdownItemId,
|
searchQueryOverride,
|
||||||
highlightClasses,
|
markdownItemId,
|
||||||
highlightStyle,
|
highlightClasses,
|
||||||
notificationDotColor,
|
highlightStyle,
|
||||||
titleText,
|
notificationDotColor,
|
||||||
}) => {
|
titleText,
|
||||||
const fullContent = step.content.thinkingText ?? preview;
|
}) => {
|
||||||
const summary = searchQueryOverride
|
const fullContent = step.content.thinkingText ?? preview;
|
||||||
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
const summary = searchQueryOverride
|
||||||
forceAllActive: true,
|
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
||||||
})
|
forceAllActive: true,
|
||||||
: preview;
|
})
|
||||||
|
: preview;
|
||||||
|
|
||||||
// Get token count from step.tokens.output or step.content.tokenCount
|
// Get token count from step.tokens.output or step.content.tokenCount
|
||||||
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseItem
|
<BaseItem
|
||||||
icon={<Brain className="size-4" />}
|
icon={<Brain className="size-4" />}
|
||||||
label="Thinking"
|
label="Thinking"
|
||||||
summary={summary}
|
summary={summary}
|
||||||
tokenCount={tokenCount}
|
tokenCount={tokenCount}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
timestampFormat={timestampFormat}
|
timestampFormat={timestampFormat}
|
||||||
titleText={titleText}
|
titleText={titleText}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
highlightClasses={highlightClasses}
|
highlightClasses={highlightClasses}
|
||||||
highlightStyle={highlightStyle}
|
highlightStyle={highlightStyle}
|
||||||
notificationDotColor={notificationDotColor}
|
notificationDotColor={notificationDotColor}
|
||||||
>
|
>
|
||||||
<MarkdownViewer
|
<MarkdownViewer
|
||||||
content={fullContent}
|
content={fullContent}
|
||||||
maxHeight="max-h-96"
|
maxHeight="max-h-96"
|
||||||
copyable
|
copyable
|
||||||
itemId={markdownItemId}
|
itemId={markdownItemId}
|
||||||
searchQueryOverride={searchQueryOverride}
|
searchQueryOverride={searchQueryOverride}
|
||||||
/>
|
/>
|
||||||
</BaseItem>
|
</BaseItem>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Supports right-click context menu for pane management.
|
* 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 { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
|
|
@ -156,238 +156,242 @@ const SessionRuntimeBadge = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SessionItem = ({
|
export const SessionItem = memo(
|
||||||
session,
|
({
|
||||||
isActive,
|
session,
|
||||||
isPinned,
|
isActive,
|
||||||
isHidden,
|
isPinned,
|
||||||
multiSelectActive,
|
isHidden,
|
||||||
isSelected,
|
multiSelectActive,
|
||||||
onToggleSelect,
|
isSelected,
|
||||||
}: Readonly<SessionItemProps>): React.JSX.Element => {
|
onToggleSelect,
|
||||||
const {
|
}: Readonly<SessionItemProps>): React.JSX.Element => {
|
||||||
openTab,
|
const {
|
||||||
activeProjectId,
|
openTab,
|
||||||
selectSession,
|
activeProjectId,
|
||||||
paneCount,
|
selectSession,
|
||||||
splitPane,
|
paneCount,
|
||||||
togglePinSession,
|
splitPane,
|
||||||
toggleHideSession,
|
togglePinSession,
|
||||||
} = useStore(
|
toggleHideSession,
|
||||||
useShallow((s) => ({
|
} = useStore(
|
||||||
openTab: s.openTab,
|
useShallow((s) => ({
|
||||||
activeProjectId: s.activeProjectId,
|
openTab: s.openTab,
|
||||||
selectSession: s.selectSession,
|
activeProjectId: s.activeProjectId,
|
||||||
paneCount: s.paneLayout.panes.length,
|
selectSession: s.selectSession,
|
||||||
splitPane: s.splitPane,
|
paneCount: s.paneLayout.panes.length,
|
||||||
togglePinSession: s.togglePinSession,
|
splitPane: s.splitPane,
|
||||||
toggleHideSession: s.toggleHideSession,
|
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 }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
selectSession(session.id);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
};
|
|
||||||
|
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
const handleClick = (event: React.MouseEvent): void => {
|
||||||
e.preventDefault();
|
if (!activeProjectId) return;
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sessionLabel = formatSessionLabel(session.firstMessage);
|
// In multi-select mode, clicks toggle selection
|
||||||
|
if (multiSelectActive && onToggleSelect) {
|
||||||
|
onToggleSelect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpenInCurrentPane = useCallback(() => {
|
// Cmd/Ctrl+click: open in new tab; plain click: replace current tab
|
||||||
if (!activeProjectId) return;
|
const forceNewTab = event.ctrlKey || event.metaKey;
|
||||||
openTab(
|
|
||||||
{
|
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',
|
type: 'session',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
projectId: activeProjectId,
|
projectId: activeProjectId,
|
||||||
label: sessionLabel,
|
label: sessionLabel,
|
||||||
},
|
});
|
||||||
{ replaceActiveTab: true }
|
selectSession(session.id);
|
||||||
);
|
// Then split it to the right
|
||||||
selectSession(session.id);
|
const state = useStore.getState();
|
||||||
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel]);
|
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(() => {
|
// Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll
|
||||||
if (!activeProjectId) return;
|
return (
|
||||||
openTab(
|
<>
|
||||||
{
|
<button
|
||||||
type: 'session',
|
onClick={handleClick}
|
||||||
sessionId: session.id,
|
onContextMenu={handleContextMenu}
|
||||||
projectId: activeProjectId,
|
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'}`}
|
||||||
label: sessionLabel,
|
style={{
|
||||||
},
|
borderColor: 'var(--color-border)',
|
||||||
{ forceNewTab: true }
|
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
|
||||||
);
|
...(isHidden ? { opacity: 0.5 } : {}),
|
||||||
selectSession(session.id);
|
}}
|
||||||
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel]);
|
>
|
||||||
|
{(() => {
|
||||||
const handleSplitRightAndOpen = useCallback(() => {
|
const parsed = parseSessionTitle(session.firstMessage);
|
||||||
if (!activeProjectId) return;
|
const isTeam = parsed.kind !== 'regular';
|
||||||
// First open the tab in the focused pane
|
return (
|
||||||
openTab({
|
<>
|
||||||
type: 'session',
|
{/* First line: title + ongoing indicator + pin/hidden icons */}
|
||||||
sessionId: session.id,
|
<div className="flex items-center gap-1.5">
|
||||||
projectId: activeProjectId,
|
{multiSelectActive && (
|
||||||
label: sessionLabel,
|
<input
|
||||||
});
|
type="checkbox"
|
||||||
selectSession(session.id);
|
checked={isSelected ?? false}
|
||||||
// Then split it to the right
|
onChange={() => onToggleSelect?.()}
|
||||||
const state = useStore.getState();
|
onClick={(e) => e.stopPropagation()}
|
||||||
const focusedPaneId = state.paneLayout.focusedPaneId;
|
className="size-3.5 shrink-0 accent-blue-500"
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
)}
|
{session.isOngoing && <OngoingIndicator />}
|
||||||
</div>
|
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
|
||||||
</>
|
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
|
||||||
);
|
{isTeam ? (
|
||||||
})()}
|
<span
|
||||||
</button>
|
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 &&
|
{/* Second line: metadata */}
|
||||||
activeProjectId &&
|
<div
|
||||||
createPortal(
|
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
|
||||||
<SessionContextMenu
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
x={contextMenu.x}
|
>
|
||||||
y={contextMenu.y}
|
{isTeam && parsed.projectName && (
|
||||||
sessionId={session.id}
|
<>
|
||||||
projectId={activeProjectId}
|
<span className="truncate">{parsed.projectName}</span>
|
||||||
sessionLabel={sessionLabel}
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
paneCount={paneCount}
|
</>
|
||||||
isPinned={isPinned ?? false}
|
)}
|
||||||
isHidden={isHidden ?? false}
|
{isTeam && (
|
||||||
onClose={() => setContextMenu(null)}
|
<>
|
||||||
onOpenInCurrentPane={handleOpenInCurrentPane}
|
<span className="flex shrink-0 items-center gap-0.5">
|
||||||
onOpenInNewTab={handleOpenInNewTab}
|
{parsed.kind === 'team-resume' ? (
|
||||||
onSplitRightAndOpen={handleSplitRightAndOpen}
|
<RotateCw className="size-2.5" />
|
||||||
onTogglePin={() => void togglePinSession(session.id)}
|
) : (
|
||||||
onToggleHide={() => void toggleHideSession(session.id)}
|
<Play className="size-2.5" />
|
||||||
/>,
|
)}
|
||||||
document.body
|
{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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue