perf: memo chat items, tool viewers, CollapsibleTeamSection, TaskTooltip
Wraps BaseItem, StatusDot, MetricsPill, LinkedToolItem, TeammateMessageItem, SlashItem, all linkedTool viewers (Default/Edit/Read/Skill/Write/ToolError, CollapsibleOutputSection), CollapsibleTeamSection, and TaskTooltip in React.memo to prevent unnecessary re-renders when chat history updates.
This commit is contained in:
parent
193def1d59
commit
5d6667b23d
14 changed files with 749 additions and 719 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables';
|
import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors';
|
import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors';
|
||||||
|
|
@ -57,14 +57,14 @@ interface BaseItemProps {
|
||||||
/**
|
/**
|
||||||
* Small status dot indicator.
|
* Small status dot indicator.
|
||||||
*/
|
*/
|
||||||
export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => {
|
export const StatusDot = memo(function StatusDot({ status }: { status: ItemStatus }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="base-item-status-dot inline-block size-1.5 shrink-0 rounded-full"
|
className="base-item-status-dot inline-block size-1.5 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: getStatusDotColor(status) }}
|
style={{ backgroundColor: getStatusDotColor(status) }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Main Component
|
// Main Component
|
||||||
|
|
@ -79,135 +79,140 @@ export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => {
|
||||||
*
|
*
|
||||||
* Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem
|
* Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem
|
||||||
*/
|
*/
|
||||||
export const BaseItem: React.FC<BaseItemProps> = ({
|
export const BaseItem = memo(
|
||||||
icon,
|
({
|
||||||
label,
|
icon,
|
||||||
summary,
|
label,
|
||||||
tokenCount,
|
summary,
|
||||||
tokenLabel = 'tokens',
|
tokenCount,
|
||||||
status,
|
tokenLabel = 'tokens',
|
||||||
durationMs,
|
status,
|
||||||
timestamp,
|
durationMs,
|
||||||
timestampFormat = 'HH:mm:ss',
|
timestamp,
|
||||||
titleText,
|
timestampFormat = 'HH:mm:ss',
|
||||||
onClick,
|
titleText,
|
||||||
isExpanded,
|
onClick,
|
||||||
hasExpandableContent = true,
|
isExpanded,
|
||||||
highlightClasses = '',
|
hasExpandableContent = true,
|
||||||
highlightStyle,
|
highlightClasses = '',
|
||||||
notificationDotColor,
|
highlightStyle,
|
||||||
children,
|
notificationDotColor,
|
||||||
}) => {
|
children,
|
||||||
return (
|
}: BaseItemProps): React.JSX.Element => {
|
||||||
<div
|
return (
|
||||||
className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
|
||||||
style={highlightStyle}
|
|
||||||
>
|
|
||||||
{/* Clickable Header */}
|
|
||||||
<div
|
<div
|
||||||
role="button"
|
className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
||||||
tabIndex={0}
|
style={highlightStyle}
|
||||||
title={titleText}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
|
||||||
style={{ backgroundColor: 'transparent' }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
Object.assign(e.currentTarget.style, { backgroundColor: 'var(--tool-item-hover-bg)' })
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Clickable Header */}
|
||||||
<span className="size-4 shrink-0" style={{ color: TOOL_ITEM_MUTED }}>
|
<div
|
||||||
{icon}
|
role="button"
|
||||||
</span>
|
tabIndex={0}
|
||||||
|
title={titleText}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
||||||
|
style={{ backgroundColor: 'transparent' }}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
Object.assign(e.currentTarget.style, { backgroundColor: 'var(--tool-item-hover-bg)' })
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<span className="size-4 shrink-0" style={{ color: TOOL_ITEM_MUTED }}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--tool-item-name)' }}>
|
<span className="text-sm font-medium" style={{ color: 'var(--tool-item-name)' }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Separator and Summary */}
|
{/* Separator and Summary */}
|
||||||
{summary && (
|
{summary && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm" style={{ color: TOOL_ITEM_MUTED }}>
|
<span className="text-sm" style={{ color: TOOL_ITEM_MUTED }}>
|
||||||
-
|
-
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="flex-1 truncate text-sm"
|
||||||
|
style={{ color: 'var(--tool-item-summary)' }}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spacer if no summary */}
|
||||||
|
{!summary && <span className="flex-1" />}
|
||||||
|
|
||||||
|
{/* Token count badge */}
|
||||||
|
{tokenCount != null && tokenCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="base-item-tokens shrink-0 rounded px-1.5 py-0.5 text-xs"
|
||||||
|
style={{
|
||||||
|
color: TOOL_ITEM_MUTED,
|
||||||
|
backgroundColor: 'var(--tool-item-badge-bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
~{formatTokens(tokenCount)} {tokenLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-sm" style={{ color: 'var(--tool-item-summary)' }}>
|
)}
|
||||||
{summary}
|
|
||||||
|
{/* Status indicator - hidden when notification dot replaces it */}
|
||||||
|
{status && !notificationDotColor && <StatusDot status={status} />}
|
||||||
|
|
||||||
|
{/* Notification dot (replaces status dot when present) */}
|
||||||
|
{notificationDotColor && (
|
||||||
|
<span
|
||||||
|
className="base-item-notification-dot inline-block size-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: getTriggerColorDef(notificationDotColor).hex }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
{durationMs !== undefined && (
|
||||||
|
<span className="shrink-0 text-xs" style={{ color: TOOL_ITEM_MUTED }}>
|
||||||
|
{formatDuration(durationMs)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spacer if no summary */}
|
{/* Timestamp — rightmost info element */}
|
||||||
{!summary && <span className="flex-1" />}
|
{timestamp && (
|
||||||
|
<span
|
||||||
|
className="base-item-timestamp shrink-0 text-[11px] tabular-nums"
|
||||||
|
style={{ color: TOOL_ITEM_MUTED }}
|
||||||
|
>
|
||||||
|
{format(timestamp, timestampFormat)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Token count badge */}
|
{/* Expand/collapse chevron */}
|
||||||
{tokenCount != null && tokenCount > 0 && (
|
{hasExpandableContent && (
|
||||||
<span
|
<ChevronRight
|
||||||
className="base-item-tokens shrink-0 rounded px-1.5 py-0.5 text-xs"
|
className={`base-item-chevron size-3 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
style={{
|
style={{ color: TOOL_ITEM_MUTED }}
|
||||||
color: TOOL_ITEM_MUTED,
|
/>
|
||||||
backgroundColor: 'var(--tool-item-badge-bg)',
|
)}
|
||||||
}}
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && children && (
|
||||||
|
<div
|
||||||
|
className="ml-2 mt-2 min-w-0 space-y-3 pl-6"
|
||||||
|
style={{ borderLeft: '2px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
~{formatTokens(tokenCount)} {tokenLabel}
|
{children}
|
||||||
</span>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status indicator - hidden when notification dot replaces it */}
|
|
||||||
{status && !notificationDotColor && <StatusDot status={status} />}
|
|
||||||
|
|
||||||
{/* Notification dot (replaces status dot when present) */}
|
|
||||||
{notificationDotColor && (
|
|
||||||
<span
|
|
||||||
className="base-item-notification-dot inline-block size-1.5 shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: getTriggerColorDef(notificationDotColor).hex }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Duration */}
|
|
||||||
{durationMs !== undefined && (
|
|
||||||
<span className="shrink-0 text-xs" style={{ color: TOOL_ITEM_MUTED }}>
|
|
||||||
{formatDuration(durationMs)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamp — rightmost info element */}
|
|
||||||
{timestamp && (
|
|
||||||
<span
|
|
||||||
className="base-item-timestamp shrink-0 text-[11px] tabular-nums"
|
|
||||||
style={{ color: TOOL_ITEM_MUTED }}
|
|
||||||
>
|
|
||||||
{format(timestamp, timestampFormat)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Expand/collapse chevron */}
|
|
||||||
{hasExpandableContent && (
|
|
||||||
<ChevronRight
|
|
||||||
className={`base-item-chevron size-3 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
|
||||||
style={{ color: TOOL_ITEM_MUTED }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{/* Expanded Content */}
|
}
|
||||||
{isExpanded && children && (
|
);
|
||||||
<div
|
|
||||||
className="ml-2 mt-2 min-w-0 space-y-3 pl-6"
|
|
||||||
style={{ borderLeft: '2px solid var(--color-border)' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* for summary generation and token calculation.
|
* for summary generation and token calculation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { memo, useRef } from 'react';
|
||||||
|
|
||||||
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
|
|
@ -64,173 +64,175 @@ interface LinkedToolItemProps {
|
||||||
titleText?: string;
|
titleText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
export const LinkedToolItem = memo(
|
||||||
linkedTool,
|
({
|
||||||
onClick,
|
linkedTool,
|
||||||
isExpanded,
|
onClick,
|
||||||
timestamp,
|
isExpanded,
|
||||||
timestampFormat,
|
timestamp,
|
||||||
searchQueryOverride,
|
timestampFormat,
|
||||||
isHighlighted,
|
searchQueryOverride,
|
||||||
highlightColor,
|
isHighlighted,
|
||||||
notificationDotColor,
|
highlightColor,
|
||||||
registerRef,
|
notificationDotColor,
|
||||||
titleText,
|
registerRef,
|
||||||
}) => {
|
titleText,
|
||||||
const status = getToolStatus(linkedTool);
|
}: LinkedToolItemProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const status = getToolStatus(linkedTool);
|
||||||
const summary = getToolSummary(linkedTool.name, linkedTool.input);
|
const { isLight } = useTheme();
|
||||||
const normalizedToolName = linkedTool.name.toLowerCase();
|
const summary = getToolSummary(linkedTool.name, linkedTool.input);
|
||||||
const summaryNode =
|
const normalizedToolName = linkedTool.name.toLowerCase();
|
||||||
searchQueryOverride && searchQueryOverride.trim().length > 0
|
const summaryNode =
|
||||||
? highlightQueryInText(
|
searchQueryOverride && searchQueryOverride.trim().length > 0
|
||||||
summary,
|
? highlightQueryInText(
|
||||||
searchQueryOverride,
|
summary,
|
||||||
`${linkedTool.id ?? linkedTool.name}:summary`,
|
searchQueryOverride,
|
||||||
{
|
`${linkedTool.id ?? linkedTool.name}:summary`,
|
||||||
forceAllActive: true,
|
{
|
||||||
}
|
forceAllActive: true,
|
||||||
)
|
}
|
||||||
: summary;
|
)
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
: summary;
|
||||||
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Combined ref callback - handles both internal ref and external registration
|
// Combined ref callback - handles both internal ref and external registration
|
||||||
const handleRef = (el: HTMLDivElement | null): void => {
|
const handleRef = (el: HTMLDivElement | null): void => {
|
||||||
// Update internal ref
|
// Update internal ref
|
||||||
(elementRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
(elementRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||||
// Call external registration if provided
|
// Call external registration if provided
|
||||||
registerRef?.(el);
|
registerRef?.(el);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render teammate_spawned results as a minimal inline row
|
// Render teammate_spawned results as a minimal inline row
|
||||||
const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned';
|
const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned';
|
||||||
if (isTeammateSpawned) {
|
if (isTeammateSpawned) {
|
||||||
const teamResult = linkedTool.result!.toolUseResult!;
|
const teamResult = linkedTool.result!.toolUseResult!;
|
||||||
const name = (teamResult.name as string) || 'teammate';
|
const name = (teamResult.name as string) || 'teammate';
|
||||||
const color = (teamResult.color as string) || '';
|
const color = (teamResult.color as string) || '';
|
||||||
const colors = getTeamColorSet(color);
|
const colors = getTeamColorSet(color);
|
||||||
return (
|
return (
|
||||||
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
|
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
|
||||||
<span className="size-2.5 rounded-full" style={{ backgroundColor: colors.border }} />
|
<span className="size-2.5 rounded-full" style={{ backgroundColor: colors.border }} />
|
||||||
<span
|
<span
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
style={{ backgroundColor: getThemedBadge(colors, isLight), color: colors.text }}
|
style={{ backgroundColor: getThemedBadge(colors, isLight), color: colors.text }}
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
Teammate spawned
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render SendMessage shutdown_request as a minimal inline row
|
|
||||||
const isShutdownRequest =
|
|
||||||
linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request';
|
|
||||||
if (isShutdownRequest) {
|
|
||||||
const target = (linkedTool.input?.recipient as string) || 'teammate';
|
|
||||||
return (
|
|
||||||
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
|
|
||||||
<span className="size-2 rounded-full bg-zinc-500" />
|
|
||||||
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
Shutdown requested →{' '}
|
|
||||||
<span className="font-medium text-text-secondary">{target}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: We no longer scroll locally - the navigation coordinator handles this
|
|
||||||
// via the registered ref. This prevents double-scroll issues.
|
|
||||||
|
|
||||||
// Highlight animation for error deep linking (supports custom hex)
|
|
||||||
const effectiveColor = highlightColor ?? 'red';
|
|
||||||
let highlightClasses = '';
|
|
||||||
let highlightStyle: React.CSSProperties | undefined;
|
|
||||||
if (isHighlighted) {
|
|
||||||
if (isPresetColorKey(effectiveColor)) {
|
|
||||||
highlightClasses = TOOL_HIGHLIGHT_CLASSES[effectiveColor];
|
|
||||||
} else {
|
|
||||||
const hp = getToolHighlightProps(effectiveColor);
|
|
||||||
highlightClasses = hp.className;
|
|
||||||
highlightStyle = hp.style;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which specialized viewer to use
|
|
||||||
const useReadViewer =
|
|
||||||
normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError;
|
|
||||||
const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool);
|
|
||||||
const useWriteViewer =
|
|
||||||
normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError;
|
|
||||||
const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool);
|
|
||||||
const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer;
|
|
||||||
|
|
||||||
// Check if we should show error display for Read/Write tools
|
|
||||||
const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError;
|
|
||||||
const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={handleRef}>
|
|
||||||
<BaseItem
|
|
||||||
icon={
|
|
||||||
<Wrench
|
|
||||||
className="size-4"
|
|
||||||
style={{ color: isHighlighted ? getTriggerColorDef(highlightColor).hex : undefined }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={linkedTool.name}
|
|
||||||
summary={summaryNode}
|
|
||||||
tokenCount={getToolContextTokens(linkedTool)}
|
|
||||||
status={status}
|
|
||||||
durationMs={linkedTool.durationMs}
|
|
||||||
timestamp={timestamp}
|
|
||||||
timestampFormat={timestampFormat}
|
|
||||||
titleText={titleText}
|
|
||||||
onClick={onClick}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
highlightClasses={highlightClasses}
|
|
||||||
highlightStyle={highlightStyle}
|
|
||||||
notificationDotColor={notificationDotColor}
|
|
||||||
>
|
|
||||||
{/* Read tool with CodeBlockViewer */}
|
|
||||||
{useReadViewer && <ReadToolViewer linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Edit tool with DiffViewer */}
|
|
||||||
{useEditViewer && <EditToolViewer linkedTool={linkedTool} status={status} />}
|
|
||||||
|
|
||||||
{/* Write tool */}
|
|
||||||
{useWriteViewer && <WriteToolViewer linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Skill tool with instructions */}
|
|
||||||
{useSkillViewer && <SkillToolViewer linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Default rendering for other tools */}
|
|
||||||
{useDefaultViewer && <DefaultToolViewer linkedTool={linkedTool} status={status} />}
|
|
||||||
|
|
||||||
{/* Error output for Read tool */}
|
|
||||||
{showReadError && <ToolErrorDisplay linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Error output for Write tool */}
|
|
||||||
{showWriteError && <ToolErrorDisplay linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Orphaned indicator */}
|
|
||||||
{linkedTool.isOrphaned && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 text-xs italic"
|
|
||||||
style={{ color: 'var(--tool-item-muted)' }}
|
|
||||||
>
|
>
|
||||||
<StatusDot status="orphaned" />
|
{name}
|
||||||
No result received
|
</span>
|
||||||
</div>
|
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
|
||||||
)}
|
Teammate spawned
|
||||||
|
</span>
|
||||||
{/* Timing */}
|
|
||||||
<div className="text-xs" style={{ color: 'var(--tool-item-muted)' }}>
|
|
||||||
Duration: {formatDuration(linkedTool.durationMs)}
|
|
||||||
</div>
|
</div>
|
||||||
</BaseItem>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
};
|
// Render SendMessage shutdown_request as a minimal inline row
|
||||||
|
const isShutdownRequest =
|
||||||
|
linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request';
|
||||||
|
if (isShutdownRequest) {
|
||||||
|
const target = (linkedTool.input?.recipient as string) || 'teammate';
|
||||||
|
return (
|
||||||
|
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-zinc-500" />
|
||||||
|
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
Shutdown requested →{' '}
|
||||||
|
<span className="font-medium text-text-secondary">{target}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: We no longer scroll locally - the navigation coordinator handles this
|
||||||
|
// via the registered ref. This prevents double-scroll issues.
|
||||||
|
|
||||||
|
// Highlight animation for error deep linking (supports custom hex)
|
||||||
|
const effectiveColor = highlightColor ?? 'red';
|
||||||
|
let highlightClasses = '';
|
||||||
|
let highlightStyle: React.CSSProperties | undefined;
|
||||||
|
if (isHighlighted) {
|
||||||
|
if (isPresetColorKey(effectiveColor)) {
|
||||||
|
highlightClasses = TOOL_HIGHLIGHT_CLASSES[effectiveColor];
|
||||||
|
} else {
|
||||||
|
const hp = getToolHighlightProps(effectiveColor);
|
||||||
|
highlightClasses = hp.className;
|
||||||
|
highlightStyle = hp.style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which specialized viewer to use
|
||||||
|
const useReadViewer =
|
||||||
|
normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError;
|
||||||
|
const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool);
|
||||||
|
const useWriteViewer =
|
||||||
|
normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError;
|
||||||
|
const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool);
|
||||||
|
const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer;
|
||||||
|
|
||||||
|
// Check if we should show error display for Read/Write tools
|
||||||
|
const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError;
|
||||||
|
const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={handleRef}>
|
||||||
|
<BaseItem
|
||||||
|
icon={
|
||||||
|
<Wrench
|
||||||
|
className="size-4"
|
||||||
|
style={{ color: isHighlighted ? getTriggerColorDef(highlightColor).hex : undefined }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={linkedTool.name}
|
||||||
|
summary={summaryNode}
|
||||||
|
tokenCount={getToolContextTokens(linkedTool)}
|
||||||
|
status={status}
|
||||||
|
durationMs={linkedTool.durationMs}
|
||||||
|
timestamp={timestamp}
|
||||||
|
timestampFormat={timestampFormat}
|
||||||
|
titleText={titleText}
|
||||||
|
onClick={onClick}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
highlightClasses={highlightClasses}
|
||||||
|
highlightStyle={highlightStyle}
|
||||||
|
notificationDotColor={notificationDotColor}
|
||||||
|
>
|
||||||
|
{/* Read tool with CodeBlockViewer */}
|
||||||
|
{useReadViewer && <ReadToolViewer linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Edit tool with DiffViewer */}
|
||||||
|
{useEditViewer && <EditToolViewer linkedTool={linkedTool} status={status} />}
|
||||||
|
|
||||||
|
{/* Write tool */}
|
||||||
|
{useWriteViewer && <WriteToolViewer linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Skill tool with instructions */}
|
||||||
|
{useSkillViewer && <SkillToolViewer linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Default rendering for other tools */}
|
||||||
|
{useDefaultViewer && <DefaultToolViewer linkedTool={linkedTool} status={status} />}
|
||||||
|
|
||||||
|
{/* Error output for Read tool */}
|
||||||
|
{showReadError && <ToolErrorDisplay linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Error output for Write tool */}
|
||||||
|
{showWriteError && <ToolErrorDisplay linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Orphaned indicator */}
|
||||||
|
{linkedTool.isOrphaned && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-xs italic"
|
||||||
|
style={{ color: 'var(--tool-item-muted)' }}
|
||||||
|
>
|
||||||
|
<StatusDot status="orphaned" />
|
||||||
|
No result received
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing */}
|
||||||
|
<div className="text-xs" style={{ color: 'var(--tool-item-muted)' }}>
|
||||||
|
Duration: {formatDuration(linkedTool.durationMs)}
|
||||||
|
</div>
|
||||||
|
</BaseItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,175 +41,177 @@ interface MetricsPillProps {
|
||||||
// Unified Metrics Pill - Compact monospace pill with tooltip
|
// Unified Metrics Pill - Compact monospace pill with tooltip
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const MetricsPill = ({
|
export const MetricsPill = memo(
|
||||||
mainSessionImpact,
|
({
|
||||||
lastUsage,
|
mainSessionImpact,
|
||||||
isolatedLabel,
|
lastUsage,
|
||||||
isolatedOverride,
|
isolatedLabel,
|
||||||
phaseBreakdown,
|
isolatedOverride,
|
||||||
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
|
phaseBreakdown,
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
|
||||||
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
||||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0;
|
const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0;
|
||||||
const hasIsolated =
|
const hasIsolated =
|
||||||
isolatedOverride != null
|
isolatedOverride != null
|
||||||
? isolatedOverride > 0
|
? isolatedOverride > 0
|
||||||
: lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
|
: lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
|
||||||
|
|
||||||
const isolatedTotal =
|
const isolatedTotal =
|
||||||
isolatedOverride ??
|
isolatedOverride ??
|
||||||
(lastUsage
|
(lastUsage
|
||||||
? lastUsage.input_tokens +
|
? lastUsage.input_tokens +
|
||||||
lastUsage.output_tokens +
|
lastUsage.output_tokens +
|
||||||
(lastUsage.cache_read_input_tokens ?? 0) +
|
(lastUsage.cache_read_input_tokens ?? 0) +
|
||||||
(lastUsage.cache_creation_input_tokens ?? 0)
|
(lastUsage.cache_creation_input_tokens ?? 0)
|
||||||
: 0);
|
: 0);
|
||||||
|
|
||||||
const hasPhases = phaseBreakdown && phaseBreakdown.length > 1;
|
const hasPhases = phaseBreakdown && phaseBreakdown.length > 1;
|
||||||
|
|
||||||
const clearHideTimeout = (): void => {
|
const clearHideTimeout = (): void => {
|
||||||
if (hideTimeoutRef.current) {
|
if (hideTimeoutRef.current) {
|
||||||
clearTimeout(hideTimeoutRef.current);
|
clearTimeout(hideTimeoutRef.current);
|
||||||
hideTimeoutRef.current = null;
|
hideTimeoutRef.current = null;
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = (): void => {
|
|
||||||
clearHideTimeout();
|
|
||||||
setShowTooltip(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = (): void => {
|
|
||||||
clearHideTimeout();
|
|
||||||
hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showTooltip && containerRef.current) {
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
|
||||||
const tooltipWidth = 220;
|
|
||||||
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
|
||||||
if (left < 8) left = 8;
|
|
||||||
if (left + tooltipWidth > window.innerWidth - 8) {
|
|
||||||
left = window.innerWidth - tooltipWidth - 8;
|
|
||||||
}
|
}
|
||||||
setTooltipStyle({
|
};
|
||||||
position: 'fixed',
|
|
||||||
bottom: window.innerHeight - rect.top + 6,
|
const handleMouseEnter = (): void => {
|
||||||
left,
|
clearHideTimeout();
|
||||||
width: tooltipWidth,
|
setShowTooltip(true);
|
||||||
zIndex: 99999,
|
};
|
||||||
});
|
|
||||||
|
const handleMouseLeave = (): void => {
|
||||||
|
clearHideTimeout();
|
||||||
|
hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showTooltip && containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const tooltipWidth = 220;
|
||||||
|
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
if (left + tooltipWidth > window.innerWidth - 8) {
|
||||||
|
left = window.innerWidth - tooltipWidth - 8;
|
||||||
|
}
|
||||||
|
setTooltipStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: window.innerHeight - rect.top + 6,
|
||||||
|
left,
|
||||||
|
width: tooltipWidth,
|
||||||
|
zIndex: 99999,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [showTooltip]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showTooltip) return;
|
||||||
|
const handleScroll = (): void => setShowTooltip(false);
|
||||||
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll, true);
|
||||||
|
}, [showTooltip]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearHideTimeout();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!hasMainImpact && !hasIsolated) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}, [showTooltip]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null;
|
||||||
if (!showTooltip) return;
|
const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null;
|
||||||
const handleScroll = (): void => setShowTooltip(false);
|
const rightLabel = isolatedLabel ?? 'Subagent Context';
|
||||||
window.addEventListener('scroll', handleScroll, true);
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll, true);
|
|
||||||
}, [showTooltip]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
return () => clearHideTimeout();
|
<>
|
||||||
}, []);
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
role="tooltip"
|
||||||
|
className="inline-flex cursor-default items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[11px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: TAG_BG,
|
||||||
|
border: `1px solid ${TAG_BORDER}`,
|
||||||
|
color: TAG_TEXT,
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{mainValue && <span className="tabular-nums">{mainValue}</span>}
|
||||||
|
{mainValue && isolatedValue && <span style={{ color: CARD_SEPARATOR }}>|</span>}
|
||||||
|
{isolatedValue && <span className="tabular-nums">{isolatedValue}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
if (!hasMainImpact && !hasIsolated) {
|
{showTooltip &&
|
||||||
return null;
|
createPortal(
|
||||||
}
|
<div
|
||||||
|
role="tooltip"
|
||||||
const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null;
|
className="rounded-md bg-surface-overlay p-2 text-[11px] shadow-xl"
|
||||||
const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null;
|
style={{
|
||||||
const rightLabel = isolatedLabel ?? 'Subagent Context';
|
...tooltipStyle,
|
||||||
|
border: `1px solid ${TAG_BORDER}`,
|
||||||
return (
|
}}
|
||||||
<>
|
onMouseEnter={handleMouseEnter}
|
||||||
<div
|
onMouseLeave={handleMouseLeave}
|
||||||
ref={containerRef}
|
>
|
||||||
role="tooltip"
|
<div className="space-y-1">
|
||||||
className="inline-flex cursor-default items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[11px]"
|
{hasMainImpact && (
|
||||||
style={{
|
<div className="flex items-center justify-between gap-3">
|
||||||
backgroundColor: TAG_BG,
|
<span style={{ color: COLOR_TEXT_MUTED }}>Main Context</span>
|
||||||
border: `1px solid ${TAG_BORDER}`,
|
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
color: TAG_TEXT,
|
{mainSessionImpact.totalTokens.toLocaleString()}
|
||||||
}}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{mainValue && <span className="tabular-nums">{mainValue}</span>}
|
|
||||||
{mainValue && isolatedValue && <span style={{ color: CARD_SEPARATOR }}>|</span>}
|
|
||||||
{isolatedValue && <span className="tabular-nums">{isolatedValue}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTooltip &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
role="tooltip"
|
|
||||||
className="rounded-md bg-surface-overlay p-2 text-[11px] shadow-xl"
|
|
||||||
style={{
|
|
||||||
...tooltipStyle,
|
|
||||||
border: `1px solid ${TAG_BORDER}`,
|
|
||||||
}}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{hasMainImpact && (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span style={{ color: COLOR_TEXT_MUTED }}>Main Context</span>
|
|
||||||
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
|
|
||||||
{mainSessionImpact.totalTokens.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasIsolated && (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span style={{ color: COLOR_TEXT_MUTED }}>{rightLabel}</span>
|
|
||||||
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
|
|
||||||
{isolatedTotal.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasPhases &&
|
|
||||||
phaseBreakdown.map((phase) => (
|
|
||||||
<div
|
|
||||||
key={phase.phaseNumber}
|
|
||||||
className="flex items-center justify-between gap-3 pl-2"
|
|
||||||
>
|
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
Phase {phase.phaseNumber}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="font-mono text-[10px] tabular-nums"
|
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
|
||||||
>
|
|
||||||
{formatTokensCompact(phase.peakTokens)}
|
|
||||||
{phase.postCompaction != null && (
|
|
||||||
<span style={{ color: '#4ade80' }}>
|
|
||||||
{' '}
|
|
||||||
→ {formatTokensCompact(phase.postCompaction)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
<div
|
{hasIsolated && (
|
||||||
className="mt-1 pt-1.5 text-[10px]"
|
<div className="flex items-center justify-between gap-3">
|
||||||
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }}
|
<span style={{ color: COLOR_TEXT_MUTED }}>{rightLabel}</span>
|
||||||
>
|
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
{hasMainImpact && hasIsolated
|
{isolatedTotal.toLocaleString()}
|
||||||
? 'Left: parent injection · Right: internal'
|
</span>
|
||||||
: hasMainImpact
|
</div>
|
||||||
? 'Tokens injected to parent'
|
)}
|
||||||
: 'Internal token usage'}
|
{hasPhases &&
|
||||||
|
phaseBreakdown.map((phase) => (
|
||||||
|
<div
|
||||||
|
key={phase.phaseNumber}
|
||||||
|
className="flex items-center justify-between gap-3 pl-2"
|
||||||
|
>
|
||||||
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
Phase {phase.phaseNumber}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] tabular-nums"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{formatTokensCompact(phase.peakTokens)}
|
||||||
|
{phase.postCompaction != null && (
|
||||||
|
<span style={{ color: '#4ade80' }}>
|
||||||
|
{' '}
|
||||||
|
→ {formatTokensCompact(phase.postCompaction)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="mt-1 pt-1.5 text-[10px]"
|
||||||
|
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{hasMainImpact && hasIsolated
|
||||||
|
? 'Left: parent injection · Right: internal'
|
||||||
|
: hasMainImpact
|
||||||
|
? 'Tokens injected to parent'
|
||||||
|
: 'Internal token usage'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body
|
||||||
document.body
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
);
|
||||||
);
|
}
|
||||||
};
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { Slash } from 'lucide-react';
|
import { Slash } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -34,48 +34,50 @@ interface SlashItemProps {
|
||||||
* - MCP commands
|
* - MCP commands
|
||||||
* - User-defined commands
|
* - User-defined commands
|
||||||
*/
|
*/
|
||||||
export const SlashItem: React.FC<SlashItemProps> = ({
|
export const SlashItem = memo(
|
||||||
slash,
|
({
|
||||||
onClick,
|
slash,
|
||||||
isExpanded,
|
onClick,
|
||||||
timestamp,
|
isExpanded,
|
||||||
timestampFormat,
|
timestamp,
|
||||||
highlightClasses,
|
timestampFormat,
|
||||||
highlightStyle,
|
highlightClasses,
|
||||||
notificationDotColor,
|
highlightStyle,
|
||||||
titleText,
|
notificationDotColor,
|
||||||
}) => {
|
titleText,
|
||||||
const hasInstructions = !!slash.instructions;
|
}: SlashItemProps): React.JSX.Element => {
|
||||||
|
const hasInstructions = !!slash.instructions;
|
||||||
|
|
||||||
// Display args or message as the description
|
// Display args or message as the description
|
||||||
const description = slash.args ?? slash.message;
|
const description = slash.args ?? slash.message;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseItem
|
<BaseItem
|
||||||
icon={<Slash className="size-4" />}
|
icon={<Slash className="size-4" />}
|
||||||
label={`/${slash.name}`}
|
label={`/${slash.name}`}
|
||||||
summary={description}
|
summary={description}
|
||||||
tokenCount={slash.instructionsTokenCount}
|
tokenCount={slash.instructionsTokenCount}
|
||||||
tokenLabel="tokens"
|
tokenLabel="tokens"
|
||||||
status={hasInstructions ? 'ok' : undefined}
|
status={hasInstructions ? 'ok' : undefined}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
timestampFormat={timestampFormat}
|
timestampFormat={timestampFormat}
|
||||||
titleText={titleText}
|
titleText={titleText}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
hasExpandableContent={hasInstructions}
|
hasExpandableContent={hasInstructions}
|
||||||
highlightClasses={highlightClasses}
|
highlightClasses={highlightClasses}
|
||||||
highlightStyle={highlightStyle}
|
highlightStyle={highlightStyle}
|
||||||
notificationDotColor={notificationDotColor}
|
notificationDotColor={notificationDotColor}
|
||||||
>
|
>
|
||||||
{hasInstructions && (
|
{hasInstructions && (
|
||||||
<MarkdownViewer
|
<MarkdownViewer
|
||||||
content={slash.instructions!}
|
content={slash.instructions!}
|
||||||
label="Slash Output"
|
label="Slash Output"
|
||||||
maxHeight="max-h-96"
|
maxHeight="max-h-96"
|
||||||
copyable
|
copyable
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</BaseItem>
|
</BaseItem>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
|
|
@ -75,187 +75,192 @@ function isResendMessage(message: TeammateMessage): boolean {
|
||||||
*
|
*
|
||||||
* Operational noise (idle/shutdown/terminated) renders as minimal inline text.
|
* Operational noise (idle/shutdown/terminated) renders as minimal inline text.
|
||||||
*/
|
*/
|
||||||
export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
export const TeammateMessageItem = memo(
|
||||||
teammateMessage,
|
({
|
||||||
onClick,
|
teammateMessage,
|
||||||
isExpanded,
|
onClick,
|
||||||
onReplyHover,
|
isExpanded,
|
||||||
highlightClasses = '',
|
onReplyHover,
|
||||||
highlightStyle,
|
highlightClasses = '',
|
||||||
}) => {
|
highlightStyle,
|
||||||
const colors = getTeamColorSet(teammateMessage.color);
|
}: TeammateMessageItemProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const colors = getTeamColorSet(teammateMessage.color);
|
||||||
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
// Get team members for @mention highlighting
|
// Get team members for @mention highlighting
|
||||||
const members = useStore(
|
const members = useStore(
|
||||||
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
|
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
|
||||||
);
|
);
|
||||||
const memberColorMap = useMemo(
|
const memberColorMap = useMemo(
|
||||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||||
[members]
|
[members]
|
||||||
);
|
|
||||||
|
|
||||||
// Get team names for @team linkification
|
|
||||||
const teams = useStore(useShallow((s) => s.teams));
|
|
||||||
const teamNames = useMemo(
|
|
||||||
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
|
||||||
[teams]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Detect operational noise
|
|
||||||
const noiseLabel = useMemo(
|
|
||||||
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
|
|
||||||
[teammateMessage.content, teammateMessage.teammateId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Detect resent/duplicate messages
|
|
||||||
const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]);
|
|
||||||
|
|
||||||
const plainSummary = useMemo(
|
|
||||||
() => extractMarkdownPlainText(teammateMessage.summary),
|
|
||||||
[teammateMessage.summary]
|
|
||||||
);
|
|
||||||
const plainReplyToSummary = useMemo(
|
|
||||||
() =>
|
|
||||||
teammateMessage.replyToSummary
|
|
||||||
? extractMarkdownPlainText(teammateMessage.replyToSummary)
|
|
||||||
: undefined,
|
|
||||||
[teammateMessage.replyToSummary]
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayContent = useMemo(() => {
|
|
||||||
const stripped = stripAgentBlocks(teammateMessage.content);
|
|
||||||
return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames);
|
|
||||||
}, [teammateMessage.content, memberColorMap, teamNames]);
|
|
||||||
|
|
||||||
// Noise: minimal inline row (no card, no expand)
|
|
||||||
if (noiseLabel) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}>
|
|
||||||
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: colors.border }} />
|
|
||||||
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
{teammateMessage.teammateId}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
{noiseLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const truncatedSummary =
|
// Get team names for @team linkification
|
||||||
plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary;
|
const teams = useStore(useShallow((s) => s.teams));
|
||||||
|
const teamNames = useMemo(
|
||||||
|
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
||||||
|
[teams]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
// Detect operational noise
|
||||||
<div
|
const noiseLabel = useMemo(
|
||||||
className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
|
||||||
style={{
|
[teammateMessage.content, teammateMessage.teammateId]
|
||||||
backgroundColor: CARD_BG,
|
);
|
||||||
border: CARD_BORDER_STYLE,
|
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
// Detect resent/duplicate messages
|
||||||
opacity: isResend ? 0.6 : undefined,
|
const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]);
|
||||||
...highlightStyle,
|
|
||||||
}}
|
const plainSummary = useMemo(
|
||||||
>
|
() => extractMarkdownPlainText(teammateMessage.summary),
|
||||||
{/* Header */}
|
[teammateMessage.summary]
|
||||||
|
);
|
||||||
|
const plainReplyToSummary = useMemo(
|
||||||
|
() =>
|
||||||
|
teammateMessage.replyToSummary
|
||||||
|
? extractMarkdownPlainText(teammateMessage.replyToSummary)
|
||||||
|
: undefined,
|
||||||
|
[teammateMessage.replyToSummary]
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayContent = useMemo(() => {
|
||||||
|
const stripped = stripAgentBlocks(teammateMessage.content);
|
||||||
|
return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames);
|
||||||
|
}, [teammateMessage.content, memberColorMap, teamNames]);
|
||||||
|
|
||||||
|
// Noise: minimal inline row (no card, no expand)
|
||||||
|
if (noiseLabel) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}>
|
||||||
|
<span
|
||||||
|
className="size-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: colors.border }}
|
||||||
|
/>
|
||||||
|
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{teammateMessage.teammateId}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{noiseLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncatedSummary =
|
||||||
|
plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
||||||
tabIndex={0}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent',
|
backgroundColor: CARD_BG,
|
||||||
borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none',
|
border: CARD_BORDER_STYLE,
|
||||||
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
|
opacity: isResend ? 0.6 : undefined,
|
||||||
|
...highlightStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronRight
|
{/* Header */}
|
||||||
className={`size-3.5 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
<div
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
role="button"
|
||||||
/>
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
{/* Message icon — distinguishes from SubagentItem's Bot/dot icon */}
|
onKeyDown={(e) => {
|
||||||
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
{/* Teammate name badge */}
|
onClick();
|
||||||
<span
|
}
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: getThemedBadge(colors, isLight),
|
backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent',
|
||||||
color: colors.text,
|
borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none',
|
||||||
border: `1px solid ${colors.border}40`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{teammateMessage.teammateId}
|
<ChevronRight
|
||||||
</span>
|
className={`size-3.5 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
|
||||||
{/* "Message" type label — parallels SubagentItem's model info */}
|
|
||||||
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
Message
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Reply indicator — shows which SendMessage triggered this response */}
|
|
||||||
{plainReplyToSummary && (
|
|
||||||
<span
|
|
||||||
role="presentation"
|
|
||||||
className="flex cursor-default items-center gap-1 text-[10px]"
|
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
onMouseEnter={() => onReplyHover?.(teammateMessage.replyToToolId ?? null)}
|
/>
|
||||||
onMouseLeave={() => onReplyHover?.(null)}
|
|
||||||
|
{/* Message icon — distinguishes from SubagentItem's Bot/dot icon */}
|
||||||
|
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
||||||
|
|
||||||
|
{/* Teammate name badge */}
|
||||||
|
<span
|
||||||
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getThemedBadge(colors, isLight),
|
||||||
|
color: colors.text,
|
||||||
|
border: `1px solid ${colors.border}40`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CornerDownLeft className="size-2.5" />
|
{teammateMessage.teammateId}
|
||||||
<span className="truncate" style={{ maxWidth: '180px' }}>
|
</span>
|
||||||
{plainReplyToSummary}
|
|
||||||
|
{/* "Message" type label — parallels SubagentItem's model info */}
|
||||||
|
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
Message
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Reply indicator — shows which SendMessage triggered this response */}
|
||||||
|
{plainReplyToSummary && (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className="flex cursor-default items-center gap-1 text-[10px]"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
onMouseEnter={() => onReplyHover?.(teammateMessage.replyToToolId ?? null)}
|
||||||
|
onMouseLeave={() => onReplyHover?.(null)}
|
||||||
|
>
|
||||||
|
<CornerDownLeft className="size-2.5" />
|
||||||
|
<span className="truncate" style={{ maxWidth: '180px' }}>
|
||||||
|
{plainReplyToSummary}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resend badge — marks duplicate/resent messages */}
|
||||||
|
{isResend && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-0.5 text-[10px]"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-2.5" />
|
||||||
|
Resent
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
|
{truncatedSummary || 'Teammate message'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Resend badge — marks duplicate/resent messages */}
|
{/* Context impact — tokens injected into main session */}
|
||||||
{isResend && (
|
{teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className="flex items-center gap-0.5 text-[10px]"
|
className="shrink-0 font-mono text-[11px] tabular-nums"
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-2.5" />
|
~{formatTokensCompact(teammateMessage.tokenCount)} tokens
|
||||||
Resent
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Timestamp — rightmost info element */}
|
||||||
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
|
||||||
{truncatedSummary || 'Teammate message'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Context impact — tokens injected into main session */}
|
|
||||||
{teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && (
|
|
||||||
<span
|
<span
|
||||||
className="shrink-0 font-mono text-[11px] tabular-nums"
|
className="shrink-0 font-mono text-[11px] tabular-nums"
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
>
|
>
|
||||||
~{formatTokensCompact(teammateMessage.tokenCount)} tokens
|
{format(teammateMessage.timestamp, 'HH:mm:ss')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamp — rightmost info element */}
|
|
||||||
<span
|
|
||||||
className="shrink-0 font-mono text-[11px] tabular-nums"
|
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
|
||||||
>
|
|
||||||
{format(teammateMessage.timestamp, 'HH:mm:ss')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="p-3">
|
|
||||||
<MarkdownViewer content={displayContent} copyable />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Expanded content */}
|
||||||
);
|
{isExpanded && (
|
||||||
};
|
<div className="p-3">
|
||||||
|
<MarkdownViewer content={displayContent} copyable />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Shows a clickable header with label, StatusDot, and chevron toggle.
|
* Shows a clickable header with label, StatusDot, and chevron toggle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
|
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -18,40 +18,44 @@ interface CollapsibleOutputSectionProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> = ({
|
export const CollapsibleOutputSection = memo(
|
||||||
status,
|
({ status, children, label = 'Output' }: CollapsibleOutputSectionProps): React.JSX.Element => {
|
||||||
children,
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
label = 'Output',
|
|
||||||
}) => {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mb-1 flex items-center gap-2 text-xs"
|
className="mb-1 flex items-center gap-2 text-xs"
|
||||||
style={{ color: 'var(--tool-item-muted)', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
|
||||||
onClick={() => setIsExpanded((prev) => !prev)}
|
|
||||||
>
|
|
||||||
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
|
||||||
{label}
|
|
||||||
<StatusDot status={status} />
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
|
||||||
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--code-bg)',
|
color: 'var(--tool-item-muted)',
|
||||||
border: '1px solid var(--code-border)',
|
background: 'none',
|
||||||
color:
|
border: 'none',
|
||||||
status === 'error'
|
padding: 0,
|
||||||
? 'var(--tool-result-error-text)'
|
cursor: 'pointer',
|
||||||
: 'var(--color-text-secondary)',
|
|
||||||
}}
|
}}
|
||||||
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{children}
|
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||||
</div>
|
{label}
|
||||||
)}
|
<StatusDot status={status} />
|
||||||
</div>
|
</button>
|
||||||
);
|
{isExpanded && (
|
||||||
};
|
<div
|
||||||
|
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--code-bg)',
|
||||||
|
border: '1px solid var(--code-border)',
|
||||||
|
color:
|
||||||
|
status === 'error'
|
||||||
|
? 'var(--tool-result-error-text)'
|
||||||
|
: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Default rendering for tools that don't have specialized viewers.
|
* Default rendering for tools that don't have specialized viewers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { type ItemStatus } from '../BaseItem';
|
import { type ItemStatus } from '../BaseItem';
|
||||||
|
|
||||||
|
|
@ -23,7 +23,10 @@ interface DefaultToolViewerProps {
|
||||||
status: ItemStatus;
|
status: ItemStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => {
|
export const DefaultToolViewer = memo(function DefaultToolViewer({
|
||||||
|
linkedTool,
|
||||||
|
status,
|
||||||
|
}: DefaultToolViewerProps) {
|
||||||
const displayOutputContent = linkedTool.result
|
const displayOutputContent = linkedTool.result
|
||||||
? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content)
|
? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -64,4 +67,4 @@ export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Renders the Edit tool with DiffViewer.
|
* Renders the Edit tool with DiffViewer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { DiffViewer } from '@renderer/components/chat/viewers';
|
import { DiffViewer } from '@renderer/components/chat/viewers';
|
||||||
|
|
||||||
|
|
@ -20,7 +20,10 @@ interface EditToolViewerProps {
|
||||||
status: ItemStatus;
|
status: ItemStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditToolViewer: React.FC<EditToolViewerProps> = ({ linkedTool, status }) => {
|
export const EditToolViewer = memo(function EditToolViewer({
|
||||||
|
linkedTool,
|
||||||
|
status,
|
||||||
|
}: EditToolViewerProps) {
|
||||||
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
|
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string);
|
const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string);
|
||||||
|
|
@ -71,4 +74,4 @@ export const EditToolViewer: React.FC<EditToolViewerProps> = ({ linkedTool, stat
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Renders the Read tool result using CodeBlockViewer.
|
* Renders the Read tool result using CodeBlockViewer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface ReadToolViewerProps {
|
||||||
linkedTool: LinkedToolItem;
|
linkedTool: LinkedToolItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) => {
|
export const ReadToolViewer = memo(function ReadToolViewer({ linkedTool }: ReadToolViewerProps) {
|
||||||
const filePath = linkedTool.input.file_path as string;
|
const filePath = linkedTool.input.file_path as string;
|
||||||
|
|
||||||
// Prefer enriched toolUseResult data
|
// Prefer enriched toolUseResult data
|
||||||
|
|
@ -55,7 +55,9 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const isMarkdownFile = /\.mdx?$/i.test(filePath);
|
const isMarkdownFile = /\.mdx?$/i.test(filePath);
|
||||||
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code');
|
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(
|
||||||
|
isMarkdownFile ? 'preview' : 'code'
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -99,4 +101,4 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Renders the Skill tool with its instructions in a code block viewer style.
|
* Renders the Skill tool with its instructions in a code block viewer style.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { CodeBlockViewer } from '@renderer/components/chat/viewers';
|
import { CodeBlockViewer } from '@renderer/components/chat/viewers';
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface SkillToolViewerProps {
|
||||||
linkedTool: LinkedToolItem;
|
linkedTool: LinkedToolItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SkillToolViewer: React.FC<SkillToolViewerProps> = ({ linkedTool }) => {
|
export const SkillToolViewer = memo(function SkillToolViewer({ linkedTool }: SkillToolViewerProps) {
|
||||||
const skillInstructions = linkedTool.skillInstructions;
|
const skillInstructions = linkedTool.skillInstructions;
|
||||||
const skillName = (linkedTool.input.skill as string) || 'Unknown Skill';
|
const skillName = (linkedTool.input.skill as string) || 'Unknown Skill';
|
||||||
|
|
||||||
|
|
@ -64,4 +64,4 @@ export const SkillToolViewer: React.FC<SkillToolViewerProps> = ({ linkedTool })
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Displays error output for tool results.
|
* Displays error output for tool results.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { StatusDot } from '../BaseItem';
|
import { StatusDot } from '../BaseItem';
|
||||||
|
|
||||||
|
|
@ -16,7 +16,9 @@ interface ToolErrorDisplayProps {
|
||||||
linkedTool: LinkedToolItem;
|
linkedTool: LinkedToolItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolErrorDisplay: React.FC<ToolErrorDisplayProps> = ({ linkedTool }) => {
|
export const ToolErrorDisplay = memo(function ToolErrorDisplay({
|
||||||
|
linkedTool,
|
||||||
|
}: ToolErrorDisplayProps) {
|
||||||
if (!linkedTool.result?.isError) return null;
|
if (!linkedTool.result?.isError) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -40,4 +42,4 @@ export const ToolErrorDisplay: React.FC<ToolErrorDisplayProps> = ({ linkedTool }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Renders the Write tool result.
|
* Renders the Write tool result.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface WriteToolViewerProps {
|
||||||
linkedTool: LinkedToolItem;
|
linkedTool: LinkedToolItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool }) => {
|
export const WriteToolViewer = memo(function WriteToolViewer({ linkedTool }: WriteToolViewerProps) {
|
||||||
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
|
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
const filePath =
|
const filePath =
|
||||||
|
|
@ -74,4 +74,4 @@ export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool })
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
@ -44,7 +44,7 @@ interface CollapsibleTeamSectionProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CollapsibleTeamSection = ({
|
export const CollapsibleTeamSection = memo(function CollapsibleTeamSection({
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
badge,
|
badge,
|
||||||
|
|
@ -63,7 +63,7 @@ export const CollapsibleTeamSection = ({
|
||||||
headerSurfaceClassName,
|
headerSurfaceClassName,
|
||||||
keepMounted,
|
keepMounted,
|
||||||
children,
|
children,
|
||||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
}: CollapsibleTeamSectionProps): React.JSX.Element {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const isOpen = forceOpen ? true : open;
|
const isOpen = forceOpen ? true : open;
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
|
@ -174,4 +174,4 @@ export const CollapsibleTeamSection = ({
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
|
@ -65,12 +65,12 @@ interface TaskTooltipProps {
|
||||||
* Tooltip that shows task summary on hover over any #taskId link.
|
* Tooltip that shows task summary on hover over any #taskId link.
|
||||||
* Reads task data from the current team in the store.
|
* Reads task data from the current team in the store.
|
||||||
*/
|
*/
|
||||||
export const TaskTooltip = ({
|
export const TaskTooltip = memo(function TaskTooltip({
|
||||||
taskId,
|
taskId,
|
||||||
teamName,
|
teamName,
|
||||||
children,
|
children,
|
||||||
side = 'top',
|
side = 'top',
|
||||||
}: TaskTooltipProps): React.JSX.Element => {
|
}: TaskTooltipProps): React.JSX.Element {
|
||||||
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
||||||
useStore(
|
useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
|
|
@ -194,4 +194,4 @@ export const TaskTooltip = ({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue