perf(renderer): performance improvements — React.memo + test stabilization Merge pull request #93 from sardorb3k/improve/v1.3

Merge pull request #93 from sardorb3k/improve/v1.3
This commit is contained in:
Илия 2026-05-03 13:02:14 +03:00 committed by GitHub
commit e21d89a057
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2975 additions and 2586 deletions

View file

@ -178,7 +178,9 @@ export class ScheduledTaskExecutor {
cwd: request.config.cwd, cwd: request.config.cwd,
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
// take precedence over the cached snapshot inside buildEnrichedEnv. // take precedence over the cached snapshot inside buildEnrichedEnv.
env, // CLAUDECODE stripped last to prevent nested-session detection regardless
// of what buildProviderAwareCliEnv merges in.
env: { ...env, CLAUDECODE: undefined },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });

View file

@ -154,6 +154,10 @@ async function hasValidServerCopy(dir: string): Promise<boolean> {
let _resolvedNodePath: string | undefined; let _resolvedNodePath: string | undefined;
export function clearResolvedNodePathForTests(): void {
_resolvedNodePath = undefined;
}
/** /**
* Find the real `node` binary path. In Electron, process.execPath is the * Find the real `node` binary path. In Electron, process.execPath is the
* Electron binary NOT node so we must resolve node separately. * Electron binary NOT node so we must resolve node separately.

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { memo, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { import {
@ -28,9 +28,9 @@ interface CompactBoundaryProps {
* CompactBoundary displays a horizontal divider indicating where * CompactBoundary displays a horizontal divider indicating where
* the conversation was compacted. Click to expand the compacted summary. * the conversation was compacted. Click to expand the compacted summary.
*/ */
export const CompactBoundary = ({ export const CompactBoundary = memo(function CompactBoundary({
compactGroup, compactGroup,
}: Readonly<CompactBoundaryProps>): React.JSX.Element => { }: Readonly<CompactBoundaryProps>): React.JSX.Element {
const { timestamp, message } = compactGroup; const { timestamp, message } = compactGroup;
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@ -166,4 +166,4 @@ export const CompactBoundary = ({
)} )}
</div> </div>
); );
}; });

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { memo, useCallback, useState } from 'react';
import { import {
CODE_BG, CODE_BG,
@ -65,9 +65,6 @@ function buildItemMetaTooltip(
return parts.length > 0 ? parts.join(' • ') : undefined; return parts.length > 0 ? parts.join(' • ') : undefined;
} }
/**
* Truncates text to a maximum length and adds ellipsis if needed.
*/
function truncateText(text: string, maxLength: number): string { function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) { if (text.length <= maxLength) {
return text; return text;
@ -75,6 +72,345 @@ function truncateText(text: string, maxLength: number): string {
return text.substring(0, maxLength) + '...'; return text.substring(0, maxLength) + '...';
} }
function getItemKey(item: AIGroupDisplayItem, index: number): string {
switch (item.type) {
case 'thinking':
return `thinking-${index}`;
case 'output':
return `output-${index}`;
case 'tool':
return `tool-${item.tool.id}-${index}`;
case 'subagent':
return `subagent-${item.subagent.id}-${index}`;
case 'slash':
return `slash-${item.slash.name}-${index}`;
case 'teammate_message':
return `teammate-${item.teammateMessage.id}-${index}`;
case 'subagent_input':
return `input-${index}`;
case 'compact_boundary':
return `compact-${index}`;
default:
return `unknown-${index}`;
}
}
// =============================================================================
// Per-item row — memoized to prevent re-renders from parent state changes
// =============================================================================
interface DisplayItemRowProps {
item: AIGroupDisplayItem;
index: number;
itemKey: string;
isExpanded: boolean;
isDimmed: boolean;
hasReplyLink: boolean;
onItemClick: (key: string) => void;
onReplyHover: (toolId: string | null) => void;
aiGroupId: string;
searchQueryOverride?: string;
highlightToolUseId?: string;
highlightColor?: TriggerColor;
notificationColorMap?: Map<string, TriggerColor>;
registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void;
previewMaxLength?: number;
timestampFormat?: string;
showItemMetaTooltip?: boolean;
}
const DisplayItemRow = memo(function DisplayItemRow({
item,
index: _index,
itemKey,
isExpanded,
isDimmed,
hasReplyLink,
onItemClick,
onReplyHover,
aiGroupId,
searchQueryOverride,
highlightToolUseId,
highlightColor,
notificationColorMap,
registerToolRef,
previewMaxLength,
timestampFormat,
showItemMetaTooltip = false,
}: DisplayItemRowProps): React.JSX.Element | null {
const handleClick = useCallback(() => onItemClick(itemKey), [onItemClick, itemKey]);
let element: React.ReactNode = null;
switch (item.type) {
case 'thinking': {
const thinkingStep = {
id: itemKey,
type: 'thinking' as const,
startTime: item.timestamp,
endTime: item.timestamp,
durationMs: 0,
content: { thinkingText: item.content, tokenCount: item.tokenCount },
tokens: { input: 0, output: item.tokenCount ?? 0 },
context: 'main' as const,
};
element = (
<ThinkingItem
step={thinkingStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={handleClick}
isExpanded={isExpanded}
timestamp={item.timestamp}
timestampFormat={timestampFormat}
titleText={
showItemMetaTooltip
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
: undefined
}
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
);
break;
}
case 'output': {
const textStep = {
id: itemKey,
type: 'output' as const,
startTime: item.timestamp,
endTime: item.timestamp,
durationMs: 0,
content: { outputText: item.content, tokenCount: item.tokenCount },
tokens: { input: 0, output: item.tokenCount ?? 0 },
context: 'main' as const,
};
element = (
<TextItem
step={textStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={handleClick}
isExpanded={isExpanded}
timestamp={item.timestamp}
timestampFormat={timestampFormat}
titleText={
showItemMetaTooltip
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
: undefined
}
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
);
break;
}
case 'tool': {
element = (
<LinkedToolItem
linkedTool={item.tool}
onClick={handleClick}
isExpanded={isExpanded}
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': {
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={handleClick}
isExpanded={isExpanded}
aiGroupId={aiGroupId}
highlightToolUseId={highlightToolUseId}
highlightColor={highlightColor}
notificationColorMap={notificationColorMap}
registerToolRef={registerToolRef}
/>
);
break;
}
case 'slash': {
element = (
<SlashItem
slash={item.slash}
onClick={handleClick}
isExpanded={isExpanded}
timestamp={item.slash.timestamp}
timestampFormat={timestampFormat}
titleText={
showItemMetaTooltip
? buildItemMetaTooltip(
item.slash.timestamp,
item.slash.instructionsTokenCount,
'tokens'
)
: undefined
}
/>
);
break;
}
case 'teammate_message': {
element = (
<TeammateMessageItem
teammateMessage={item.teammateMessage}
onClick={handleClick}
isExpanded={isExpanded}
onReplyHover={onReplyHover}
/>
);
break;
}
case 'subagent_input': {
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={handleClick}
isExpanded={isExpanded}
>
<MarkdownViewer
content={inputContent}
copyable
itemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
break;
}
case 'compact_boundary': {
const compactContent = item.content;
element = (
<div>
<button
onClick={handleClick}
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>
{isExpanded && 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;
}
return (
<div
style={
hasReplyLink ? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' } : undefined
}
>
{element}
</div>
);
});
// =============================================================================
// Main component
// =============================================================================
/** /**
* Renders a flat list of AIGroupDisplayItem[] into the appropriate components. * Renders a flat list of AIGroupDisplayItem[] into the appropriate components.
* *
@ -87,7 +423,7 @@ function truncateText(text: string, maxLength: number): string {
* *
* The list is completely flat with no nested toggles or hierarchies. * The list is completely flat with no nested toggles or hierarchies.
*/ */
export const DisplayItemList = ({ export const DisplayItemList = React.memo(function DisplayItemList({
items, items,
onItemClick, onItemClick,
expandedItemIds, expandedItemIds,
@ -101,23 +437,13 @@ export const DisplayItemList = ({
previewMaxLength, previewMaxLength,
timestampFormat, timestampFormat,
showItemMetaTooltip = false, showItemMetaTooltip = false,
}: Readonly<DisplayItemListProps>): React.JSX.Element => { }: Readonly<DisplayItemListProps>): React.JSX.Element {
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null); const [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 */
const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => {
if (!replyLinkToolId) return false;
if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true;
if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId)
return true;
return false;
};
if (!items || items.length === 0) { if (!items || items.length === 0) {
return ( return (
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary"> <div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
@ -133,299 +459,39 @@ export const DisplayItemList = ({
} }
> >
{items.map((item, index) => { {items.map((item, index) => {
let itemKey = ''; const itemKey = getItemKey(item, index);
let element: React.ReactNode = null; const isExpanded = expandedItemIds.has(itemKey);
switch (item.type) { const isInReplyLink =
case 'thinking': { replyLinkToolId !== null &&
itemKey = `thinking-${index}`; ((item.type === 'tool' && item.tool.id === replyLinkToolId) ||
const thinkingStep = { (item.type === 'teammate_message' &&
id: itemKey, item.teammateMessage.replyToToolId === replyLinkToolId));
type: 'thinking' as const, const isDimmed = replyLinkToolId !== null && !isInReplyLink;
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 ( return (
<div <DisplayItemRow
key={itemKey} key={itemKey}
style={ item={item}
replyLinkToolId !== null index={index}
? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' } itemKey={itemKey}
: undefined isExpanded={isExpanded}
} isDimmed={isDimmed}
> hasReplyLink={replyLinkToolId !== null}
{element} onItemClick={onItemClick}
</div> onReplyHover={handleReplyHover}
aiGroupId={aiGroupId}
searchQueryOverride={searchQueryOverride}
highlightToolUseId={highlightToolUseId}
highlightColor={highlightColor}
notificationColorMap={notificationColorMap}
registerToolRef={registerToolRef}
previewMaxLength={previewMaxLength}
timestampFormat={timestampFormat}
showItemMetaTooltip={showItemMetaTooltip}
/>
); );
})} })}
</div> </div>
); );
}; });

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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 &rarr;{' '}
<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 &rarr;{' '}
<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>
);
}
);

View file

@ -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 )}
)} </>
</> );
); }
}; );

View file

@ -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>
); );
}; }
);

File diff suppressed because it is too large Load diff

View file

@ -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>
);
}
);

View file

@ -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>
); );
}; }
);

View file

@ -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>
); );
}; }
);

View file

@ -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>
);
}
);

View file

@ -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
)} )}
</> </>
); );
}; });

View file

@ -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>
); );
}; });

View file

@ -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>
); );
}; });

View file

@ -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>
); );
}; });

View file

@ -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>
); );
}; });

View file

@ -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>
); );
}; });

View file

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react'; import React, { memo, useMemo, useState } from 'react';
import { getBaseName } from '@renderer/utils/pathUtils'; import { getBaseName } from '@renderer/utils/pathUtils';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
@ -117,14 +117,14 @@ function inferLanguage(fileName: string): string {
// Component // Component
// ============================================================================= // =============================================================================
export const CodeBlockViewer: React.FC<CodeBlockViewerProps> = ({ export const CodeBlockViewer = memo(function CodeBlockViewer({
fileName, fileName,
content, content,
language, language,
startLine = 1, startLine = 1,
endLine, endLine,
maxHeight = 'max-h-96', maxHeight = 'max-h-96',
}): React.JSX.Element => { }: CodeBlockViewerProps): React.JSX.Element {
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
// Infer language from file extension if not provided // Infer language from file extension if not provided
@ -241,4 +241,4 @@ export const CodeBlockViewer: React.FC<CodeBlockViewerProps> = ({
</div> </div>
</div> </div>
); );
}; });

View file

@ -1,4 +1,4 @@
import React, { useMemo } from 'react'; import React, { memo, useMemo } from 'react';
import { import {
CODE_BG, CODE_BG,
@ -349,14 +349,14 @@ const DiffLineRow: React.FC<DiffLineRowProps> = ({ line, highlightedHtml }): Rea
// Main Component // Main Component
// ============================================================================= // =============================================================================
export const DiffViewer: React.FC<DiffViewerProps> = ({ export const DiffViewer = memo(function DiffViewer({
fileName, fileName,
oldString, oldString,
newString, newString,
maxHeight = 'max-h-96', maxHeight = 'max-h-96',
tokenCount, tokenCount,
syntaxHighlight = false, syntaxHighlight = false,
}): React.JSX.Element => { }: DiffViewerProps): React.JSX.Element {
// Compute diff // Compute diff
const oldLines = oldString.split(/\r?\n/); const oldLines = oldString.split(/\r?\n/);
const newLines = newString.split(/\r?\n/); const newLines = newString.split(/\r?\n/);
@ -456,4 +456,4 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
</div> </div>
</div> </div>
); );
}; });

View file

@ -946,7 +946,7 @@ export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = Rea
} }
); );
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(function MarkdownViewer({
content, content,
maxHeight = 'max-h-96', maxHeight = 'max-h-96',
className = '', className = '',
@ -958,7 +958,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
baseDir, baseDir,
teamColorByName: providedTeamColorByName, teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick, onTeamClick: providedOnTeamClick,
}) => { }) {
const [showRaw, setShowRaw] = React.useState(false); const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme(); const { isLight } = useTheme();
@ -1169,12 +1169,36 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}> <span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label} {label}
</span> </span>
{copyable && ( <span className="flex-1" />
<> <button
<span className="flex-1" /> type="button"
<CopyButton text={content} inline /> className="text-xs underline"
</> style={{ color: PROSE_LINK }}
)} onClick={() => setShowRaw(true)}
title="Show raw"
>
Show raw
</button>
{copyable && <CopyButton text={content} inline />}
</div>
)}
{/* Show raw toggle for no-label path (skip in bare mode) */}
{!label && !bare && (
<div
className="flex items-center justify-between px-3 py-1 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
<span />
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(true)}
title="Show raw"
>
Show raw
</button>
</div> </div>
)} )}
@ -1195,4 +1219,4 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
</div> </div>
</div> </div>
); );
}; });

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
@ -24,13 +24,18 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog'; import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types'; import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
const LaunchTeamDialog = lazy(() =>
import('@renderer/components/team/dialogs/LaunchTeamDialog').then((m) => ({
default: m.LaunchTeamDialog,
}))
);
// ============================================================================= // =============================================================================
// Constants // Constants
// ============================================================================= // =============================================================================
@ -562,13 +567,17 @@ export const SchedulesView = (): React.JSX.Element => {
</div> </div>
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<LaunchTeamDialog {dialogOpen && (
mode="schedule" <Suspense fallback={null}>
open={dialogOpen} <LaunchTeamDialog
teamName={editingSchedule?.teamName} mode="schedule"
schedule={editingSchedule} open={dialogOpen}
onClose={handleClose} teamName={editingSchedule?.teamName}
/> schedule={editingSchedule}
onClose={handleClose}
/>
</Suspense>
)}
</div> </div>
); );
}; };

View file

@ -4,7 +4,7 @@
* Supports multi-select with bulk actions and hidden session filtering. * Supports multi-select with bulk actions and hidden session filtering.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
@ -184,7 +184,7 @@ function matchesSessionSearch(session: Session, query: string): boolean {
return haystack.includes(query); return haystack.includes(query);
} }
export const DateGroupedSessions = (): React.JSX.Element => { export const DateGroupedSessions = memo((): React.JSX.Element => {
const { const {
sessions, sessions,
selectedSessionId, selectedSessionId,
@ -202,7 +202,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
toggleShowHiddenSessions, toggleShowHiddenSessions,
sidebarSelectedSessionIds, sidebarSelectedSessionIds,
sidebarMultiSelectActive, sidebarMultiSelectActive,
toggleSidebarSessionSelection,
clearSidebarSelection, clearSidebarSelection,
toggleSidebarMultiSelect, toggleSidebarMultiSelect,
hideMultipleSessions, hideMultipleSessions,
@ -239,7 +238,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
toggleShowHiddenSessions: s.toggleShowHiddenSessions, toggleShowHiddenSessions: s.toggleShowHiddenSessions,
sidebarSelectedSessionIds: s.sidebarSelectedSessionIds, sidebarSelectedSessionIds: s.sidebarSelectedSessionIds,
sidebarMultiSelectActive: s.sidebarMultiSelectActive, sidebarMultiSelectActive: s.sidebarMultiSelectActive,
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
clearSidebarSelection: s.clearSidebarSelection, clearSidebarSelection: s.clearSidebarSelection,
toggleSidebarMultiSelect: s.toggleSidebarMultiSelect, toggleSidebarMultiSelect: s.toggleSidebarMultiSelect,
hideMultipleSessions: s.hideMultipleSessions, hideMultipleSessions: s.hideMultipleSessions,
@ -1104,7 +1102,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
isHidden={item.isHidden} isHidden={item.isHidden}
multiSelectActive={sidebarMultiSelectActive} multiSelectActive={sidebarMultiSelectActive}
isSelected={selectedSet.has(item.session.id)} isSelected={selectedSet.has(item.session.id)}
onToggleSelect={() => toggleSidebarSessionSelection(item.session.id)}
/> />
)} )}
</div> </div>
@ -1114,4 +1111,4 @@ export const DateGroupedSessions = (): React.JSX.Element => {
</div> </div>
</div> </div>
); );
}; });

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { confirm } from '@renderer/components/common/ConfirmDialog'; import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -173,13 +173,13 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl
return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized); return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized);
} }
export const GlobalTaskList = ({ export const GlobalTaskList = memo(function GlobalTaskList({
hideHeader = false, hideHeader = false,
filters: externalFilters, filters: externalFilters,
onFiltersChange: externalOnFiltersChange, onFiltersChange: externalOnFiltersChange,
filtersPopoverOpen: externalFiltersPopoverOpen, filtersPopoverOpen: externalFiltersPopoverOpen,
onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange,
}: GlobalTaskListProps = {}): React.JSX.Element => { }: GlobalTaskListProps = {}): React.JSX.Element {
const { const {
globalTasks, globalTasks,
globalTasksLoading, globalTasksLoading,
@ -271,37 +271,43 @@ export const GlobalTaskList = ({
saveSortMode(mode); saveSortMode(mode);
}; };
const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => { const handleRenameComplete = useCallback(
taskLocalState.renameTask(teamName, taskId, newSubject); (teamName: string, taskId: string, newSubject: string): void => {
setRenamingTaskKey(null); taskLocalState.renameTask(teamName, taskId, newSubject);
}; setRenamingTaskKey(null);
},
[taskLocalState]
);
const handleRenameCancel = (): void => { const handleRenameCancel = useCallback((): void => {
setRenamingTaskKey(null); setRenamingTaskKey(null);
}; }, []);
const handleDeleteTask = async (teamName: string, taskId: string): Promise<void> => { const handleDeleteTask = useCallback(
const confirmed = await confirm({ async (teamName: string, taskId: string): Promise<void> => {
title: 'Delete task', const confirmed = await confirm({
message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, title: 'Delete task',
confirmLabel: 'Delete', message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
cancelLabel: 'Cancel', confirmLabel: 'Delete',
variant: 'danger', cancelLabel: 'Cancel',
}); variant: 'danger',
if (confirmed) { });
try { if (confirmed) {
await softDeleteTask(teamName, taskId); try {
await fetchAllTasks(); await softDeleteTask(teamName, taskId);
} catch (err) { await fetchAllTasks();
void confirm({ } catch (err) {
title: 'Failed to delete task', void confirm({
message: err instanceof Error ? err.message : 'An unexpected error occurred', title: 'Failed to delete task',
confirmLabel: 'OK', message: err instanceof Error ? err.message : 'An unexpected error occurred',
variant: 'danger', confirmLabel: 'OK',
}); variant: 'danger',
});
}
} }
} },
}; [fetchAllTasks, softDeleteTask]
);
// Fetch tasks on mount — loading guard in the store action prevents // Fetch tasks on mount — loading guard in the store action prevents
// duplicate IPC calls when the centralized init chain is already fetching. // duplicate IPC calls when the centralized init chain is already fetching.
@ -850,4 +856,4 @@ export const GlobalTaskList = ({
</div> </div>
</div> </div>
); );
}; });

View file

@ -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';
@ -30,7 +30,6 @@ interface SessionItemProps {
isHidden?: boolean; isHidden?: boolean;
multiSelectActive?: boolean; multiSelectActive?: boolean;
isSelected?: boolean; isSelected?: boolean;
onToggleSelect?: () => void;
} }
/** /**
@ -156,15 +155,14 @@ const SessionRuntimeBadge = ({
); );
}; };
export const SessionItem = ({ export const SessionItem = memo(function SessionItem({
session, session,
isActive, isActive,
isPinned, isPinned,
isHidden, isHidden,
multiSelectActive, multiSelectActive,
isSelected, isSelected,
onToggleSelect, }: Readonly<SessionItemProps>): React.JSX.Element {
}: Readonly<SessionItemProps>): React.JSX.Element => {
const { const {
openTab, openTab,
activeProjectId, activeProjectId,
@ -173,6 +171,7 @@ export const SessionItem = ({
splitPane, splitPane,
togglePinSession, togglePinSession,
toggleHideSession, toggleHideSession,
toggleSidebarSessionSelection,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
openTab: s.openTab, openTab: s.openTab,
@ -182,6 +181,7 @@ export const SessionItem = ({
splitPane: s.splitPane, splitPane: s.splitPane,
togglePinSession: s.togglePinSession, togglePinSession: s.togglePinSession,
toggleHideSession: s.toggleHideSession, toggleHideSession: s.toggleHideSession,
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
})) }))
); );
@ -191,8 +191,8 @@ export const SessionItem = ({
if (!activeProjectId) return; if (!activeProjectId) return;
// In multi-select mode, clicks toggle selection // In multi-select mode, clicks toggle selection
if (multiSelectActive && onToggleSelect) { if (multiSelectActive) {
onToggleSelect(); toggleSidebarSessionSelection(session.id);
return; return;
} }
@ -290,7 +290,7 @@ export const SessionItem = ({
<input <input
type="checkbox" type="checkbox"
checked={isSelected ?? false} checked={isSelected ?? false}
onChange={() => onToggleSelect?.()} onChange={() => toggleSidebarSessionSelection(session.id)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-blue-500" className="size-3.5 shrink-0 accent-blue-500"
/> />
@ -390,4 +390,4 @@ export const SessionItem = ({
)} )}
</> </>
); );
}; });

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
@ -69,7 +69,7 @@ interface SidebarTaskItemProps {
getDisplaySubject?: (task: GlobalTask) => string | undefined; getDisplaySubject?: (task: GlobalTask) => string | undefined;
} }
export const SidebarTaskItem = ({ export const SidebarTaskItem = memo(function SidebarTaskItem({
task, task,
hideTeamName, hideTeamName,
showTeamName, showTeamName,
@ -77,7 +77,7 @@ export const SidebarTaskItem = ({
onRenameComplete, onRenameComplete,
onRenameCancel, onRenameCancel,
getDisplaySubject, getDisplaySubject,
}: SidebarTaskItemProps): React.JSX.Element => { }: SidebarTaskItemProps): React.JSX.Element {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
@ -283,4 +283,4 @@ export const SidebarTaskItem = ({
)} )}
</button> </button>
); );
}; });

View file

@ -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>
); );
}; });

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { memo, useMemo } from 'react';
import { import {
getTeamColorSet, getTeamColorSet,
@ -37,81 +37,83 @@ interface MemberBadgeProps {
* When onClick is provided, both avatar and badge are clickable as one unit. * When onClick is provided, both avatar and badge are clickable as one unit.
* Wrapped in MemberHoverCard to show member info on hover. * Wrapped in MemberHoverCard to show member info on hover.
*/ */
export const MemberBadge = ({ export const MemberBadge = memo(
name, ({
color, name,
teamName, color,
size = 'sm', teamName,
hideAvatar, size = 'sm',
onClick, hideAvatar,
disableHoverCard, onClick,
}: MemberBadgeProps): React.JSX.Element => { disableHoverCard,
const colors = getTeamColorSet(color ?? ''); }: MemberBadgeProps): React.JSX.Element => {
const { isLight } = useTheme(); const colors = getTeamColorSet(color ?? '');
const selectedTeamName = useStore((s) => s.selectedTeamName); const { isLight } = useTheme();
const effectiveTeamName = teamName ?? selectedTeamName; const selectedTeamName = useStore((s) => s.selectedTeamName);
const teamMembers = useStore((s) => const effectiveTeamName = teamName ?? selectedTeamName;
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : [] const teamMembers = useStore((s) =>
); effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); );
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18; const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4'; const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]'; const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5'; const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5';
const badgeStyle = { const badgeStyle = {
backgroundColor: getThemedBadge(colors, isLight), backgroundColor: getThemedBadge(colors, isLight),
color: getThemedText(colors, isLight), color: getThemedText(colors, isLight),
border: `1px solid ${getThemedBorder(colors, isLight)}40`, border: `1px solid ${getThemedBorder(colors, isLight)}40`,
}; };
const avatar = ( const avatar = (
<img <img
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)} src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
alt="" alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`} className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy" loading="lazy"
/> />
); );
const badge = ( const badge = (
<span <span
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`} className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
style={badgeStyle} style={badgeStyle}
> >
{displayMemberName(name)} {displayMemberName(name)}
</span> </span>
); );
// Skip hover card for "user" and "system" pseudo-members // Skip hover card for "user" and "system" pseudo-members
const skipHoverCard = disableHoverCard || name === 'user' || name === 'system'; const skipHoverCard = disableHoverCard || name === 'user' || name === 'system';
const content = onClick ? ( const content = onClick ? (
<button <button
type="button" type="button"
className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]" className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClick(name); onClick(name);
}} }}
> >
{!hideAvatar && avatar} {!hideAvatar && avatar}
{badge} {badge}
</button> </button>
) : ( ) : (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{!hideAvatar && avatar} {!hideAvatar && avatar}
{badge} {badge}
</span> </span>
); );
if (skipHoverCard) { if (skipHoverCard) {
return content; return content;
}
return (
<MemberHoverCard name={name} color={color} teamName={teamName}>
{content}
</MemberHoverCard>
);
} }
);
return (
<MemberHoverCard name={name} color={color} teamName={teamName}>
{content}
</MemberHoverCard>
);
};

View file

@ -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>
); );
}; });

View file

@ -78,12 +78,8 @@ import {
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { AddMemberDialog } from './dialogs/AddMemberDialog';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog';
import { LaunchTeamDialog, type TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
import { ReviewDialog } from './dialogs/ReviewDialog'; import { ReviewDialog } from './dialogs/ReviewDialog';
import { SendMessageDialog } from './dialogs/SendMessageDialog';
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow'; import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
import { KanbanBoard } from './kanban/KanbanBoard'; import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
@ -93,9 +89,13 @@ import { MemberDetailDialog } from './members/MemberDetailDialog';
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes'; import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
import type { AddMemberEntry } from './dialogs/AddMemberDialog'; import type { AddMemberEntry } from './dialogs/AddMemberDialog';
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { ComponentProps, CSSProperties } from 'react'; import type { ComponentProps, CSSProperties } from 'react';
const LaunchTeamDialog = lazy(() =>
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
const ProjectEditorOverlay = lazy(() => const ProjectEditorOverlay = lazy(() =>
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
); );
@ -104,9 +104,20 @@ const TeamGraphOverlay = lazy(() =>
default: m.TeamGraphOverlay, default: m.TeamGraphOverlay,
})) }))
); );
const TaskDetailDialog = lazy(() =>
import('./dialogs/TaskDetailDialog').then((m) => ({ default: m.TaskDetailDialog }))
);
const SendMessageDialog = lazy(() =>
import('./dialogs/SendMessageDialog').then((m) => ({ default: m.SendMessageDialog }))
);
const CreateTaskDialog = lazy(() =>
import('./dialogs/CreateTaskDialog').then((m) => ({ default: m.CreateTaskDialog }))
);
const ChangeReviewDialog = lazy(() =>
import('./review/ChangeReviewDialog').then((m) => ({ default: m.ChangeReviewDialog }))
);
import { MemberList } from './members/MemberList'; import { MemberList } from './members/MemberList';
import { MessagesPanel } from './messages/MessagesPanel'; import { MessagesPanel } from './messages/MessagesPanel';
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
import { ScheduleSection } from './schedule/ScheduleSection'; import { ScheduleSection } from './schedule/ScheduleSection';
import { TeamSidebarHost } from './sidebar/TeamSidebarHost'; import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource'; import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
@ -874,10 +885,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
); );
}); });
export const TeamDetailView = ({ export const TeamDetailView = memo(function TeamDetailView({
teamName, teamName,
isPaneFocused = false, isPaneFocused = false,
}: TeamDetailViewProps): React.JSX.Element => { }: TeamDetailViewProps): React.JSX.Element {
const { isLight } = useTheme(); const { isLight } = useTheme();
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null); const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null); const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
@ -2077,18 +2088,22 @@ export const TeamDetailView = ({
</div> </div>
</div> </div>
</div> </div>
<LaunchTeamDialog {launchDialogOpen && (
mode={launchDialogState.mode} <Suspense fallback={null}>
open={launchDialogOpen} <LaunchTeamDialog
teamName={teamName} mode={launchDialogState.mode}
members={[]} open={launchDialogOpen}
defaultProjectPath={draftTeamSummary?.projectPath} teamName={teamName}
provisioningError={provisioningError} members={[]}
clearProvisioningError={clearProvisioningError} defaultProjectPath={draftTeamSummary?.projectPath}
onClose={closeLaunchDialog} provisioningError={provisioningError}
onLaunch={handleLaunchDialogSubmit} clearProvisioningError={clearProvisioningError}
onRelaunch={handleRelaunchDialogSubmit} onClose={closeLaunchDialog}
/> onLaunch={handleLaunchDialogSubmit}
onRelaunch={handleRelaunchDialogSubmit}
/>
</Suspense>
)}
</> </>
); );
} }
@ -2744,21 +2759,25 @@ export const TeamDetailView = ({
}} }}
/> />
<CreateTaskDialog {createTaskDialog.open && (
open={createTaskDialog.open} <Suspense fallback={null}>
teamName={teamName} <CreateTaskDialog
members={activeMembers} open={createTaskDialog.open}
tasks={data.tasks} teamName={teamName}
isTeamAlive={data.isAlive && !isTeamProvisioning} members={activeMembers}
defaultSubject={createTaskDialog.defaultSubject} tasks={data.tasks}
defaultDescription={createTaskDialog.defaultDescription} isTeamAlive={data.isAlive && !isTeamProvisioning}
defaultOwner={createTaskDialog.defaultOwner} defaultSubject={createTaskDialog.defaultSubject}
defaultStartImmediately={createTaskDialog.defaultStartImmediately} defaultDescription={createTaskDialog.defaultDescription}
defaultChip={createTaskDialog.defaultChip} defaultOwner={createTaskDialog.defaultOwner}
onClose={closeCreateTaskDialog} defaultStartImmediately={createTaskDialog.defaultStartImmediately}
onSubmit={handleCreateTask} defaultChip={createTaskDialog.defaultChip}
submitting={creatingTask} onClose={closeCreateTaskDialog}
/> onSubmit={handleCreateTask}
submitting={creatingTask}
/>
</Suspense>
)}
<EditTeamDialog <EditTeamDialog
open={editDialogOpen} open={editDialogOpen}
@ -2864,117 +2883,129 @@ export const TeamDetailView = ({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<LaunchTeamDialog {launchDialogOpen && (
mode={launchDialogState.mode} <Suspense fallback={null}>
open={launchDialogOpen} <LaunchTeamDialog
teamName={teamName} mode={launchDialogState.mode}
members={membersWithLiveBranches} open={launchDialogOpen}
defaultProjectPath={data.config.projectPath} teamName={teamName}
provisioningError={provisioningError} members={membersWithLiveBranches}
clearProvisioningError={clearProvisioningError} defaultProjectPath={data.config.projectPath}
activeTeams={activeTeamsForLaunch} provisioningError={provisioningError}
onClose={closeLaunchDialog} clearProvisioningError={clearProvisioningError}
onLaunch={handleLaunchDialogSubmit} activeTeams={activeTeamsForLaunch}
onRelaunch={handleRelaunchDialogSubmit} onClose={closeLaunchDialog}
/> onLaunch={handleLaunchDialogSubmit}
onRelaunch={handleRelaunchDialogSubmit}
/>
</Suspense>
)}
<SendMessageDialog {sendDialogOpen && (
open={sendDialogOpen} <Suspense fallback={null}>
teamName={teamName} <SendMessageDialog
members={activeMembers} open={sendDialogOpen}
defaultRecipient={sendDialogRecipient} teamName={teamName}
defaultText={sendDialogDefaultText} members={activeMembers}
defaultChip={sendDialogDefaultChip} defaultRecipient={sendDialogRecipient}
quotedMessage={replyQuote} defaultText={sendDialogDefaultText}
isTeamAlive={data.isAlive} defaultChip={sendDialogDefaultChip}
sending={sendingMessage} quotedMessage={replyQuote}
sendError={sendMessageError} isTeamAlive={data.isAlive}
sendWarning={sendMessageWarning} sending={sendingMessage}
sendDebugDetails={sendMessageDebugDetails} sendError={sendMessageError}
lastResult={lastSendMessageResult} sendWarning={sendMessageWarning}
onSend={async (member, text, summary, attachments, actionMode, taskRefs) => { sendDebugDetails={sendMessageDebugDetails}
const sentAtMs = Date.now(); lastResult={lastSendMessageResult}
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); onSend={async (member, text, summary, attachments, actionMode, taskRefs) => {
try { const sentAtMs = Date.now();
const result = await sendTeamMessage(teamName, { setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
member, try {
text, const result = await sendTeamMessage(teamName, {
summary, member,
attachments, text,
actionMode, summary,
taskRefs, attachments,
}); actionMode,
if ( taskRefs,
result?.runtimeDelivery?.attempted === true && });
result.runtimeDelivery.delivered === false if (
) { result?.runtimeDelivery?.attempted === true &&
setPendingRepliesByMember((prev) => { result.runtimeDelivery.delivered === false
if (prev[member] !== sentAtMs) return prev; ) {
const next = { ...prev }; setPendingRepliesByMember((prev) => {
delete next[member]; if (prev[member] !== sentAtMs) return prev;
return next; const next = { ...prev };
}); delete next[member];
} return next;
return result; });
} catch (error) { }
setPendingRepliesByMember((prev) => { return result;
if (prev[member] !== sentAtMs) return prev; } catch (error) {
const next = { ...prev }; setPendingRepliesByMember((prev) => {
delete next[member]; if (prev[member] !== sentAtMs) return prev;
return next; const next = { ...prev };
}); delete next[member];
throw error; return next;
} });
}} throw error;
onClose={() => { }
setSendDialogOpen(false); }}
setReplyQuote(undefined); onClose={() => {
setSendDialogDefaultText(undefined); setSendDialogOpen(false);
setSendDialogDefaultChip(undefined); setReplyQuote(undefined);
}} setSendDialogDefaultText(undefined);
/> setSendDialogDefaultChip(undefined);
}}
/>
</Suspense>
)}
<TaskDetailDialog {selectedTask !== null && (
open={selectedTask !== null} <Suspense fallback={null}>
task={selectedTask} <TaskDetailDialog
teamName={teamName} open={selectedTask !== null}
kanbanTaskState={ task={selectedTask}
selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined teamName={teamName}
} kanbanTaskState={
taskMap={taskMap} selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined
members={activeMembers}
onClose={() => setSelectedTask(null)}
onScrollToTask={(taskId) => {
setSelectedTask(null);
const el = document.querySelector(`[data-task-id="${taskId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
el.classList.remove('kanban-card-focus-pulse');
void (el as HTMLElement).offsetWidth;
el.classList.add('kanban-card-focus-pulse');
el.addEventListener(
'animationend',
() => el.classList.remove('kanban-card-focus-pulse'),
{ once: true }
);
}
}}
onOwnerChange={(taskId, owner) => {
void (async () => {
try {
await updateTaskOwner(teamName, taskId, owner);
} catch {
// error via store
} }
})(); taskMap={taskMap}
}} members={activeMembers}
onViewChanges={handleViewChangesForFile} onClose={() => setSelectedTask(null)}
onOpenInEditor={(filePath) => { onScrollToTask={(taskId) => {
const { revealFileInEditor } = useStore.getState(); setSelectedTask(null);
revealFileInEditor(filePath); const el = document.querySelector(`[data-task-id="${taskId}"]`);
}} if (el) {
onDeleteTask={handleDeleteTask} el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
/> el.classList.remove('kanban-card-focus-pulse');
void (el as HTMLElement).offsetWidth;
el.classList.add('kanban-card-focus-pulse');
el.addEventListener(
'animationend',
() => el.classList.remove('kanban-card-focus-pulse'),
{ once: true }
);
}
}}
onOwnerChange={(taskId, owner) => {
void (async () => {
try {
await updateTaskOwner(teamName, taskId, owner);
} catch {
// error via store
}
})();
}}
onViewChanges={handleViewChangesForFile}
onOpenInEditor={(filePath) => {
const { revealFileInEditor } = useStore.getState();
revealFileInEditor(filePath);
}}
onDeleteTask={handleDeleteTask}
/>
</Suspense>
)}
<TrashDialog <TrashDialog
open={trashOpen} open={trashOpen}
@ -2991,26 +3022,30 @@ export const TeamDetailView = ({
}} }}
/> />
<ChangeReviewDialog {reviewDialogState.open && (
open={reviewDialogState.open} <Suspense fallback={null}>
onOpenChange={(open) => <ChangeReviewDialog
setReviewDialogState((prev) => ({ open={reviewDialogState.open}
...prev, onOpenChange={(open) =>
open, setReviewDialogState((prev) => ({
...(open ...prev,
? {} open,
: { initialFilePath: undefined, taskChangeRequestOptions: undefined }), ...(open
})) ? {}
} : { initialFilePath: undefined, taskChangeRequestOptions: undefined }),
teamName={teamName} }))
mode={reviewDialogState.mode} }
memberName={reviewDialogState.memberName} teamName={teamName}
taskId={reviewDialogState.taskId} mode={reviewDialogState.mode}
initialFilePath={reviewDialogState.initialFilePath} memberName={reviewDialogState.memberName}
taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} taskId={reviewDialogState.taskId}
projectPath={data.config.projectPath} initialFilePath={reviewDialogState.initialFilePath}
onEditorAction={handleEditorAction} taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions}
/> projectPath={data.config.projectPath}
onEditorAction={handleEditorAction}
/>
</Suspense>
)}
</div> </div>
<div <div
ref={setMessagesPanelMountPoint} ref={setMessagesPanelMountPoint}
@ -3077,4 +3112,4 @@ export const TeamDetailView = ({
{renderBody()} {renderBody()}
</> </>
); );
}; });

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
@ -45,8 +45,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
import { TeamEmptyState } from './TeamEmptyState'; import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import { import {
@ -67,6 +65,13 @@ import type {
TeamSummaryMember, TeamSummaryMember,
} from '@shared/types'; } from '@shared/types';
const CreateTeamDialog = lazy(() =>
import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog }))
);
const LaunchTeamDialog = lazy(() =>
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
function generateUniqueName(sourceName: string, existingNames: string[]): string { function generateUniqueName(sourceName: string, existingNames: string[]): string {
const base = sourceName.replace(/-\d+$/, ''); const base = sourceName.replace(/-\d+$/, '');
const existing = new Set(existingNames); const existing = new Set(existingNames);
@ -233,7 +238,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
} }
}; };
export const TeamListView = (): React.JSX.Element => { export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const { isLight } = useTheme(); const { isLight } = useTheme();
const electronMode = isElectronMode(); const electronMode = isElectronMode();
const [showCreateDialog, setShowCreateDialog] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false);
@ -731,36 +736,40 @@ export const TeamListView = (): React.JSX.Element => {
); );
} }
const createDialogElement = ( const createDialogElement = showCreateDialog && (
<CreateTeamDialog <Suspense fallback={null}>
open={showCreateDialog} <CreateTeamDialog
canCreate={canCreate} open={showCreateDialog}
provisioningErrorsByTeam={provisioningErrorByTeam} canCreate={canCreate}
clearProvisioningError={clearProvisioningError} provisioningErrorsByTeam={provisioningErrorByTeam}
existingTeamNames={teams.map((t) => t.teamName)} clearProvisioningError={clearProvisioningError}
provisioningTeamNames={provisioningTeamNames} existingTeamNames={teams.map((t) => t.teamName)}
activeTeams={activeTeams} provisioningTeamNames={provisioningTeamNames}
initialData={copyData ?? undefined} activeTeams={activeTeams}
defaultProjectPath={currentProjectPath} initialData={copyData ?? undefined}
onClose={handleCreateDialogClose} defaultProjectPath={currentProjectPath}
onCreate={handleCreateSubmit} onClose={handleCreateDialogClose}
onOpenTeam={openTeamTab} onCreate={handleCreateSubmit}
/> onOpenTeam={openTeamTab}
/>
</Suspense>
); );
const launchDialogElement = ( const launchDialogElement = launchDialogOpen && (
<LaunchTeamDialog <Suspense fallback={null}>
mode="launch" <LaunchTeamDialog
open={launchDialogOpen} mode="launch"
teamName={launchDialogTeamName} open={launchDialogOpen}
members={launchDialogMembers} teamName={launchDialogTeamName}
defaultProjectPath={launchDialogDefaultPath} members={launchDialogMembers}
provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null} defaultProjectPath={launchDialogDefaultPath}
clearProvisioningError={clearProvisioningError} provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null}
activeTeams={activeTeams} clearProvisioningError={clearProvisioningError}
onClose={() => setLaunchDialogOpen(false)} activeTeams={activeTeams}
onLaunch={handleLaunchSubmit} onClose={() => setLaunchDialogOpen(false)}
/> onLaunch={handleLaunchSubmit}
/>
</Suspense>
); );
const renderHeader = (): React.JSX.Element => ( const renderHeader = (): React.JSX.Element => (
@ -1177,4 +1186,4 @@ export const TeamListView = (): React.JSX.Element => {
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
}; });

View file

@ -1,4 +1,4 @@
import { type ReactNode, useState } from 'react'; import { memo, type ReactNode, useState } from 'react';
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
@ -32,14 +32,14 @@ interface ActivityEntry {
kind: 'working' | 'reviewing'; kind: 'working' | 'reviewing';
} }
export const ActiveTasksBlock = ({ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
members, members,
tasks, tasks,
defaultCollapsed = false, defaultCollapsed = false,
headerRight, headerRight,
onMemberClick, onMemberClick,
onTaskClick, onTaskClick,
}: ActiveTasksBlockProps): React.JSX.Element | null => { }: ActiveTasksBlockProps): React.JSX.Element | null {
const { isLight } = useTheme(); const { isLight } = useTheme();
const [collapsed, setCollapsed] = useState(defaultCollapsed); const [collapsed, setCollapsed] = useState(defaultCollapsed);
const colorMap = buildMemberColorMap(members); const colorMap = buildMemberColorMap(members);
@ -188,4 +188,4 @@ export const ActiveTasksBlock = ({
})} })}
</div> </div>
); );
}; });

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
@ -32,13 +34,13 @@ interface PendingRepliesBlockProps {
onMemberClick?: (member: ResolvedTeamMember) => void; onMemberClick?: (member: ResolvedTeamMember) => void;
} }
export const PendingRepliesBlock = ({ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
members, members,
pendingRepliesByMember, pendingRepliesByMember,
pendingCrossTeamReplies = [], pendingCrossTeamReplies = [],
headerRight, headerRight,
onMemberClick, onMemberClick,
}: PendingRepliesBlockProps): React.JSX.Element | null => { }: PendingRepliesBlockProps): React.JSX.Element | null {
const { isLight } = useTheme(); const { isLight } = useTheme();
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals)); const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
const colorMap = buildMemberColorMap(members); const colorMap = buildMemberColorMap(members);
@ -270,4 +272,4 @@ export const PendingRepliesBlock = ({
})} })}
</div> </div>
); );
}; });

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { memo, useState } 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';
@ -20,60 +20,62 @@ interface ReplyQuoteBlockProps {
/** Threshold (characters) above which the "more/less" toggle is shown. */ /** Threshold (characters) above which the "more/less" toggle is shown. */
const LONG_QUOTE_THRESHOLD = 200; const LONG_QUOTE_THRESHOLD = 200;
export const ReplyQuoteBlock = ({ export const ReplyQuoteBlock = memo(
reply, ({
memberColor, reply,
bodyMaxHeight = 'max-h-56', memberColor,
replyTaskRefs, bodyMaxHeight = 'max-h-56',
}: ReplyQuoteBlockProps): React.JSX.Element => { replyTaskRefs,
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; }: ReplyQuoteBlockProps): React.JSX.Element => {
const [expanded, setExpanded] = useState(false); const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
const [expanded, setExpanded] = useState(false);
const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]'; const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]';
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{/* Quote block — styled like SendMessageDialog */} {/* Quote block — styled like SendMessageDialog */}
<div className="relative overflow-hidden rounded-md border border-blue-400/20 bg-blue-100/40 py-2 pl-3 pr-2 dark:border-blue-500/20 dark:bg-blue-950/20"> <div className="relative overflow-hidden rounded-md border border-blue-400/20 bg-blue-100/40 py-2 pl-3 pr-2 dark:border-blue-500/20 dark:bg-blue-950/20">
{/* Decorative quotation mark */} {/* Decorative quotation mark */}
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-600/[0.08] dark:text-blue-400/[0.08]"> <span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-600/[0.08] dark:text-blue-400/[0.08]">
&ldquo; &ldquo;
</span> </span>
{/* "Replying to" + MemberBadge */} {/* "Replying to" + MemberBadge */}
<div className="mb-1 flex items-center gap-1.5"> <div className="mb-1 flex items-center gap-1.5">
<span className="text-[10px] text-blue-600/60 dark:text-blue-300/60">Replying to</span> <span className="text-[10px] text-blue-600/60 dark:text-blue-300/60">Replying to</span>
<MemberBadge name={reply.agentName} color={memberColor} size="sm" /> <MemberBadge name={reply.agentName} color={memberColor} size="sm" />
</div>
{/* Quote text */}
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}>
<MarkdownViewer
content={linkifyTaskIdsInMarkdown(reply.originalText)}
bare
maxHeight={quoteMaxHeight}
/>
</div>
{/* More/less toggle */}
{isLong ? (
<button
type="button"
className="mt-0.5 text-[10px] text-blue-600/60 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'less' : 'more'}
</button>
) : null}
</div> </div>
{/* Quote text */} {/* Reply text */}
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}> <MarkdownViewer
<MarkdownViewer content={linkifyTaskIdsInMarkdown(reply.replyText, replyTaskRefs)}
content={linkifyTaskIdsInMarkdown(reply.originalText)} maxHeight={bodyMaxHeight}
bare copyable
maxHeight={quoteMaxHeight} bare
/> />
</div>
{/* More/less toggle */}
{isLong ? (
<button
type="button"
className="mt-0.5 text-[10px] text-blue-600/60 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'less' : 'more'}
</button>
) : null}
</div> </div>
);
{/* Reply text */} }
<MarkdownViewer );
content={linkifyTaskIdsInMarkdown(reply.replyText, replyTaskRefs)}
maxHeight={bodyMaxHeight}
copyable
bare
/>
</div>
);
};

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
@ -311,7 +311,7 @@ const SortableKanbanTaskCard = ({
); );
}; };
export const KanbanBoard = ({ export const KanbanBoard = memo(function KanbanBoard({
tasks, tasks,
teamName, teamName,
kanbanState, kanbanState,
@ -338,7 +338,7 @@ export const KanbanBoard = ({
onDeleteTask, onDeleteTask,
deletedTaskCount, deletedTaskCount,
onOpenTrash, onOpenTrash,
}: KanbanBoardProps): React.JSX.Element => { }: KanbanBoardProps): React.JSX.Element {
const boardRef = useRef<HTMLDivElement>(null); const boardRef = useRef<HTMLDivElement>(null);
const scrollRestoreTimeoutsRef = useRef<number[]>([]); const scrollRestoreTimeoutsRef = useRef<number[]>([]);
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid'); const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
@ -422,101 +422,119 @@ export const KanbanBoard = ({
[onColumnOrderChange, groupedOrdered] [onColumnOrderChange, groupedOrdered]
); );
const renderCards = ( const renderCards = useCallback(
columnId: KanbanColumnId, (columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean): React.JSX.Element => {
columnTasks: TeamTask[], const addHandler =
compact?: boolean onAddTask && columnId === 'todo'
): React.JSX.Element => { ? () => onAddTask(false)
const addHandler = : onAddTask && columnId === 'in_progress'
onAddTask && columnId === 'todo' ? () => onAddTask(true)
? () => onAddTask(false) : undefined;
: onAddTask && columnId === 'in_progress'
? () => onAddTask(true)
: undefined;
const addButton = addHandler ? ( const addButton = addHandler ? (
<button <button
type="button" type="button"
onClick={addHandler} onClick={addHandler}
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]" className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
> >
<Plus size={13} /> <Plus size={13} />
Add task Add task
</button> </button>
) : null; ) : null;
if (columnTasks.length === 0) { if (columnTasks.length === 0) {
return ( return (
addButton ?? ( addButton ?? (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]"> <div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks No tasks
</div> </div>
) )
); );
} }
if (enableTaskSorting) { if (enableTaskSorting) {
const itemIds = columnTasks.map((t) => t.id); const itemIds = columnTasks.map((t) => t.id);
return (
<>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{columnTasks.map((task) => (
<SortableKanbanTaskCard
key={task.id}
task={task}
columnId={columnId}
teamName={teamName}
kanbanState={kanbanState}
compact={compact}
taskMap={taskMap}
memberColorMap={memberColorMap}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
))}
</SortableContext>
{addButton}
</>
);
}
return ( return (
<> <>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}> {columnTasks.map((task) => (
{columnTasks.map((task) => ( <KanbanTaskCard
<SortableKanbanTaskCard key={task.id}
key={task.id} task={task}
task={task} teamName={teamName}
columnId={columnId} columnId={columnId}
teamName={teamName} kanbanTaskState={kanbanState.tasks[task.id]}
kanbanState={kanbanState} hasReviewers={hasReviewers}
compact={compact} compact={compact}
taskMap={taskMap} taskMap={taskMap}
memberColorMap={memberColorMap} memberColorMap={memberColorMap}
onRequestReview={onRequestReview} onRequestReview={onRequestReview}
onApprove={onApprove} onApprove={onApprove}
onRequestChanges={onRequestChanges} onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone} onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask} onStartTask={onStartTask}
onCompleteTask={onCompleteTask} onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask} onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask} onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
onViewChanges={onViewChanges} onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask} onDeleteTask={onDeleteTask}
/> />
))} ))}
</SortableContext>
{addButton} {addButton}
</> </>
); );
} },
return ( [
<> enableTaskSorting,
{columnTasks.map((task) => ( hasReviewers,
<KanbanTaskCard kanbanState,
key={task.id} memberColorMap,
task={task} onAddTask,
teamName={teamName} onApprove,
columnId={columnId} onCancelTask,
kanbanTaskState={kanbanState.tasks[task.id]} onCompleteTask,
hasReviewers={hasReviewers} onDeleteTask,
compact={compact} onMoveBackToDone,
taskMap={taskMap} onRequestChanges,
memberColorMap={memberColorMap} onRequestReview,
onRequestReview={onRequestReview} onScrollToTask,
onApprove={onApprove} onStartTask,
onRequestChanges={onRequestChanges} onTaskClick,
onMoveBackToDone={onMoveBackToDone} onViewChanges,
onStartTask={onStartTask} taskMap,
onCompleteTask={onCompleteTask} teamName,
onCancelTask={onCancelTask} ]
onScrollToTask={onScrollToTask} );
onTaskClick={onTaskClick}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
))}
{addButton}
</>
);
};
const visibleColumns = useMemo( const visibleColumns = useMemo(
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
@ -591,6 +609,29 @@ export const KanbanBoard = ({
[scheduleScrollRestore, viewMode] [scheduleScrollRestore, viewMode]
); );
const gridColumns = useMemo(
() =>
visibleColumns.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return {
id: column.id,
title: column.title,
count: columnTasks.length,
icon: accent.icon,
headerBg: accent.headerBg,
bodyBg: accent.bodyBg,
content: renderCards(column.id, columnTasks),
showAddButton: columnSupportsAddButton(column.id, onAddTask),
skeletonCards: columnTasks.map((task) => ({
key: task.id,
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers),
})),
};
}),
[visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers]
);
const boardContent = ( const boardContent = (
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden"> <div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
<div <div
@ -682,25 +723,7 @@ export const KanbanBoard = ({
primaryColumnId={primaryVisibleColumnId} primaryColumnId={primaryVisibleColumnId}
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
skeletonDelayMs={gridSkeletonDelayMs} skeletonDelayMs={gridSkeletonDelayMs}
columns={visibleColumns.map((column) => { columns={gridColumns}
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return {
id: column.id,
title: column.title,
count: columnTasks.length,
icon: accent.icon,
headerBg: accent.headerBg,
bodyBg: accent.bodyBg,
content: renderCards(column.id, columnTasks),
showAddButton: columnSupportsAddButton(column.id, onAddTask),
skeletonCards: columnTasks.map((task) => ({
key: task.id,
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers),
})),
};
})}
/> />
) : ( ) : (
<div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden px-1 pb-6 pr-4 pt-2"> <div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden px-1 pb-6 pr-4 pt-2">
@ -752,4 +775,4 @@ export const KanbanBoard = ({
} }
return boardContent; return boardContent;
}; });

View file

@ -1,5 +1,5 @@
/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ /* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
@ -387,74 +387,76 @@ const LoadedKanbanGridLayout = ({
); );
}; };
export const KanbanGridLayout = ({ export const KanbanGridLayout = memo(
columns, ({
allColumnIds, columns,
primaryColumnId, allColumnIds,
onPrimaryColumnWidthChange, primaryColumnId,
skeletonDelayMs = SKELETON_HIDE_DELAY_MS, onPrimaryColumnWidthChange,
}: KanbanGridLayoutProps): React.JSX.Element => { skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); }: KanbanGridLayoutProps): React.JSX.Element => {
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
scopeKey: GRID_SCOPE_KEY, const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
allItemIds: allColumnIds, scopeKey: GRID_SCOPE_KEY,
visibleItemIds: visibleColumnIds, allItemIds: allColumnIds,
cols: GRID_COLS, visibleItemIds: visibleColumnIds,
repository: browserGridLayoutRepository, cols: GRID_COLS,
buildDefaultItems, repository: browserGridLayoutRepository,
}); buildDefaultItems,
const [showResolvedLayout, setShowResolvedLayout] = useState(false); });
const [showResolvedLayout, setShowResolvedLayout] = useState(false);
useEffect(() => { useEffect(() => {
if (showResolvedLayout) return; if (showResolvedLayout) return;
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
setShowResolvedLayout(true); setShowResolvedLayout(true);
}, skeletonDelayMs); }, skeletonDelayMs);
return () => { return () => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
}; };
}, [showResolvedLayout, skeletonDelayMs]); }, [showResolvedLayout, skeletonDelayMs]);
const applyReactGridLayout = useCallback( const applyReactGridLayout = useCallback(
(layout: Layout, options?: { persist?: boolean }) => { (layout: Layout, options?: { persist?: boolean }) => {
if (options?.persist) { if (options?.persist) {
applyVisibleItems(fromReactGridLayout(layout), options); applyVisibleItems(fromReactGridLayout(layout), options);
} }
}, },
[applyVisibleItems] [applyVisibleItems]
); );
const showSkeletonOverlay = !showResolvedLayout || !isLoaded; const showSkeletonOverlay = !showResolvedLayout || !isLoaded;
const gridKey = visibleItems.map((item) => item.id).join('|'); const gridKey = visibleItems.map((item) => item.id).join('|');
return ( return (
<div className="relative min-w-0 max-w-full"> <div className="relative min-w-0 max-w-full">
<LoadedKanbanGridLayout <LoadedKanbanGridLayout
key={gridKey} key={gridKey}
columns={columns}
visibleItems={visibleItems}
onPersistLayout={applyReactGridLayout}
primaryColumnId={primaryColumnId}
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
className={cn(
'transition-opacity duration-150',
showSkeletonOverlay ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
/>
{showSkeletonOverlay ? (
<LoadingKanbanGridLayout
columns={columns} columns={columns}
visibleItems={visibleItems} visibleItems={visibleItems}
onPersistLayout={applyReactGridLayout}
primaryColumnId={primaryColumnId} primaryColumnId={primaryColumnId}
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange} onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
className="pointer-events-none absolute inset-0 z-10" className={cn(
'transition-opacity duration-150',
showSkeletonOverlay ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
/> />
) : null} {showSkeletonOverlay ? (
</div> <LoadingKanbanGridLayout
); columns={columns}
}; visibleItems={visibleItems}
primaryColumnId={primaryColumnId}
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
className="pointer-events-none absolute inset-0 z-10"
/>
) : null}
</div>
);
}
);
export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH }; export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH };
/* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */ /* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -15,42 +17,44 @@ interface CurrentTaskIndicatorProps {
* Inline indicator showing a spinning loader + "working on" + task label button. * Inline indicator showing a spinning loader + "working on" + task label button.
* Shared between MemberCard and MemberHoverCard. * Shared between MemberCard and MemberHoverCard.
*/ */
export const CurrentTaskIndicator = ({ export const CurrentTaskIndicator = memo(
task, ({
borderColor, task,
maxSubjectLength, borderColor,
activityLabel = 'working on', maxSubjectLength,
onOpenTask, activityLabel = 'working on',
}: CurrentTaskIndicatorProps): React.JSX.Element => { onOpenTask,
const subjectText = }: CurrentTaskIndicatorProps): React.JSX.Element => {
typeof maxSubjectLength === 'number' && const subjectText =
maxSubjectLength > 0 && typeof maxSubjectLength === 'number' &&
task.subject.length > maxSubjectLength maxSubjectLength > 0 &&
? `${task.subject.slice(0, maxSubjectLength)}` task.subject.length > maxSubjectLength
: task.subject; ? `${task.subject.slice(0, maxSubjectLength)}`
: task.subject;
return ( return (
<div className="flex min-w-0 flex-1 items-center gap-1.5"> <div className="flex min-w-0 flex-1 items-center gap-1.5">
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} /> <SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span> <span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
<button <button
type="button" type="button"
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]" className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
title="Open task" title="Open task"
onClick={(e) => { onClick={(e) => {
e.stopPropagation();
onOpenTask?.();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onOpenTask?.(); onOpenTask?.();
} }}
}} onKeyDown={(e) => {
> if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
{formatTaskDisplayLabel(task)} {subjectText} e.preventDefault();
</button> e.stopPropagation();
</div> onOpenTask?.();
); }
}; }}
>
{formatTaskDisplayLabel(task)} {subjectText}
</button>
</div>
);
}
);

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { memo, useMemo, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
@ -91,7 +91,7 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
}; };
} }
export const MemberCard = ({ export const MemberCard = memo(function MemberCard({
member, member,
memberColor, memberColor,
runtimeSummary, runtimeSummary,
@ -119,7 +119,7 @@ export const MemberCard = ({
onAssignTask, onAssignTask,
onRestartMember, onRestartMember,
onSkipMemberForLaunch, onSkipMemberForLaunch,
}: MemberCardProps): React.JSX.Element => { }: MemberCardProps): React.JSX.Element {
// NOTE: lead context display disabled — usage formula is inaccurate // NOTE: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName); // const teamName = useStore((s) => s.selectedTeamName);
// const leadContext = useStore((s) => // const leadContext = useStore((s) =>
@ -711,4 +711,4 @@ export const MemberCard = ({
</div> </div>
</div> </div>
); );
}; });

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card';
import { import {
@ -57,13 +59,13 @@ interface MemberHoverCardProps {
* Reads member data from the team snapshot + resolved member selectors. * Reads member data from the team snapshot + resolved member selectors.
* Falls back to a simple wrapper when member data is unavailable. * Falls back to a simple wrapper when member data is unavailable.
*/ */
export const MemberHoverCard = ({ export const MemberHoverCard = memo(function MemberHoverCard({
name, name,
color, color,
teamName, teamName,
onOpenTask, onOpenTask,
children, children,
}: MemberHoverCardProps): React.JSX.Element => { }: MemberHoverCardProps): React.JSX.Element {
const { isLight } = useTheme(); const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName); const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName; const effectiveTeamName = teamName ?? selectedTeamName;
@ -289,4 +291,4 @@ export const MemberHoverCard = ({
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
); );
}; });

View file

@ -10,6 +10,9 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { import type {
LeadActivityState, LeadActivityState,
MemberLaunchState,
MemberSpawnLivenessSource,
MemberSpawnStatus,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
ResolvedTeamMember, ResolvedTeamMember,
TeamAgentRuntimeEntry, TeamAgentRuntimeEntry,
@ -255,6 +258,115 @@ function areMemberListPropsEqual(
); );
} }
// ---------------------------------------------------------------------------
// Per-member row wrapper — creates stable callbacks so MemberCard memo holds
// ---------------------------------------------------------------------------
interface MemberCardRowProps {
member: ResolvedTeamMember;
isRemoved: boolean;
memberColor: string;
currentTask: TeamTaskWithKanban | null;
reviewTask: TeamTaskWithKanban | null;
awaitingReply: boolean;
taskCounts?: TaskStatusCounts | null;
runtimeSummary?: string;
runtimeEntry?: TeamAgentRuntimeEntry;
runtimeRunId?: string | null;
spawnStatus?: MemberSpawnStatus;
spawnEntry?: MemberSpawnStatusEntry;
spawnError?: string;
spawnLivenessSource?: MemberSpawnLivenessSource;
spawnLaunchState?: MemberLaunchState;
spawnRuntimeAlive?: boolean;
isTeamAlive?: boolean;
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
isLaunchSettling?: boolean;
onOpenTask?: (taskId: string) => void;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
onRestartMember?: (memberName: string) => Promise<void> | void;
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
}
const MemberCardRow = memo(function MemberCardRow({
member,
isRemoved,
memberColor,
currentTask,
reviewTask,
awaitingReply,
taskCounts,
runtimeSummary,
runtimeEntry,
runtimeRunId,
spawnStatus,
spawnEntry,
spawnError,
spawnLivenessSource,
spawnLaunchState,
spawnRuntimeAlive,
isTeamAlive,
isTeamProvisioning,
leadActivity,
isLaunchSettling,
onOpenTask,
onMemberClick,
onSendMessage,
onAssignTask,
onRestartMember,
onSkipMemberForLaunch,
}: MemberCardRowProps): React.JSX.Element {
const currentTaskId = currentTask?.id;
const reviewTaskId = reviewTask?.id;
const handleOpenTask = useCallback(() => {
if (currentTaskId) onOpenTask?.(currentTaskId);
}, [onOpenTask, currentTaskId]);
const handleOpenReviewTask = useCallback(() => {
if (reviewTaskId) onOpenTask?.(reviewTaskId);
}, [onOpenTask, reviewTaskId]);
const handleClick = useCallback(() => onMemberClick?.(member), [onMemberClick, member]);
const handleSendMessage = useCallback(() => onSendMessage?.(member), [onSendMessage, member]);
const handleAssignTask = useCallback(() => onAssignTask?.(member), [onAssignTask, member]);
return (
<MemberCard
member={member}
memberColor={memberColor}
taskCounts={taskCounts}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={isLeadMember(member) ? leadActivity : undefined}
currentTask={currentTask}
reviewTask={reviewTask}
isAwaitingReply={awaitingReply}
isRemoved={isRemoved}
runtimeSummary={runtimeSummary}
runtimeEntry={runtimeEntry}
runtimeRunId={runtimeRunId}
spawnStatus={spawnStatus}
spawnEntry={spawnEntry}
spawnError={spawnError}
spawnLivenessSource={spawnLivenessSource}
spawnLaunchState={spawnLaunchState}
spawnRuntimeAlive={spawnRuntimeAlive}
isLaunchSettling={isLaunchSettling}
onOpenTask={currentTask ? handleOpenTask : undefined}
onOpenReviewTask={reviewTask ? handleOpenReviewTask : undefined}
onClick={handleClick}
onSendMessage={handleSendMessage}
onAssignTask={handleAssignTask}
onRestartMember={onRestartMember}
onSkipMemberForLaunch={onSkipMemberForLaunch}
/>
);
});
export const MemberList = memo(function MemberList({ export const MemberList = memo(function MemberList({
members, members,
memberTaskCounts, memberTaskCounts,
@ -340,63 +452,89 @@ export const MemberList = memo(function MemberList({
return result; return result;
}, [taskMap]); }, [taskMap]);
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
const reviewTask =
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]);
const spawnEntry = memberSpawnStatuses?.get(member.name);
const runtimeEntry = memberRuntimeEntries?.get(member.name);
return (
<MemberCard
key={member.name}
member={member}
memberColor={colorMap.get(member.name) ?? 'blue'}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={isLeadMember(member) ? leadActivity : undefined}
currentTask={isRemoved ? null : currentTask}
reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
runtimeSummary={buildRuntimeSummary(
member,
isRemoved ? undefined : spawnEntry,
isRemoved ? undefined : runtimeEntry
)}
runtimeEntry={isRemoved ? undefined : runtimeEntry}
runtimeRunId={isRemoved ? undefined : runtimeRunId}
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnEntry={isRemoved ? undefined : spawnEntry}
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
isLaunchSettling={isRemoved ? false : isLaunchSettling}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask.id) : undefined}
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}
onRestartMember={isRemoved ? undefined : onRestartMember}
onSkipMemberForLaunch={isRemoved ? undefined : onSkipMemberForLaunch}
/>
);
};
return ( return (
<div ref={containerRef} className="flex flex-col gap-1"> <div ref={containerRef} className="flex flex-col gap-1">
<div className={gridClass}>{activeMembers.map((member) => renderCard(member, false))}</div> <div className={gridClass}>
{activeMembers.map((member) => {
const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
const reviewTask =
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
const spawnEntry = memberSpawnStatuses?.get(member.name);
const runtimeEntry = memberRuntimeEntries?.get(member.name);
return (
<MemberCardRow
key={member.name}
member={member}
isRemoved={false}
memberColor={colorMap.get(member.name) ?? 'blue'}
currentTask={currentTask}
reviewTask={reviewTask}
awaitingReply={
isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name])
}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
runtimeSummary={buildRuntimeSummary(member, spawnEntry, runtimeEntry)}
runtimeEntry={runtimeEntry}
runtimeRunId={runtimeRunId}
spawnStatus={spawnEntry?.status}
spawnEntry={spawnEntry}
spawnError={spawnEntry?.error ?? spawnEntry?.hardFailureReason}
spawnLivenessSource={spawnEntry?.livenessSource}
spawnLaunchState={spawnEntry?.launchState}
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity}
isLaunchSettling={isLaunchSettling}
onOpenTask={onOpenTask}
onMemberClick={onMemberClick}
onSendMessage={onSendMessage}
onAssignTask={onAssignTask}
onRestartMember={onRestartMember}
onSkipMemberForLaunch={onSkipMemberForLaunch}
/>
);
})}
</div>
{removedMembers.length > 0 && ( {removedMembers.length > 0 && (
<> <>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]"> <div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Removed ({removedMembers.length}) Removed ({removedMembers.length})
</div> </div>
<div className={gridClass}> <div className={gridClass}>
{removedMembers.map((member) => renderCard(member, true))} {removedMembers.map((member) => (
<MemberCardRow
key={member.name}
member={member}
isRemoved={true}
memberColor={colorMap.get(member.name) ?? 'blue'}
currentTask={null}
reviewTask={null}
awaitingReply={false}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
runtimeSummary={buildRuntimeSummary(member, undefined, undefined)}
runtimeEntry={undefined}
runtimeRunId={undefined}
spawnStatus={undefined}
spawnEntry={undefined}
spawnError={undefined}
spawnLivenessSource={undefined}
spawnLaunchState={undefined}
spawnRuntimeAlive={undefined}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity}
isLaunchSettling={false}
onOpenTask={onOpenTask}
onMemberClick={onMemberClick}
onSendMessage={onSendMessage}
onAssignTask={onAssignTask}
onRestartMember={undefined}
onSkipMemberForLaunch={undefined}
/>
))}
</div> </div>
</> </>
)} )}

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle'; import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
@ -8,21 +10,20 @@ interface MemberPresenceDotProps {
label: string; label: string;
} }
export const MemberPresenceDot = ({ export const MemberPresenceDot = memo(
className, ({ className, label }: MemberPresenceDotProps): React.JSX.Element => {
label, const shouldSyncPulse = className?.includes('animate-pulse') === true;
}: MemberPresenceDotProps): React.JSX.Element => { const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
const shouldSyncPulse = className?.includes('animate-pulse') === true;
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
return ( return (
<span <span
className={cn( className={cn(
'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]', 'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]',
className className
)} )}
style={syncedPulseStyle} style={syncedPulseStyle}
aria-label={label} aria-label={label}
/> />
); );
}; }
);

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
@ -18,13 +18,15 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog';
import { ScheduleEmptyState } from './ScheduleEmptyState'; import { ScheduleEmptyState } from './ScheduleEmptyState';
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog'; import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
import { ScheduleRunRow } from './ScheduleRunRow'; import { ScheduleRunRow } from './ScheduleRunRow';
import { ScheduleStatusBadge } from './ScheduleStatusBadge'; import { ScheduleStatusBadge } from './ScheduleStatusBadge';
const LaunchTeamDialog = lazy(() =>
import('../dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
import type { Schedule, ScheduleRun } from '@shared/types'; import type { Schedule, ScheduleRun } from '@shared/types';
// ============================================================================= // =============================================================================
@ -305,13 +307,17 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
)} )}
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<LaunchTeamDialog {dialogOpen && (
mode="schedule" <Suspense fallback={null}>
open={dialogOpen} <LaunchTeamDialog
teamName={teamName} mode="schedule"
schedule={editingSchedule} open={dialogOpen}
onClose={handleClose} teamName={teamName}
/> schedule={editingSchedule}
onClose={handleClose}
/>
</Suspense>
)}
</div> </div>
); );
}; };

View file

@ -1,3 +1,5 @@
import { memo } from 'react';
import { import {
KANBAN_COLUMN_DISPLAY, KANBAN_COLUMN_DISPLAY,
REVIEW_STATE_DISPLAY, REVIEW_STATE_DISPLAY,
@ -12,7 +14,7 @@ interface TaskRowProps {
task: TeamTaskWithKanban; task: TeamTaskWithKanban;
} }
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const kanbanColumn = getTaskKanbanColumn(task); const kanbanColumn = getTaskKanbanColumn(task);
@ -62,4 +64,4 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
</td> </td>
</tr> </tr>
); );
}; });

View file

@ -46,7 +46,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
}); });
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; import {
TeamMcpConfigBuilder,
clearResolvedNodePathForTests,
} from '@main/services/team/TeamMcpConfigBuilder';
describe('TeamMcpConfigBuilder', () => { describe('TeamMcpConfigBuilder', () => {
const createdPaths: string[] = []; const createdPaths: string[] = [];
@ -93,7 +96,7 @@ describe('TeamMcpConfigBuilder', () => {
entry: string entry: string
): void { ): void {
expect(server?.args).toEqual([entry]); expect(server?.args).toEqual([entry]);
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/);
} }
function expectNodeTsxSourceEntry( function expectNodeTsxSourceEntry(
@ -102,7 +105,7 @@ describe('TeamMcpConfigBuilder', () => {
sourceEntry: string sourceEntry: string
): void { ): void {
expect(server?.args).toEqual([tsxCli, sourceEntry]); expect(server?.args).toEqual([tsxCli, sourceEntry]);
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/);
} }
function getBuiltWorkspaceEntry(): string { function getBuiltWorkspaceEntry(): string {
@ -165,6 +168,7 @@ describe('TeamMcpConfigBuilder', () => {
} }
beforeEach(() => { beforeEach(() => {
clearResolvedNodePathForTests();
originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-')); tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-'));
createdDirs.push(tempAppData); createdDirs.push(tempAppData);

View file

@ -16,9 +16,10 @@ const hoisted = vi.hoisted(() => {
error.code = 'ENOENT'; error.code = 'ENOENT';
throw error; throw error;
} }
const size = Buffer.byteLength(data, 'utf8');
return { return {
isFile: () => true, isFile: () => true,
size: Buffer.byteLength(data, 'utf8'), size,
mode: 0o100644, mode: 0o100644,
dev: 1, dev: 1,
ino: 1, ino: 1,
@ -63,22 +64,20 @@ const hoisted = vi.hoisted(() => {
files.set(sentMessagesPath, JSON.stringify(rows)); files.set(sentMessagesPath, JSON.stringify(rows));
return message; return message;
}), }),
sendInboxMessage: vi.fn( sendInboxMessage: vi.fn((teamName: string, message: Record<string, unknown>) => {
(teamName: string, message: Record<string, unknown>) => { const member =
const member = typeof message.member === 'string'
typeof message.member === 'string' ? message.member
? message.member : typeof message.to === 'string'
: typeof message.to === 'string' ? message.to
? message.to : 'unknown';
: 'unknown'; const p = `/mock/teams/${teamName}/inboxes/${member}.json`;
const p = `/mock/teams/${teamName}/inboxes/${member}.json`; const current = files.get(p);
const current = files.get(p); const rows = current ? (JSON.parse(current) as unknown[]) : [];
const rows = current ? (JSON.parse(current) as unknown[]) : []; rows.push(message);
rows.push(message); files.set(p, JSON.stringify(rows));
files.set(p, JSON.stringify(rows)); return { deliveredToInbox: true, messageId: 'mock-id', message };
return { deliveredToInbox: true, messageId: 'mock-id', message }; }),
}
),
setAtomicWriteShouldFail: (next: boolean) => { setAtomicWriteShouldFail: (next: boolean) => {
atomicWriteShouldFail = next; atomicWriteShouldFail = next;
}, },
@ -419,7 +418,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('Source: system_notification'); expect(payload).toContain('Source: system_notification');
expect(payload).toContain('summary looks like \\"Comment on #...\\"'); expect(payload).toContain('summary looks like \\"Comment on #...\\"');
expect(payload).toContain('reply via task_add_comment only when you have a substantive board update'); expect(payload).toContain(
'reply via task_add_comment only when you have a substantive board update'
);
expect(payload).toContain('Do NOT post acknowledgement-only task comments'); expect(payload).toContain('Do NOT post acknowledgement-only task comments');
(service as any).handleStreamJsonMessage(run, { (service as any).handleStreamJsonMessage(run, {
@ -540,9 +541,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
runId: 'run-old', runId: 'run-old',
}); });
const inboxDeferred = createDeferred<typeof inboxMessages>(); const inboxDeferred = createDeferred<typeof inboxMessages>();
const inboxReader = (service as unknown as { const inboxReader = (
inboxReader: { getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages> }; service as unknown as {
}).inboxReader; inboxReader: {
getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages>;
};
}
).inboxReader;
const inboxSpy = vi const inboxSpy = vi
.spyOn(inboxReader, 'getMessagesFor') .spyOn(inboxReader, 'getMessagesFor')
.mockImplementationOnce(async () => await inboxDeferred.promise) .mockImplementationOnce(async () => await inboxDeferred.promise)
@ -586,14 +591,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' }); const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' });
const inboxDeferred = createDeferred<[typeof permissionMessage]>(); const inboxDeferred = createDeferred<[typeof permissionMessage]>();
const inboxReader = (service as unknown as { const inboxReader = (
inboxReader: { service as unknown as {
getMessagesFor: ( inboxReader: {
team: string, getMessagesFor: (team: string, member: string) => Promise<[typeof permissionMessage]>;
member: string };
) => Promise<[typeof permissionMessage]>; }
}; ).inboxReader;
}).inboxReader;
const inboxSpy = vi const inboxSpy = vi
.spyOn(inboxReader, 'getMessagesFor') .spyOn(inboxReader, 'getMessagesFor')
.mockImplementationOnce(async () => await inboxDeferred.promise) .mockImplementationOnce(async () => await inboxDeferred.promise)
@ -702,7 +706,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('Source: cross_team'); expect(payload).toContain('Source: cross_team');
expect(payload).toContain('Cross-team conversationId: conv-explicit'); expect(payload).toContain('Cross-team conversationId: conv-explicit');
expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"'); expect(payload).toContain(
'Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"'
);
expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"'); expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"');
expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"'); expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"');
@ -953,7 +959,11 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
attachAliveRun(service, teamName); attachAliveRun(service, teamName);
const run = (service as unknown as { runs: Map<string, unknown> }).runs.get('run-1') as { const run = (service as unknown as { runs: Map<string, unknown> }).runs.get('run-1') as {
silentUserDmForward: { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' } | null; silentUserDmForward: {
target: string;
startedAt: string;
mode: 'user_dm' | 'member_inbox_relay';
} | null;
}; };
run.silentUserDmForward = { run.silentUserDmForward = {
target: 'alice', target: 'alice',
@ -1120,9 +1130,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
runId: 'run-old', runId: 'run-old',
}); });
const inboxDeferred = createDeferred<typeof inboxMessages>(); const inboxDeferred = createDeferred<typeof inboxMessages>();
const inboxReader = (service as unknown as { const inboxReader = (
inboxReader: { getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages> }; service as unknown as {
}).inboxReader; inboxReader: {
getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages>;
};
}
).inboxReader;
const inboxSpy = vi const inboxSpy = vi
.spyOn(inboxReader, 'getMessagesFor') .spyOn(inboxReader, 'getMessagesFor')
.mockImplementationOnce(async () => await inboxDeferred.promise) .mockImplementationOnce(async () => await inboxDeferred.promise)
@ -1332,11 +1346,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
await (service as any).markInboxMessagesRead(teamName, 'alice', [ await (service as any).markInboxMessagesRead(teamName, 'alice', [
{ {
messageId: buildLegacyInboxMessageId( messageId: buildLegacyInboxMessageId(legacyRow.from, legacyRow.timestamp, legacyRow.text),
legacyRow.from,
legacyRow.timestamp,
legacyRow.text
),
}, },
]); ]);
@ -1732,9 +1742,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
}) })
); );
const rows = JSON.parse( const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
);
expect(rows[0].read).toBe(true); expect(rows[0].read).toBe(true);
}); });
@ -1780,9 +1788,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
failed: 0, failed: 0,
lastDelivery: { delivered: true, responsePending: true }, lastDelivery: { delivered: true, responsePending: true },
}); });
const rows = JSON.parse( const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
);
expect(rows[0].read).toBe(false); expect(rows[0].read).toBe(false);
}); });
@ -1914,9 +1920,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
teamName, teamName,
expect.objectContaining({ messageId: 'opencode-terminal-new' }) expect.objectContaining({ messageId: 'opencode-terminal-new' })
); );
const rows = JSON.parse( const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
);
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]);
}); });
@ -2000,9 +2004,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
'opencode_attachments_not_supported_for_secondary_runtime' 'opencode_attachments_not_supported_for_secondary_runtime'
); );
vi.mocked(console.warn).mockClear(); vi.mocked(console.warn).mockClear();
const rows = JSON.parse( const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
);
expect(rows[0].read).toBe(false); expect(rows[0].read).toBe(false);
expect(records[0]).toMatchObject({ expect(records[0]).toMatchObject({
inboxMessageId: 'opencode-attachment-1', inboxMessageId: 'opencode-attachment-1',
@ -2027,7 +2029,10 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
], ],
}) })
); );
const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack'); const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(
teamName,
'jack'
);
expect(identity.ok).toBe(true); expect(identity.ok).toBe(true);
const laneId = identity.laneId; const laneId = identity.laneId;
const records: any[] = []; const records: any[] = [];
@ -2048,7 +2053,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
return record; return record;
}), }),
markAcceptanceUnknown: vi.fn( markAcceptanceUnknown: vi.fn(
async (input: { id: string; reason: string; nextAttemptAt: string; markedAt: string }) => { async (input: {
id: string;
reason: string;
nextAttemptAt: string;
markedAt: string;
}) => {
const record = records.find((candidate) => candidate.id === input.id); const record = records.find((candidate) => candidate.id === input.id);
Object.assign(record, { Object.assign(record, {
status: 'failed_retryable', status: 'failed_retryable',
@ -2180,9 +2190,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
teamName, teamName,
expect.objectContaining({ messageId: 'opencode-inflight-new' }) expect.objectContaining({ messageId: 'opencode-inflight-new' })
); );
const rows = JSON.parse( const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
);
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, true]); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, true]);
}); });
@ -2353,9 +2361,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
'OpenCode inbox relay failed for jack/opencode-relay-failed-1' 'OpenCode inbox relay failed for jack/opencode-relay-failed-1'
); );
vi.mocked(console.warn).mockClear(); vi.mocked(console.warn).mockClear();
const rows = JSON.parse( const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
);
expect(rows[0].read).toBe(false); expect(rows[0].read).toBe(false);
}); });
@ -2387,9 +2393,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
delivered: true, delivered: true,
diagnostics: [], diagnostics: [],
}); });
vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue( vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue(new Error('write failed'));
new Error('write failed')
);
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
@ -2410,9 +2414,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
'opencode_inbox_mark_read_failed_after_delivery' 'opencode_inbox_mark_read_failed_after_delivery'
); );
vi.mocked(console.warn).mockClear(); vi.mocked(console.warn).mockClear();
const rows = JSON.parse( const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
);
expect(rows[0].read).toBe(false); expect(rows[0].read).toBe(false);
}); });
}); });