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:
commit
e21d89a057
47 changed files with 2975 additions and 2586 deletions
|
|
@ -178,7 +178,9 @@ export class ScheduledTaskExecutor {
|
|||
cwd: request.config.cwd,
|
||||
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
|
||||
// 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'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@ async function hasValidServerCopy(dir: string): Promise<boolean> {
|
|||
|
||||
let _resolvedNodePath: string | undefined;
|
||||
|
||||
export function clearResolvedNodePathForTests(): void {
|
||||
_resolvedNodePath = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the real `node` binary path. In Electron, process.execPath is the
|
||||
* Electron binary — NOT node — so we must resolve node separately.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
import {
|
||||
|
|
@ -28,9 +28,9 @@ interface CompactBoundaryProps {
|
|||
* CompactBoundary displays a horizontal divider indicating where
|
||||
* the conversation was compacted. Click to expand the compacted summary.
|
||||
*/
|
||||
export const CompactBoundary = ({
|
||||
export const CompactBoundary = memo(function CompactBoundary({
|
||||
compactGroup,
|
||||
}: Readonly<CompactBoundaryProps>): React.JSX.Element => {
|
||||
}: Readonly<CompactBoundaryProps>): React.JSX.Element {
|
||||
const { timestamp, message } = compactGroup;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
|
|
@ -166,4 +166,4 @@ export const CompactBoundary = ({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
CODE_BG,
|
||||
|
|
@ -65,9 +65,6 @@ function buildItemMetaTooltip(
|
|||
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 {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
|
|
@ -75,24 +72,63 @@ function truncateText(text: string, maxLength: number): string {
|
|||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a flat list of AIGroupDisplayItem[] into the appropriate components.
|
||||
*
|
||||
* This component maps each display item to its corresponding component based on type:
|
||||
* - thinking -> ThinkingItem
|
||||
* - output -> TextItem
|
||||
* - tool -> LinkedToolItem
|
||||
* - subagent -> SubagentItem
|
||||
* - slash -> SlashItem
|
||||
*
|
||||
* The list is completely flat with no nested toggles or hierarchies.
|
||||
*/
|
||||
export const DisplayItemList = ({
|
||||
items,
|
||||
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,
|
||||
expandedItemIds,
|
||||
onReplyHover,
|
||||
aiGroupId,
|
||||
order = 'chronological',
|
||||
searchQueryOverride,
|
||||
highlightToolUseId,
|
||||
highlightColor,
|
||||
|
|
@ -101,44 +137,13 @@ export const DisplayItemList = ({
|
|||
previewMaxLength,
|
||||
timestampFormat,
|
||||
showItemMetaTooltip = false,
|
||||
}: Readonly<DisplayItemListProps>): React.JSX.Element => {
|
||||
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
|
||||
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
|
||||
}: DisplayItemRowProps): React.JSX.Element | null {
|
||||
const handleClick = useCallback(() => onItemClick(itemKey), [onItemClick, itemKey]);
|
||||
|
||||
const handleReplyHover = useCallback((toolId: string | null) => {
|
||||
setReplyLinkToolId(toolId);
|
||||
}, []);
|
||||
|
||||
/** Check if an item is part of the currently highlighted reply link */
|
||||
const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => {
|
||||
if (!replyLinkToolId) return false;
|
||||
if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true;
|
||||
if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId)
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
|
||||
No items to display
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
|
||||
}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
let itemKey = '';
|
||||
let element: React.ReactNode = null;
|
||||
|
||||
switch (item.type) {
|
||||
case 'thinking': {
|
||||
itemKey = `thinking-${index}`;
|
||||
const thinkingStep = {
|
||||
id: itemKey,
|
||||
type: 'thinking' as const,
|
||||
|
|
@ -153,8 +158,8 @@ export const DisplayItemList = ({
|
|||
<ThinkingItem
|
||||
step={thinkingStep}
|
||||
preview={truncateText(item.content, previewMaxLength ?? 150)}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
onClick={handleClick}
|
||||
isExpanded={isExpanded}
|
||||
timestamp={item.timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={
|
||||
|
|
@ -170,7 +175,6 @@ export const DisplayItemList = ({
|
|||
}
|
||||
|
||||
case 'output': {
|
||||
itemKey = `output-${index}`;
|
||||
const textStep = {
|
||||
id: itemKey,
|
||||
type: 'output' as const,
|
||||
|
|
@ -185,8 +189,8 @@ export const DisplayItemList = ({
|
|||
<TextItem
|
||||
step={textStep}
|
||||
preview={truncateText(item.content, previewMaxLength ?? 150)}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
onClick={handleClick}
|
||||
isExpanded={isExpanded}
|
||||
timestamp={item.timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={
|
||||
|
|
@ -202,37 +206,29 @@ export const DisplayItemList = ({
|
|||
}
|
||||
|
||||
case 'tool': {
|
||||
itemKey = `tool-${item.tool.id}-${index}`;
|
||||
element = (
|
||||
<LinkedToolItem
|
||||
linkedTool={item.tool}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
onClick={handleClick}
|
||||
isExpanded={isExpanded}
|
||||
timestamp={item.tool.startTime}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={
|
||||
showItemMetaTooltip
|
||||
? buildItemMetaTooltip(
|
||||
item.tool.startTime,
|
||||
getToolContextTokens(item.tool),
|
||||
'tokens'
|
||||
)
|
||||
? 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
|
||||
}
|
||||
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,
|
||||
|
|
@ -250,8 +246,8 @@ export const DisplayItemList = ({
|
|||
<SubagentItem
|
||||
step={subagentStep}
|
||||
subagent={item.subagent}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
onClick={handleClick}
|
||||
isExpanded={isExpanded}
|
||||
aiGroupId={aiGroupId}
|
||||
highlightToolUseId={highlightToolUseId}
|
||||
highlightColor={highlightColor}
|
||||
|
|
@ -263,12 +259,11 @@ export const DisplayItemList = ({
|
|||
}
|
||||
|
||||
case 'slash': {
|
||||
itemKey = `slash-${item.slash.name}-${index}`;
|
||||
element = (
|
||||
<SlashItem
|
||||
slash={item.slash}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
onClick={handleClick}
|
||||
isExpanded={isExpanded}
|
||||
timestamp={item.slash.timestamp}
|
||||
timestampFormat={timestampFormat}
|
||||
titleText={
|
||||
|
|
@ -286,20 +281,18 @@ export const DisplayItemList = ({
|
|||
}
|
||||
|
||||
case 'teammate_message': {
|
||||
itemKey = `teammate-${item.teammateMessage.id}-${index}`;
|
||||
element = (
|
||||
<TeammateMessageItem
|
||||
teammateMessage={item.teammateMessage}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
onReplyHover={handleReplyHover}
|
||||
onClick={handleClick}
|
||||
isExpanded={isExpanded}
|
||||
onReplyHover={onReplyHover}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'subagent_input': {
|
||||
itemKey = `input-${index}`;
|
||||
const inputContent = item.content;
|
||||
const inputTokenCount = item.tokenCount;
|
||||
element = (
|
||||
|
|
@ -315,8 +308,8 @@ export const DisplayItemList = ({
|
|||
? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens')
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
onClick={handleClick}
|
||||
isExpanded={isExpanded}
|
||||
>
|
||||
<MarkdownViewer
|
||||
content={inputContent}
|
||||
|
|
@ -330,27 +323,22 @@ export const DisplayItemList = ({
|
|||
}
|
||||
|
||||
case 'compact_boundary': {
|
||||
itemKey = `compact-${index}`;
|
||||
const compactContent = item.content;
|
||||
const compactExpanded = expandedItemIds.has(itemKey);
|
||||
element = (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
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={compactExpanded}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ color: TOOL_CALL_TEXT }}
|
||||
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 ${compactExpanded ? 'rotate-90' : ''}`}
|
||||
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<Layers size={14} />
|
||||
</div>
|
||||
|
|
@ -379,14 +367,11 @@ export const DisplayItemList = ({
|
|||
>
|
||||
Phase {item.phaseNumber}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto shrink-0 text-[11px]"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
<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 && (
|
||||
{isExpanded && compactContent && (
|
||||
<div
|
||||
className="mt-1 overflow-hidden rounded-lg"
|
||||
style={{
|
||||
|
|
@ -411,21 +396,102 @@ export const DisplayItemList = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
// Apply reply-link spotlight: dim items not in the highlighted pair
|
||||
const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item);
|
||||
return (
|
||||
<div
|
||||
key={itemKey}
|
||||
style={
|
||||
replyLinkToolId !== null
|
||||
? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' }
|
||||
: undefined
|
||||
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.
|
||||
*
|
||||
* This component maps each display item to its corresponding component based on type:
|
||||
* - thinking -> ThinkingItem
|
||||
* - output -> TextItem
|
||||
* - tool -> LinkedToolItem
|
||||
* - subagent -> SubagentItem
|
||||
* - slash -> SlashItem
|
||||
*
|
||||
* The list is completely flat with no nested toggles or hierarchies.
|
||||
*/
|
||||
export const DisplayItemList = React.memo(function DisplayItemList({
|
||||
items,
|
||||
onItemClick,
|
||||
expandedItemIds,
|
||||
aiGroupId,
|
||||
order = 'chronological',
|
||||
searchQueryOverride,
|
||||
highlightToolUseId,
|
||||
highlightColor,
|
||||
notificationColorMap,
|
||||
registerToolRef,
|
||||
previewMaxLength,
|
||||
timestampFormat,
|
||||
showItemMetaTooltip = false,
|
||||
}: Readonly<DisplayItemListProps>): React.JSX.Element {
|
||||
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
|
||||
|
||||
const handleReplyHover = useCallback((toolId: string | null) => {
|
||||
setReplyLinkToolId(toolId);
|
||||
}, []);
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
|
||||
No items to display
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
|
||||
}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const itemKey = getItemKey(item, index);
|
||||
const isExpanded = expandedItemIds.has(itemKey);
|
||||
|
||||
const isInReplyLink =
|
||||
replyLinkToolId !== null &&
|
||||
((item.type === 'tool' && item.tool.id === replyLinkToolId) ||
|
||||
(item.type === 'teammate_message' &&
|
||||
item.teammateMessage.replyToToolId === replyLinkToolId));
|
||||
const isDimmed = replyLinkToolId !== null && !isInReplyLink;
|
||||
|
||||
return (
|
||||
<DisplayItemRow
|
||||
key={itemKey}
|
||||
item={item}
|
||||
index={index}
|
||||
itemKey={itemKey}
|
||||
isExpanded={isExpanded}
|
||||
isDimmed={isDimmed}
|
||||
hasReplyLink={replyLinkToolId !== null}
|
||||
onItemClick={onItemClick}
|
||||
onReplyHover={handleReplyHover}
|
||||
aiGroupId={aiGroupId}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
highlightToolUseId={highlightToolUseId}
|
||||
highlightColor={highlightColor}
|
||||
notificationColorMap={notificationColorMap}
|
||||
registerToolRef={registerToolRef}
|
||||
previewMaxLength={previewMaxLength}
|
||||
timestampFormat={timestampFormat}
|
||||
showItemMetaTooltip={showItemMetaTooltip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables';
|
||||
import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors';
|
||||
|
|
@ -57,14 +57,14 @@ interface BaseItemProps {
|
|||
/**
|
||||
* Small status dot indicator.
|
||||
*/
|
||||
export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => {
|
||||
export const StatusDot = memo(function StatusDot({ status }: { status: ItemStatus }) {
|
||||
return (
|
||||
<span
|
||||
className="base-item-status-dot inline-block size-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: getStatusDotColor(status) }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
|
|
@ -79,7 +79,8 @@ export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => {
|
|||
*
|
||||
* Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem
|
||||
*/
|
||||
export const BaseItem: React.FC<BaseItemProps> = ({
|
||||
export const BaseItem = memo(
|
||||
({
|
||||
icon,
|
||||
label,
|
||||
summary,
|
||||
|
|
@ -97,7 +98,7 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
highlightStyle,
|
||||
notificationDotColor,
|
||||
children,
|
||||
}) => {
|
||||
}: BaseItemProps): React.JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
||||
|
|
@ -140,7 +141,10 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
<span className="text-sm" style={{ color: TOOL_ITEM_MUTED }}>
|
||||
-
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm" style={{ color: 'var(--tool-item-summary)' }}>
|
||||
<span
|
||||
className="flex-1 truncate text-sm"
|
||||
style={{ color: 'var(--tool-item-summary)' }}
|
||||
>
|
||||
{summary}
|
||||
</span>
|
||||
</>
|
||||
|
|
@ -210,4 +214,5 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ interface ExecutionTraceProps {
|
|||
// Execution Trace Component
|
||||
// =============================================================================
|
||||
|
||||
export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
||||
export const ExecutionTrace: React.FC<ExecutionTraceProps> = React.memo(
|
||||
({
|
||||
items,
|
||||
aiGroupId: _aiGroupId,
|
||||
highlightToolUseId,
|
||||
|
|
@ -54,7 +55,7 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
|||
notificationColorMap,
|
||||
searchExpandedItemId,
|
||||
registerToolRef,
|
||||
}): React.JSX.Element => {
|
||||
}): React.JSX.Element => {
|
||||
const [manualExpandedItemId, setManualExpandedItemId] = useState<string | null>(null);
|
||||
|
||||
// Use searchExpandedItemId if set, otherwise use manually expanded item
|
||||
|
|
@ -217,7 +218,10 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
|||
/>
|
||||
<Layers size={14} />
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
|
||||
<span
|
||||
className="shrink-0 text-xs font-medium"
|
||||
style={{ color: TOOL_CALL_TEXT }}
|
||||
>
|
||||
Compacted
|
||||
</span>
|
||||
{item.tokenDelta && (
|
||||
|
|
@ -276,4 +280,5 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
|||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* 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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
|
|
@ -64,7 +64,8 @@ interface LinkedToolItemProps {
|
|||
titleText?: string;
|
||||
}
|
||||
|
||||
export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
||||
export const LinkedToolItem = memo(
|
||||
({
|
||||
linkedTool,
|
||||
onClick,
|
||||
isExpanded,
|
||||
|
|
@ -76,7 +77,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
notificationDotColor,
|
||||
registerRef,
|
||||
titleText,
|
||||
}) => {
|
||||
}: LinkedToolItemProps): React.JSX.Element => {
|
||||
const status = getToolStatus(linkedTool);
|
||||
const { isLight } = useTheme();
|
||||
const summary = getToolSummary(linkedTool.name, linkedTool.input);
|
||||
|
|
@ -233,4 +234,5 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
</BaseItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
|
|
@ -41,13 +41,14 @@ interface MetricsPillProps {
|
|||
// Unified Metrics Pill - Compact monospace pill with tooltip
|
||||
// =============================================================================
|
||||
|
||||
export const MetricsPill = ({
|
||||
export const MetricsPill = memo(
|
||||
({
|
||||
mainSessionImpact,
|
||||
lastUsage,
|
||||
isolatedLabel,
|
||||
isolatedOverride,
|
||||
phaseBreakdown,
|
||||
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
|
||||
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -212,4 +213,5 @@ export const MetricsPill = ({
|
|||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { Slash } from 'lucide-react';
|
||||
|
||||
|
|
@ -34,7 +34,8 @@ interface SlashItemProps {
|
|||
* - MCP commands
|
||||
* - User-defined commands
|
||||
*/
|
||||
export const SlashItem: React.FC<SlashItemProps> = ({
|
||||
export const SlashItem = memo(
|
||||
({
|
||||
slash,
|
||||
onClick,
|
||||
isExpanded,
|
||||
|
|
@ -44,7 +45,7 @@ export const SlashItem: React.FC<SlashItemProps> = ({
|
|||
highlightStyle,
|
||||
notificationDotColor,
|
||||
titleText,
|
||||
}) => {
|
||||
}: SlashItemProps): React.JSX.Element => {
|
||||
const hasInstructions = !!slash.instructions;
|
||||
|
||||
// Display args or message as the description
|
||||
|
|
@ -78,4 +79,5 @@ export const SlashItem: React.FC<SlashItemProps> = ({
|
|||
)}
|
||||
</BaseItem>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ interface SubagentItemProps {
|
|||
// Main Component - Linear-style DevTools Card
|
||||
// =============================================================================
|
||||
|
||||
export const SubagentItem: React.FC<SubagentItemProps> = ({
|
||||
export const SubagentItem: React.FC<SubagentItemProps> = React.memo(
|
||||
({
|
||||
step,
|
||||
subagent,
|
||||
onClick,
|
||||
|
|
@ -77,7 +78,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
highlightColor,
|
||||
notificationColorMap,
|
||||
registerToolRef,
|
||||
}) => {
|
||||
}) => {
|
||||
const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent';
|
||||
const subagentType = subagent.subagentType ?? 'Task';
|
||||
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
|
||||
|
|
@ -483,7 +484,10 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
{hasIsolated && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleDot className="size-3" style={{ color: 'rgba(56, 189, 248, 0.7)' }} />
|
||||
<CircleDot
|
||||
className="size-3"
|
||||
style={{ color: 'rgba(56, 189, 248, 0.7)' }}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||
{subagent.team ? 'Context Window' : 'Subagent Context'}
|
||||
</span>
|
||||
|
|
@ -500,7 +504,10 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
{/* Per-phase breakdown when multi-phase */}
|
||||
{isMultiPhase &&
|
||||
phaseData.phases.map((phase) => (
|
||||
<div key={phase.phaseNumber} className="flex items-center justify-between pl-5">
|
||||
<div
|
||||
key={phase.phaseNumber}
|
||||
className="flex items-center justify-between pl-5"
|
||||
>
|
||||
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
Phase {phase.phaseNumber}
|
||||
</span>
|
||||
|
|
@ -589,4 +596,5 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
CARD_BG,
|
||||
|
|
@ -75,14 +75,15 @@ function isResendMessage(message: TeammateMessage): boolean {
|
|||
*
|
||||
* Operational noise (idle/shutdown/terminated) renders as minimal inline text.
|
||||
*/
|
||||
export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
||||
export const TeammateMessageItem = memo(
|
||||
({
|
||||
teammateMessage,
|
||||
onClick,
|
||||
isExpanded,
|
||||
onReplyHover,
|
||||
highlightClasses = '',
|
||||
highlightStyle,
|
||||
}) => {
|
||||
}: TeammateMessageItemProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(teammateMessage.color);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
|
|
@ -132,7 +133,10 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
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="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: colors.border }}
|
||||
/>
|
||||
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{teammateMessage.teammateId}
|
||||
</span>
|
||||
|
|
@ -258,4 +262,5 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ interface TextItemProps {
|
|||
titleText?: string;
|
||||
}
|
||||
|
||||
export const TextItem: React.FC<TextItemProps> = ({
|
||||
export const TextItem: React.FC<TextItemProps> = React.memo(
|
||||
({
|
||||
step,
|
||||
preview,
|
||||
onClick,
|
||||
|
|
@ -44,7 +45,7 @@ export const TextItem: React.FC<TextItemProps> = ({
|
|||
highlightStyle,
|
||||
notificationDotColor,
|
||||
titleText,
|
||||
}) => {
|
||||
}) => {
|
||||
const fullContent = step.content.outputText ?? preview;
|
||||
const summary = searchQueryOverride
|
||||
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
||||
|
|
@ -79,4 +80,5 @@ export const TextItem: React.FC<TextItemProps> = ({
|
|||
/>
|
||||
</BaseItem>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ interface ThinkingItemProps {
|
|||
titleText?: string;
|
||||
}
|
||||
|
||||
export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
||||
export const ThinkingItem: React.FC<ThinkingItemProps> = React.memo(
|
||||
({
|
||||
step,
|
||||
preview,
|
||||
onClick,
|
||||
|
|
@ -44,7 +45,7 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
|||
highlightStyle,
|
||||
notificationDotColor,
|
||||
titleText,
|
||||
}) => {
|
||||
}) => {
|
||||
const fullContent = step.content.thinkingText ?? preview;
|
||||
const summary = searchQueryOverride
|
||||
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
||||
|
|
@ -79,4 +80,5 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
|||
/>
|
||||
</BaseItem>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* 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';
|
||||
|
||||
|
|
@ -18,11 +18,8 @@ interface CollapsibleOutputSectionProps {
|
|||
label?: string;
|
||||
}
|
||||
|
||||
export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> = ({
|
||||
status,
|
||||
children,
|
||||
label = 'Output',
|
||||
}) => {
|
||||
export const CollapsibleOutputSection = memo(
|
||||
({ status, children, label = 'Output' }: CollapsibleOutputSectionProps): React.JSX.Element => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -30,7 +27,13 @@ export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> =
|
|||
<button
|
||||
type="button"
|
||||
className="mb-1 flex items-center gap-2 text-xs"
|
||||
style={{ color: 'var(--tool-item-muted)', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
||||
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" />}
|
||||
|
|
@ -54,4 +57,5 @@ export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> =
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Default rendering for tools that don't have specialized viewers.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { type ItemStatus } from '../BaseItem';
|
||||
|
||||
|
|
@ -23,7 +23,10 @@ interface DefaultToolViewerProps {
|
|||
status: ItemStatus;
|
||||
}
|
||||
|
||||
export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => {
|
||||
export const DefaultToolViewer = memo(function DefaultToolViewer({
|
||||
linkedTool,
|
||||
status,
|
||||
}: DefaultToolViewerProps) {
|
||||
const displayOutputContent = linkedTool.result
|
||||
? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content)
|
||||
: null;
|
||||
|
|
@ -64,4 +67,4 @@ export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool
|
|||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Renders the Edit tool with DiffViewer.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { DiffViewer } from '@renderer/components/chat/viewers';
|
||||
|
||||
|
|
@ -20,7 +20,10 @@ interface EditToolViewerProps {
|
|||
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 filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string);
|
||||
|
|
@ -71,4 +74,4 @@ export const EditToolViewer: React.FC<EditToolViewerProps> = ({ linkedTool, stat
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Renders the Read tool result using CodeBlockViewer.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ interface ReadToolViewerProps {
|
|||
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;
|
||||
|
||||
// Prefer enriched toolUseResult data
|
||||
|
|
@ -55,7 +55,9 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
|
|||
: undefined;
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -99,4 +101,4 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* 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';
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ interface SkillToolViewerProps {
|
|||
linkedTool: LinkedToolItem;
|
||||
}
|
||||
|
||||
export const SkillToolViewer: React.FC<SkillToolViewerProps> = ({ linkedTool }) => {
|
||||
export const SkillToolViewer = memo(function SkillToolViewer({ linkedTool }: SkillToolViewerProps) {
|
||||
const skillInstructions = linkedTool.skillInstructions;
|
||||
const skillName = (linkedTool.input.skill as string) || 'Unknown Skill';
|
||||
|
||||
|
|
@ -64,4 +64,4 @@ export const SkillToolViewer: React.FC<SkillToolViewerProps> = ({ linkedTool })
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Displays error output for tool results.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { StatusDot } from '../BaseItem';
|
||||
|
||||
|
|
@ -16,7 +16,9 @@ interface ToolErrorDisplayProps {
|
|||
linkedTool: LinkedToolItem;
|
||||
}
|
||||
|
||||
export const ToolErrorDisplay: React.FC<ToolErrorDisplayProps> = ({ linkedTool }) => {
|
||||
export const ToolErrorDisplay = memo(function ToolErrorDisplay({
|
||||
linkedTool,
|
||||
}: ToolErrorDisplayProps) {
|
||||
if (!linkedTool.result?.isError) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -40,4 +42,4 @@ export const ToolErrorDisplay: React.FC<ToolErrorDisplayProps> = ({ linkedTool }
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Renders the Write tool result.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ interface WriteToolViewerProps {
|
|||
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 filePath =
|
||||
|
|
@ -74,4 +74,4 @@ export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool })
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import React, { memo, useMemo, useState } from 'react';
|
||||
|
||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -117,14 +117,14 @@ function inferLanguage(fileName: string): string {
|
|||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const CodeBlockViewer: React.FC<CodeBlockViewerProps> = ({
|
||||
export const CodeBlockViewer = memo(function CodeBlockViewer({
|
||||
fileName,
|
||||
content,
|
||||
language,
|
||||
startLine = 1,
|
||||
endLine,
|
||||
maxHeight = 'max-h-96',
|
||||
}): React.JSX.Element => {
|
||||
}: CodeBlockViewerProps): React.JSX.Element {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
// Infer language from file extension if not provided
|
||||
|
|
@ -241,4 +241,4 @@ export const CodeBlockViewer: React.FC<CodeBlockViewerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
CODE_BG,
|
||||
|
|
@ -349,14 +349,14 @@ const DiffLineRow: React.FC<DiffLineRowProps> = ({ line, highlightedHtml }): Rea
|
|||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
export const DiffViewer = memo(function DiffViewer({
|
||||
fileName,
|
||||
oldString,
|
||||
newString,
|
||||
maxHeight = 'max-h-96',
|
||||
tokenCount,
|
||||
syntaxHighlight = false,
|
||||
}): React.JSX.Element => {
|
||||
}: DiffViewerProps): React.JSX.Element {
|
||||
// Compute diff
|
||||
const oldLines = oldString.split(/\r?\n/);
|
||||
const newLines = newString.split(/\r?\n/);
|
||||
|
|
@ -456,4 +456,4 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
maxHeight = 'max-h-96',
|
||||
className = '',
|
||||
|
|
@ -958,7 +958,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
baseDir,
|
||||
teamColorByName: providedTeamColorByName,
|
||||
onTeamClick: providedOnTeamClick,
|
||||
}) => {
|
||||
}) {
|
||||
const [showRaw, setShowRaw] = React.useState(false);
|
||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||
const { isLight } = useTheme();
|
||||
|
|
@ -1169,12 +1169,36 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||
{label}
|
||||
</span>
|
||||
{copyable && (
|
||||
<>
|
||||
<span className="flex-1" />
|
||||
<CopyButton text={content} inline />
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
)}
|
||||
|
||||
|
|
@ -1195,4 +1219,4 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { Input } from '@renderer/components/ui/input';
|
||||
|
|
@ -24,13 +24,18 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
|
||||
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
||||
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
||||
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
||||
|
||||
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
|
||||
|
||||
const LaunchTeamDialog = lazy(() =>
|
||||
import('@renderer/components/team/dialogs/LaunchTeamDialog').then((m) => ({
|
||||
default: m.LaunchTeamDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
|
@ -562,6 +567,8 @@ export const SchedulesView = (): React.JSX.Element => {
|
|||
</div>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
{dialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LaunchTeamDialog
|
||||
mode="schedule"
|
||||
open={dialogOpen}
|
||||
|
|
@ -569,6 +576,8 @@ export const SchedulesView = (): React.JSX.Element => {
|
|||
schedule={editingSchedule}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* 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 { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
|
||||
|
|
@ -184,7 +184,7 @@ function matchesSessionSearch(session: Session, query: string): boolean {
|
|||
return haystack.includes(query);
|
||||
}
|
||||
|
||||
export const DateGroupedSessions = (): React.JSX.Element => {
|
||||
export const DateGroupedSessions = memo((): React.JSX.Element => {
|
||||
const {
|
||||
sessions,
|
||||
selectedSessionId,
|
||||
|
|
@ -202,7 +202,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
toggleShowHiddenSessions,
|
||||
sidebarSelectedSessionIds,
|
||||
sidebarMultiSelectActive,
|
||||
toggleSidebarSessionSelection,
|
||||
clearSidebarSelection,
|
||||
toggleSidebarMultiSelect,
|
||||
hideMultipleSessions,
|
||||
|
|
@ -239,7 +238,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
toggleShowHiddenSessions: s.toggleShowHiddenSessions,
|
||||
sidebarSelectedSessionIds: s.sidebarSelectedSessionIds,
|
||||
sidebarMultiSelectActive: s.sidebarMultiSelectActive,
|
||||
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
|
||||
clearSidebarSelection: s.clearSidebarSelection,
|
||||
toggleSidebarMultiSelect: s.toggleSidebarMultiSelect,
|
||||
hideMultipleSessions: s.hideMultipleSessions,
|
||||
|
|
@ -1104,7 +1102,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
isHidden={item.isHidden}
|
||||
multiSelectActive={sidebarMultiSelectActive}
|
||||
isSelected={selectedSet.has(item.session.id)}
|
||||
onToggleSelect={() => toggleSidebarSessionSelection(item.session.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1114,4 +1111,4 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { 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);
|
||||
}
|
||||
|
||||
export const GlobalTaskList = ({
|
||||
export const GlobalTaskList = memo(function GlobalTaskList({
|
||||
hideHeader = false,
|
||||
filters: externalFilters,
|
||||
onFiltersChange: externalOnFiltersChange,
|
||||
filtersPopoverOpen: externalFiltersPopoverOpen,
|
||||
onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange,
|
||||
}: GlobalTaskListProps = {}): React.JSX.Element => {
|
||||
}: GlobalTaskListProps = {}): React.JSX.Element {
|
||||
const {
|
||||
globalTasks,
|
||||
globalTasksLoading,
|
||||
|
|
@ -271,16 +271,20 @@ export const GlobalTaskList = ({
|
|||
saveSortMode(mode);
|
||||
};
|
||||
|
||||
const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => {
|
||||
const handleRenameComplete = useCallback(
|
||||
(teamName: string, taskId: string, newSubject: string): void => {
|
||||
taskLocalState.renameTask(teamName, taskId, newSubject);
|
||||
setRenamingTaskKey(null);
|
||||
};
|
||||
},
|
||||
[taskLocalState]
|
||||
);
|
||||
|
||||
const handleRenameCancel = (): void => {
|
||||
const handleRenameCancel = useCallback((): void => {
|
||||
setRenamingTaskKey(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeleteTask = async (teamName: string, taskId: string): Promise<void> => {
|
||||
const handleDeleteTask = useCallback(
|
||||
async (teamName: string, taskId: string): Promise<void> => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete task',
|
||||
message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
|
||||
|
|
@ -301,7 +305,9 @@ export const GlobalTaskList = ({
|
|||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[fetchAllTasks, softDeleteTask]
|
||||
);
|
||||
|
||||
// Fetch tasks on mount — loading guard in the store action prevents
|
||||
// duplicate IPC calls when the centralized init chain is already fetching.
|
||||
|
|
@ -850,4 +856,4 @@ export const GlobalTaskList = ({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Supports right-click context menu for pane management.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
|
|
@ -30,7 +30,6 @@ interface SessionItemProps {
|
|||
isHidden?: boolean;
|
||||
multiSelectActive?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -156,15 +155,14 @@ const SessionRuntimeBadge = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const SessionItem = ({
|
||||
export const SessionItem = memo(function SessionItem({
|
||||
session,
|
||||
isActive,
|
||||
isPinned,
|
||||
isHidden,
|
||||
multiSelectActive,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
}: Readonly<SessionItemProps>): React.JSX.Element => {
|
||||
}: Readonly<SessionItemProps>): React.JSX.Element {
|
||||
const {
|
||||
openTab,
|
||||
activeProjectId,
|
||||
|
|
@ -173,6 +171,7 @@ export const SessionItem = ({
|
|||
splitPane,
|
||||
togglePinSession,
|
||||
toggleHideSession,
|
||||
toggleSidebarSessionSelection,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
openTab: s.openTab,
|
||||
|
|
@ -182,6 +181,7 @@ export const SessionItem = ({
|
|||
splitPane: s.splitPane,
|
||||
togglePinSession: s.togglePinSession,
|
||||
toggleHideSession: s.toggleHideSession,
|
||||
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -191,8 +191,8 @@ export const SessionItem = ({
|
|||
if (!activeProjectId) return;
|
||||
|
||||
// In multi-select mode, clicks toggle selection
|
||||
if (multiSelectActive && onToggleSelect) {
|
||||
onToggleSelect();
|
||||
if (multiSelectActive) {
|
||||
toggleSidebarSessionSelection(session.id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -290,7 +290,7 @@ export const SessionItem = ({
|
|||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected ?? false}
|
||||
onChange={() => onToggleSelect?.()}
|
||||
onChange={() => toggleSidebarSessionSelection(session.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="size-3.5 shrink-0 accent-blue-500"
|
||||
/>
|
||||
|
|
@ -390,4 +390,4 @@ export const SessionItem = ({
|
|||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
|
|
@ -69,7 +69,7 @@ interface SidebarTaskItemProps {
|
|||
getDisplaySubject?: (task: GlobalTask) => string | undefined;
|
||||
}
|
||||
|
||||
export const SidebarTaskItem = ({
|
||||
export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
task,
|
||||
hideTeamName,
|
||||
showTeamName,
|
||||
|
|
@ -77,7 +77,7 @@ export const SidebarTaskItem = ({
|
|||
onRenameComplete,
|
||||
onRenameCancel,
|
||||
getDisplaySubject,
|
||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
||||
}: SidebarTaskItemProps): React.JSX.Element {
|
||||
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
||||
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
|
||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||
|
|
@ -283,4 +283,4 @@ export const SidebarTaskItem = ({
|
|||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { cn } from '@renderer/lib/utils';
|
||||
|
|
@ -44,7 +44,7 @@ interface CollapsibleTeamSectionProps {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CollapsibleTeamSection = ({
|
||||
export const CollapsibleTeamSection = memo(function CollapsibleTeamSection({
|
||||
title,
|
||||
icon,
|
||||
badge,
|
||||
|
|
@ -63,7 +63,7 @@ export const CollapsibleTeamSection = ({
|
|||
headerSurfaceClassName,
|
||||
keepMounted,
|
||||
children,
|
||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||
}: CollapsibleTeamSectionProps): React.JSX.Element {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isOpen = forceOpen ? true : open;
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
|
@ -174,4 +174,4 @@ export const CollapsibleTeamSection = ({
|
|||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
getTeamColorSet,
|
||||
|
|
@ -37,7 +37,8 @@ interface MemberBadgeProps {
|
|||
* When onClick is provided, both avatar and badge are clickable as one unit.
|
||||
* Wrapped in MemberHoverCard to show member info on hover.
|
||||
*/
|
||||
export const MemberBadge = ({
|
||||
export const MemberBadge = memo(
|
||||
({
|
||||
name,
|
||||
color,
|
||||
teamName,
|
||||
|
|
@ -45,7 +46,7 @@ export const MemberBadge = ({
|
|||
hideAvatar,
|
||||
onClick,
|
||||
disableHoverCard,
|
||||
}: MemberBadgeProps): React.JSX.Element => {
|
||||
}: MemberBadgeProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(color ?? '');
|
||||
const { isLight } = useTheme();
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
|
|
@ -114,4 +115,5 @@ export const MemberBadge = ({
|
|||
{content}
|
||||
</MemberHoverCard>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
|
|
@ -65,12 +65,12 @@ interface TaskTooltipProps {
|
|||
* Tooltip that shows task summary on hover over any #taskId link.
|
||||
* Reads task data from the current team in the store.
|
||||
*/
|
||||
export const TaskTooltip = ({
|
||||
export const TaskTooltip = memo(function TaskTooltip({
|
||||
taskId,
|
||||
teamName,
|
||||
children,
|
||||
side = 'top',
|
||||
}: TaskTooltipProps): React.JSX.Element => {
|
||||
}: TaskTooltipProps): React.JSX.Element {
|
||||
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -194,4 +194,4 @@ export const TaskTooltip = ({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,12 +78,8 @@ import {
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||||
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
||||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||
import { LaunchTeamDialog, type TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
|
||||
import { ReviewDialog } from './dialogs/ReviewDialog';
|
||||
import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
||||
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
||||
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
||||
import { KanbanBoard } from './kanban/KanbanBoard';
|
||||
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 { AddMemberEntry } from './dialogs/AddMemberDialog';
|
||||
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
|
||||
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
|
||||
const LaunchTeamDialog = lazy(() =>
|
||||
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
|
||||
);
|
||||
const ProjectEditorOverlay = lazy(() =>
|
||||
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
|
||||
);
|
||||
|
|
@ -104,9 +104,20 @@ const TeamGraphOverlay = lazy(() =>
|
|||
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 { MessagesPanel } from './messages/MessagesPanel';
|
||||
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
|
||||
import { ScheduleSection } from './schedule/ScheduleSection';
|
||||
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
|
||||
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
|
||||
|
|
@ -874,10 +885,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
);
|
||||
});
|
||||
|
||||
export const TeamDetailView = ({
|
||||
export const TeamDetailView = memo(function TeamDetailView({
|
||||
teamName,
|
||||
isPaneFocused = false,
|
||||
}: TeamDetailViewProps): React.JSX.Element => {
|
||||
}: TeamDetailViewProps): React.JSX.Element {
|
||||
const { isLight } = useTheme();
|
||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||||
|
|
@ -2077,6 +2088,8 @@ export const TeamDetailView = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{launchDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LaunchTeamDialog
|
||||
mode={launchDialogState.mode}
|
||||
open={launchDialogOpen}
|
||||
|
|
@ -2089,6 +2102,8 @@ export const TeamDetailView = ({
|
|||
onLaunch={handleLaunchDialogSubmit}
|
||||
onRelaunch={handleRelaunchDialogSubmit}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2744,6 +2759,8 @@ export const TeamDetailView = ({
|
|||
}}
|
||||
/>
|
||||
|
||||
{createTaskDialog.open && (
|
||||
<Suspense fallback={null}>
|
||||
<CreateTaskDialog
|
||||
open={createTaskDialog.open}
|
||||
teamName={teamName}
|
||||
|
|
@ -2759,6 +2776,8 @@ export const TeamDetailView = ({
|
|||
onSubmit={handleCreateTask}
|
||||
submitting={creatingTask}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<EditTeamDialog
|
||||
open={editDialogOpen}
|
||||
|
|
@ -2864,6 +2883,8 @@ export const TeamDetailView = ({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{launchDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LaunchTeamDialog
|
||||
mode={launchDialogState.mode}
|
||||
open={launchDialogOpen}
|
||||
|
|
@ -2877,7 +2898,11 @@ export const TeamDetailView = ({
|
|||
onLaunch={handleLaunchDialogSubmit}
|
||||
onRelaunch={handleRelaunchDialogSubmit}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{sendDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<SendMessageDialog
|
||||
open={sendDialogOpen}
|
||||
teamName={teamName}
|
||||
|
|
@ -2933,7 +2958,11 @@ export const TeamDetailView = ({
|
|||
setSendDialogDefaultChip(undefined);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{selectedTask !== null && (
|
||||
<Suspense fallback={null}>
|
||||
<TaskDetailDialog
|
||||
open={selectedTask !== null}
|
||||
task={selectedTask}
|
||||
|
|
@ -2975,6 +3004,8 @@ export const TeamDetailView = ({
|
|||
}}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<TrashDialog
|
||||
open={trashOpen}
|
||||
|
|
@ -2991,6 +3022,8 @@ export const TeamDetailView = ({
|
|||
}}
|
||||
/>
|
||||
|
||||
{reviewDialogState.open && (
|
||||
<Suspense fallback={null}>
|
||||
<ChangeReviewDialog
|
||||
open={reviewDialogState.open}
|
||||
onOpenChange={(open) =>
|
||||
|
|
@ -3011,6 +3044,8 @@ export const TeamDetailView = ({
|
|||
projectPath={data.config.projectPath}
|
||||
onEditorAction={handleEditorAction}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={setMessagesPanelMountPoint}
|
||||
|
|
@ -3077,4 +3112,4 @@ export const TeamDetailView = ({
|
|||
{renderBody()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { api, isElectronMode } from '@renderer/api';
|
||||
|
|
@ -45,8 +45,6 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
||||
import { TeamEmptyState } from './TeamEmptyState';
|
||||
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
||||
import {
|
||||
|
|
@ -67,6 +65,13 @@ import type {
|
|||
TeamSummaryMember,
|
||||
} 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 {
|
||||
const base = sourceName.replace(/-\d+$/, '');
|
||||
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 electronMode = isElectronMode();
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
|
@ -731,7 +736,8 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
);
|
||||
}
|
||||
|
||||
const createDialogElement = (
|
||||
const createDialogElement = showCreateDialog && (
|
||||
<Suspense fallback={null}>
|
||||
<CreateTeamDialog
|
||||
open={showCreateDialog}
|
||||
canCreate={canCreate}
|
||||
|
|
@ -746,9 +752,11 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
onCreate={handleCreateSubmit}
|
||||
onOpenTeam={openTeamTab}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const launchDialogElement = (
|
||||
const launchDialogElement = launchDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LaunchTeamDialog
|
||||
mode="launch"
|
||||
open={launchDialogOpen}
|
||||
|
|
@ -761,6 +769,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
onClose={() => setLaunchDialogOpen(false)}
|
||||
onLaunch={handleLaunchSubmit}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const renderHeader = (): React.JSX.Element => (
|
||||
|
|
@ -1177,4 +1186,4 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
|
|
@ -32,14 +32,14 @@ interface ActivityEntry {
|
|||
kind: 'working' | 'reviewing';
|
||||
}
|
||||
|
||||
export const ActiveTasksBlock = ({
|
||||
export const ActiveTasksBlock = memo(function ActiveTasksBlock({
|
||||
members,
|
||||
tasks,
|
||||
defaultCollapsed = false,
|
||||
headerRight,
|
||||
onMemberClick,
|
||||
onTaskClick,
|
||||
}: ActiveTasksBlockProps): React.JSX.Element | null => {
|
||||
}: ActiveTasksBlockProps): React.JSX.Element | null {
|
||||
const { isLight } = useTheme();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
|
|
@ -188,4 +188,4 @@ export const ActiveTasksBlock = ({
|
|||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
|
|
@ -32,13 +34,13 @@ interface PendingRepliesBlockProps {
|
|||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
}
|
||||
|
||||
export const PendingRepliesBlock = ({
|
||||
export const PendingRepliesBlock = memo(function PendingRepliesBlock({
|
||||
members,
|
||||
pendingRepliesByMember,
|
||||
pendingCrossTeamReplies = [],
|
||||
headerRight,
|
||||
onMemberClick,
|
||||
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
||||
}: PendingRepliesBlockProps): React.JSX.Element | null {
|
||||
const { isLight } = useTheme();
|
||||
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
|
|
@ -270,4 +272,4 @@ export const PendingRepliesBlock = ({
|
|||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
|
|
@ -20,12 +20,13 @@ interface ReplyQuoteBlockProps {
|
|||
/** Threshold (characters) above which the "more/less" toggle is shown. */
|
||||
const LONG_QUOTE_THRESHOLD = 200;
|
||||
|
||||
export const ReplyQuoteBlock = ({
|
||||
export const ReplyQuoteBlock = memo(
|
||||
({
|
||||
reply,
|
||||
memberColor,
|
||||
bodyMaxHeight = 'max-h-56',
|
||||
replyTaskRefs,
|
||||
}: ReplyQuoteBlockProps): React.JSX.Element => {
|
||||
}: ReplyQuoteBlockProps): React.JSX.Element => {
|
||||
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
|
|
@ -76,4 +77,5 @@ export const ReplyQuoteBlock = ({
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { arrayMove } from '@dnd-kit/sortable';
|
||||
|
|
@ -311,7 +311,7 @@ const SortableKanbanTaskCard = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const KanbanBoard = ({
|
||||
export const KanbanBoard = memo(function KanbanBoard({
|
||||
tasks,
|
||||
teamName,
|
||||
kanbanState,
|
||||
|
|
@ -338,7 +338,7 @@ export const KanbanBoard = ({
|
|||
onDeleteTask,
|
||||
deletedTaskCount,
|
||||
onOpenTrash,
|
||||
}: KanbanBoardProps): React.JSX.Element => {
|
||||
}: KanbanBoardProps): React.JSX.Element {
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
|
||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||
|
|
@ -422,11 +422,8 @@ export const KanbanBoard = ({
|
|||
[onColumnOrderChange, groupedOrdered]
|
||||
);
|
||||
|
||||
const renderCards = (
|
||||
columnId: KanbanColumnId,
|
||||
columnTasks: TeamTask[],
|
||||
compact?: boolean
|
||||
): React.JSX.Element => {
|
||||
const renderCards = useCallback(
|
||||
(columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean): React.JSX.Element => {
|
||||
const addHandler =
|
||||
onAddTask && columnId === 'todo'
|
||||
? () => onAddTask(false)
|
||||
|
|
@ -516,7 +513,28 @@ export const KanbanBoard = ({
|
|||
{addButton}
|
||||
</>
|
||||
);
|
||||
};
|
||||
},
|
||||
[
|
||||
enableTaskSorting,
|
||||
hasReviewers,
|
||||
kanbanState,
|
||||
memberColorMap,
|
||||
onAddTask,
|
||||
onApprove,
|
||||
onCancelTask,
|
||||
onCompleteTask,
|
||||
onDeleteTask,
|
||||
onMoveBackToDone,
|
||||
onRequestChanges,
|
||||
onRequestReview,
|
||||
onScrollToTask,
|
||||
onStartTask,
|
||||
onTaskClick,
|
||||
onViewChanges,
|
||||
taskMap,
|
||||
teamName,
|
||||
]
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(
|
||||
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
|
||||
|
|
@ -591,6 +609,29 @@ export const KanbanBoard = ({
|
|||
[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 = (
|
||||
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
|
||||
<div
|
||||
|
|
@ -682,25 +723,7 @@ export const KanbanBoard = ({
|
|||
primaryColumnId={primaryVisibleColumnId}
|
||||
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
|
||||
skeletonDelayMs={gridSkeletonDelayMs}
|
||||
columns={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),
|
||||
})),
|
||||
};
|
||||
})}
|
||||
columns={gridColumns}
|
||||
/>
|
||||
) : (
|
||||
<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;
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* 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 { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
|
||||
|
|
@ -387,13 +387,14 @@ const LoadedKanbanGridLayout = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const KanbanGridLayout = ({
|
||||
export const KanbanGridLayout = memo(
|
||||
({
|
||||
columns,
|
||||
allColumnIds,
|
||||
primaryColumnId,
|
||||
onPrimaryColumnWidthChange,
|
||||
skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
|
||||
}: KanbanGridLayoutProps): React.JSX.Element => {
|
||||
}: KanbanGridLayoutProps): React.JSX.Element => {
|
||||
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
||||
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
|
||||
scopeKey: GRID_SCOPE_KEY,
|
||||
|
|
@ -454,7 +455,8 @@ export const KanbanGridLayout = ({
|
|||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
|
|
@ -15,13 +17,14 @@ interface CurrentTaskIndicatorProps {
|
|||
* Inline indicator showing a spinning loader + "working on" + task label button.
|
||||
* Shared between MemberCard and MemberHoverCard.
|
||||
*/
|
||||
export const CurrentTaskIndicator = ({
|
||||
export const CurrentTaskIndicator = memo(
|
||||
({
|
||||
task,
|
||||
borderColor,
|
||||
maxSubjectLength,
|
||||
activityLabel = 'working on',
|
||||
onOpenTask,
|
||||
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
||||
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
||||
const subjectText =
|
||||
typeof maxSubjectLength === 'number' &&
|
||||
maxSubjectLength > 0 &&
|
||||
|
|
@ -53,4 +56,5 @@ export const CurrentTaskIndicator = ({
|
|||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
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,
|
||||
memberColor,
|
||||
runtimeSummary,
|
||||
|
|
@ -119,7 +119,7 @@ export const MemberCard = ({
|
|||
onAssignTask,
|
||||
onRestartMember,
|
||||
onSkipMemberForLaunch,
|
||||
}: MemberCardProps): React.JSX.Element => {
|
||||
}: MemberCardProps): React.JSX.Element {
|
||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||
// const teamName = useStore((s) => s.selectedTeamName);
|
||||
// const leadContext = useStore((s) =>
|
||||
|
|
@ -711,4 +711,4 @@ export const MemberCard = ({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card';
|
||||
import {
|
||||
|
|
@ -57,13 +59,13 @@ interface MemberHoverCardProps {
|
|||
* Reads member data from the team snapshot + resolved member selectors.
|
||||
* Falls back to a simple wrapper when member data is unavailable.
|
||||
*/
|
||||
export const MemberHoverCard = ({
|
||||
export const MemberHoverCard = memo(function MemberHoverCard({
|
||||
name,
|
||||
color,
|
||||
teamName,
|
||||
onOpenTask,
|
||||
children,
|
||||
}: MemberHoverCardProps): React.JSX.Element => {
|
||||
}: MemberHoverCardProps): React.JSX.Element {
|
||||
const { isLight } = useTheme();
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||
|
|
@ -289,4 +291,4 @@ export const MemberHoverCard = ({
|
|||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
|||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type {
|
||||
LeadActivityState,
|
||||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
MemberSpawnStatusEntry,
|
||||
ResolvedTeamMember,
|
||||
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({
|
||||
members,
|
||||
memberTaskCounts,
|
||||
|
|
@ -340,63 +452,89 @@ export const MemberList = memo(function MemberList({
|
|||
return result;
|
||||
}, [taskMap]);
|
||||
|
||||
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-1">
|
||||
<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 awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]);
|
||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||
return (
|
||||
<MemberCard
|
||||
<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={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}
|
||||
leadActivity={leadActivity}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
onOpenTask={onOpenTask}
|
||||
onMemberClick={onMemberClick}
|
||||
onSendMessage={onSendMessage}
|
||||
onAssignTask={onAssignTask}
|
||||
onRestartMember={onRestartMember}
|
||||
onSkipMemberForLaunch={onSkipMemberForLaunch}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-1">
|
||||
<div className={gridClass}>{activeMembers.map((member) => renderCard(member, false))}</div>
|
||||
})}
|
||||
</div>
|
||||
{removedMembers.length > 0 && (
|
||||
<>
|
||||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
Removed ({removedMembers.length})
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
|
|
@ -8,10 +10,8 @@ interface MemberPresenceDotProps {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export const MemberPresenceDot = ({
|
||||
className,
|
||||
label,
|
||||
}: MemberPresenceDotProps): React.JSX.Element => {
|
||||
export const MemberPresenceDot = memo(
|
||||
({ className, label }: MemberPresenceDotProps): React.JSX.Element => {
|
||||
const shouldSyncPulse = className?.includes('animate-pulse') === true;
|
||||
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
|
||||
|
||||
|
|
@ -25,4 +25,5 @@ export const MemberPresenceDot = ({
|
|||
aria-label={label}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
|
|
@ -18,13 +18,15 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog';
|
||||
|
||||
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
||||
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
|
||||
import { ScheduleRunRow } from './ScheduleRunRow';
|
||||
import { ScheduleStatusBadge } from './ScheduleStatusBadge';
|
||||
|
||||
const LaunchTeamDialog = lazy(() =>
|
||||
import('../dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
|
||||
);
|
||||
|
||||
import type { Schedule, ScheduleRun } from '@shared/types';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -305,6 +307,8 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
|
|||
)}
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
{dialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LaunchTeamDialog
|
||||
mode="schedule"
|
||||
open={dialogOpen}
|
||||
|
|
@ -312,6 +316,8 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
|
|||
schedule={editingSchedule}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import {
|
||||
KANBAN_COLUMN_DISPLAY,
|
||||
REVIEW_STATE_DISPLAY,
|
||||
|
|
@ -12,7 +14,7 @@ interface TaskRowProps {
|
|||
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 blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||
const kanbanColumn = getTaskKanbanColumn(task);
|
||||
|
|
@ -62,4 +64,4 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
|||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
|||
});
|
||||
|
||||
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
|
||||
import {
|
||||
TeamMcpConfigBuilder,
|
||||
clearResolvedNodePathForTests,
|
||||
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||
|
||||
describe('TeamMcpConfigBuilder', () => {
|
||||
const createdPaths: string[] = [];
|
||||
|
|
@ -93,7 +96,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
entry: string
|
||||
): void {
|
||||
expect(server?.args).toEqual([entry]);
|
||||
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/);
|
||||
expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/);
|
||||
}
|
||||
|
||||
function expectNodeTsxSourceEntry(
|
||||
|
|
@ -102,7 +105,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
sourceEntry: string
|
||||
): void {
|
||||
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 {
|
||||
|
|
@ -165,6 +168,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearResolvedNodePathForTests();
|
||||
originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-'));
|
||||
createdDirs.push(tempAppData);
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ const hoisted = vi.hoisted(() => {
|
|||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
const size = Buffer.byteLength(data, 'utf8');
|
||||
return {
|
||||
isFile: () => true,
|
||||
size: Buffer.byteLength(data, 'utf8'),
|
||||
size,
|
||||
mode: 0o100644,
|
||||
dev: 1,
|
||||
ino: 1,
|
||||
|
|
@ -63,8 +64,7 @@ const hoisted = vi.hoisted(() => {
|
|||
files.set(sentMessagesPath, JSON.stringify(rows));
|
||||
return message;
|
||||
}),
|
||||
sendInboxMessage: vi.fn(
|
||||
(teamName: string, message: Record<string, unknown>) => {
|
||||
sendInboxMessage: vi.fn((teamName: string, message: Record<string, unknown>) => {
|
||||
const member =
|
||||
typeof message.member === 'string'
|
||||
? message.member
|
||||
|
|
@ -77,8 +77,7 @@ const hoisted = vi.hoisted(() => {
|
|||
rows.push(message);
|
||||
files.set(p, JSON.stringify(rows));
|
||||
return { deliveredToInbox: true, messageId: 'mock-id', message };
|
||||
}
|
||||
),
|
||||
}),
|
||||
setAtomicWriteShouldFail: (next: boolean) => {
|
||||
atomicWriteShouldFail = next;
|
||||
},
|
||||
|
|
@ -419,7 +418,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('Source: system_notification');
|
||||
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');
|
||||
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
|
|
@ -540,9 +541,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
runId: 'run-old',
|
||||
});
|
||||
const inboxDeferred = createDeferred<typeof inboxMessages>();
|
||||
const inboxReader = (service as unknown as {
|
||||
inboxReader: { getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages> };
|
||||
}).inboxReader;
|
||||
const inboxReader = (
|
||||
service as unknown as {
|
||||
inboxReader: {
|
||||
getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages>;
|
||||
};
|
||||
}
|
||||
).inboxReader;
|
||||
const inboxSpy = vi
|
||||
.spyOn(inboxReader, 'getMessagesFor')
|
||||
.mockImplementationOnce(async () => await inboxDeferred.promise)
|
||||
|
|
@ -586,14 +591,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
|
||||
const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' });
|
||||
const inboxDeferred = createDeferred<[typeof permissionMessage]>();
|
||||
const inboxReader = (service as unknown as {
|
||||
const inboxReader = (
|
||||
service as unknown as {
|
||||
inboxReader: {
|
||||
getMessagesFor: (
|
||||
team: string,
|
||||
member: string
|
||||
) => Promise<[typeof permissionMessage]>;
|
||||
getMessagesFor: (team: string, member: string) => Promise<[typeof permissionMessage]>;
|
||||
};
|
||||
}).inboxReader;
|
||||
}
|
||||
).inboxReader;
|
||||
const inboxSpy = vi
|
||||
.spyOn(inboxReader, 'getMessagesFor')
|
||||
.mockImplementationOnce(async () => await inboxDeferred.promise)
|
||||
|
|
@ -702,7 +706,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('Source: cross_team');
|
||||
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('NEVER set recipient/to to \\"cross_team_send\\"');
|
||||
|
||||
|
|
@ -953,7 +959,11 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
attachAliveRun(service, teamName);
|
||||
|
||||
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 = {
|
||||
target: 'alice',
|
||||
|
|
@ -1120,9 +1130,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
runId: 'run-old',
|
||||
});
|
||||
const inboxDeferred = createDeferred<typeof inboxMessages>();
|
||||
const inboxReader = (service as unknown as {
|
||||
inboxReader: { getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages> };
|
||||
}).inboxReader;
|
||||
const inboxReader = (
|
||||
service as unknown as {
|
||||
inboxReader: {
|
||||
getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages>;
|
||||
};
|
||||
}
|
||||
).inboxReader;
|
||||
const inboxSpy = vi
|
||||
.spyOn(inboxReader, 'getMessagesFor')
|
||||
.mockImplementationOnce(async () => await inboxDeferred.promise)
|
||||
|
|
@ -1332,11 +1346,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
|
||||
await (service as any).markInboxMessagesRead(teamName, 'alice', [
|
||||
{
|
||||
messageId: buildLegacyInboxMessageId(
|
||||
legacyRow.from,
|
||||
legacyRow.timestamp,
|
||||
legacyRow.text
|
||||
),
|
||||
messageId: buildLegacyInboxMessageId(legacyRow.from, legacyRow.timestamp, legacyRow.text),
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
@ -1732,9 +1742,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
|
||||
})
|
||||
);
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
expect(rows[0].read).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -1780,9 +1788,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
failed: 0,
|
||||
lastDelivery: { delivered: true, responsePending: true },
|
||||
});
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
expect(rows[0].read).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -1914,9 +1920,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
teamName,
|
||||
expect.objectContaining({ messageId: 'opencode-terminal-new' })
|
||||
);
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
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'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
expect(rows[0].read).toBe(false);
|
||||
expect(records[0]).toMatchObject({
|
||||
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);
|
||||
const laneId = identity.laneId;
|
||||
const records: any[] = [];
|
||||
|
|
@ -2048,7 +2053,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
return record;
|
||||
}),
|
||||
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);
|
||||
Object.assign(record, {
|
||||
status: 'failed_retryable',
|
||||
|
|
@ -2180,9 +2190,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
teamName,
|
||||
expect.objectContaining({ messageId: 'opencode-inflight-new' })
|
||||
);
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
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'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
expect(rows[0].read).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -2387,9 +2393,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
delivered: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue(
|
||||
new Error('write failed')
|
||||
);
|
||||
vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue(new Error('write failed'));
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||
|
||||
|
|
@ -2410,9 +2414,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
'opencode_inbox_mark_read_failed_after_delivery'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
expect(rows[0].read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue