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,
|
cwd: request.config.cwd,
|
||||||
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
|
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
|
||||||
// take precedence over the cached snapshot inside buildEnrichedEnv.
|
// take precedence over the cached snapshot inside buildEnrichedEnv.
|
||||||
env,
|
// CLAUDECODE stripped last to prevent nested-session detection regardless
|
||||||
|
// of what buildProviderAwareCliEnv merges in.
|
||||||
|
env: { ...env, CLAUDECODE: undefined },
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,10 @@ async function hasValidServerCopy(dir: string): Promise<boolean> {
|
||||||
|
|
||||||
let _resolvedNodePath: string | undefined;
|
let _resolvedNodePath: string | undefined;
|
||||||
|
|
||||||
|
export function clearResolvedNodePathForTests(): void {
|
||||||
|
_resolvedNodePath = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the real `node` binary path. In Electron, process.execPath is the
|
* Find the real `node` binary path. In Electron, process.execPath is the
|
||||||
* Electron binary — NOT node — so we must resolve node separately.
|
* Electron binary — NOT node — so we must resolve node separately.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,9 +28,9 @@ interface CompactBoundaryProps {
|
||||||
* CompactBoundary displays a horizontal divider indicating where
|
* CompactBoundary displays a horizontal divider indicating where
|
||||||
* the conversation was compacted. Click to expand the compacted summary.
|
* the conversation was compacted. Click to expand the compacted summary.
|
||||||
*/
|
*/
|
||||||
export const CompactBoundary = ({
|
export const CompactBoundary = memo(function CompactBoundary({
|
||||||
compactGroup,
|
compactGroup,
|
||||||
}: Readonly<CompactBoundaryProps>): React.JSX.Element => {
|
}: Readonly<CompactBoundaryProps>): React.JSX.Element {
|
||||||
const { timestamp, message } = compactGroup;
|
const { timestamp, message } = compactGroup;
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
|
@ -166,4 +166,4 @@ export const CompactBoundary = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { memo, useCallback, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CODE_BG,
|
CODE_BG,
|
||||||
|
|
@ -65,9 +65,6 @@ function buildItemMetaTooltip(
|
||||||
return parts.length > 0 ? parts.join(' • ') : undefined;
|
return parts.length > 0 ? parts.join(' • ') : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncates text to a maximum length and adds ellipsis if needed.
|
|
||||||
*/
|
|
||||||
function truncateText(text: string, maxLength: number): string {
|
function truncateText(text: string, maxLength: number): string {
|
||||||
if (text.length <= maxLength) {
|
if (text.length <= maxLength) {
|
||||||
return text;
|
return text;
|
||||||
|
|
@ -75,6 +72,345 @@ function truncateText(text: string, maxLength: number): string {
|
||||||
return text.substring(0, maxLength) + '...';
|
return text.substring(0, maxLength) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getItemKey(item: AIGroupDisplayItem, index: number): string {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'thinking':
|
||||||
|
return `thinking-${index}`;
|
||||||
|
case 'output':
|
||||||
|
return `output-${index}`;
|
||||||
|
case 'tool':
|
||||||
|
return `tool-${item.tool.id}-${index}`;
|
||||||
|
case 'subagent':
|
||||||
|
return `subagent-${item.subagent.id}-${index}`;
|
||||||
|
case 'slash':
|
||||||
|
return `slash-${item.slash.name}-${index}`;
|
||||||
|
case 'teammate_message':
|
||||||
|
return `teammate-${item.teammateMessage.id}-${index}`;
|
||||||
|
case 'subagent_input':
|
||||||
|
return `input-${index}`;
|
||||||
|
case 'compact_boundary':
|
||||||
|
return `compact-${index}`;
|
||||||
|
default:
|
||||||
|
return `unknown-${index}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Per-item row — memoized to prevent re-renders from parent state changes
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface DisplayItemRowProps {
|
||||||
|
item: AIGroupDisplayItem;
|
||||||
|
index: number;
|
||||||
|
itemKey: string;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isDimmed: boolean;
|
||||||
|
hasReplyLink: boolean;
|
||||||
|
onItemClick: (key: string) => void;
|
||||||
|
onReplyHover: (toolId: string | null) => void;
|
||||||
|
aiGroupId: string;
|
||||||
|
searchQueryOverride?: string;
|
||||||
|
highlightToolUseId?: string;
|
||||||
|
highlightColor?: TriggerColor;
|
||||||
|
notificationColorMap?: Map<string, TriggerColor>;
|
||||||
|
registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void;
|
||||||
|
previewMaxLength?: number;
|
||||||
|
timestampFormat?: string;
|
||||||
|
showItemMetaTooltip?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DisplayItemRow = memo(function DisplayItemRow({
|
||||||
|
item,
|
||||||
|
index: _index,
|
||||||
|
itemKey,
|
||||||
|
isExpanded,
|
||||||
|
isDimmed,
|
||||||
|
hasReplyLink,
|
||||||
|
onItemClick,
|
||||||
|
onReplyHover,
|
||||||
|
aiGroupId,
|
||||||
|
searchQueryOverride,
|
||||||
|
highlightToolUseId,
|
||||||
|
highlightColor,
|
||||||
|
notificationColorMap,
|
||||||
|
registerToolRef,
|
||||||
|
previewMaxLength,
|
||||||
|
timestampFormat,
|
||||||
|
showItemMetaTooltip = false,
|
||||||
|
}: DisplayItemRowProps): React.JSX.Element | null {
|
||||||
|
const handleClick = useCallback(() => onItemClick(itemKey), [onItemClick, itemKey]);
|
||||||
|
|
||||||
|
let element: React.ReactNode = null;
|
||||||
|
|
||||||
|
switch (item.type) {
|
||||||
|
case 'thinking': {
|
||||||
|
const thinkingStep = {
|
||||||
|
id: itemKey,
|
||||||
|
type: 'thinking' as const,
|
||||||
|
startTime: item.timestamp,
|
||||||
|
endTime: item.timestamp,
|
||||||
|
durationMs: 0,
|
||||||
|
content: { thinkingText: item.content, tokenCount: item.tokenCount },
|
||||||
|
tokens: { input: 0, output: item.tokenCount ?? 0 },
|
||||||
|
context: 'main' as const,
|
||||||
|
};
|
||||||
|
element = (
|
||||||
|
<ThinkingItem
|
||||||
|
step={thinkingStep}
|
||||||
|
preview={truncateText(item.content, previewMaxLength ?? 150)}
|
||||||
|
onClick={handleClick}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
timestampFormat={timestampFormat}
|
||||||
|
titleText={
|
||||||
|
showItemMetaTooltip
|
||||||
|
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||||
|
searchQueryOverride={searchQueryOverride}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'output': {
|
||||||
|
const textStep = {
|
||||||
|
id: itemKey,
|
||||||
|
type: 'output' as const,
|
||||||
|
startTime: item.timestamp,
|
||||||
|
endTime: item.timestamp,
|
||||||
|
durationMs: 0,
|
||||||
|
content: { outputText: item.content, tokenCount: item.tokenCount },
|
||||||
|
tokens: { input: 0, output: item.tokenCount ?? 0 },
|
||||||
|
context: 'main' as const,
|
||||||
|
};
|
||||||
|
element = (
|
||||||
|
<TextItem
|
||||||
|
step={textStep}
|
||||||
|
preview={truncateText(item.content, previewMaxLength ?? 150)}
|
||||||
|
onClick={handleClick}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
timestampFormat={timestampFormat}
|
||||||
|
titleText={
|
||||||
|
showItemMetaTooltip
|
||||||
|
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||||
|
searchQueryOverride={searchQueryOverride}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool': {
|
||||||
|
element = (
|
||||||
|
<LinkedToolItem
|
||||||
|
linkedTool={item.tool}
|
||||||
|
onClick={handleClick}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.tool.startTime}
|
||||||
|
timestampFormat={timestampFormat}
|
||||||
|
titleText={
|
||||||
|
showItemMetaTooltip
|
||||||
|
? buildItemMetaTooltip(item.tool.startTime, getToolContextTokens(item.tool), 'tokens')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
searchQueryOverride={searchQueryOverride}
|
||||||
|
isHighlighted={highlightToolUseId === item.tool.id}
|
||||||
|
highlightColor={highlightColor}
|
||||||
|
notificationDotColor={notificationColorMap?.get(item.tool.id)}
|
||||||
|
registerRef={registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'subagent': {
|
||||||
|
const subagentStep = {
|
||||||
|
id: itemKey,
|
||||||
|
type: 'subagent' as const,
|
||||||
|
startTime: item.subagent.startTime,
|
||||||
|
endTime: item.subagent.endTime,
|
||||||
|
durationMs: item.subagent.durationMs,
|
||||||
|
content: {
|
||||||
|
subagentId: item.subagent.id,
|
||||||
|
subagentDescription: item.subagent.description,
|
||||||
|
},
|
||||||
|
isParallel: item.subagent.isParallel,
|
||||||
|
context: 'main' as const,
|
||||||
|
};
|
||||||
|
element = (
|
||||||
|
<SubagentItem
|
||||||
|
step={subagentStep}
|
||||||
|
subagent={item.subagent}
|
||||||
|
onClick={handleClick}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
aiGroupId={aiGroupId}
|
||||||
|
highlightToolUseId={highlightToolUseId}
|
||||||
|
highlightColor={highlightColor}
|
||||||
|
notificationColorMap={notificationColorMap}
|
||||||
|
registerToolRef={registerToolRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'slash': {
|
||||||
|
element = (
|
||||||
|
<SlashItem
|
||||||
|
slash={item.slash}
|
||||||
|
onClick={handleClick}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.slash.timestamp}
|
||||||
|
timestampFormat={timestampFormat}
|
||||||
|
titleText={
|
||||||
|
showItemMetaTooltip
|
||||||
|
? buildItemMetaTooltip(
|
||||||
|
item.slash.timestamp,
|
||||||
|
item.slash.instructionsTokenCount,
|
||||||
|
'tokens'
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'teammate_message': {
|
||||||
|
element = (
|
||||||
|
<TeammateMessageItem
|
||||||
|
teammateMessage={item.teammateMessage}
|
||||||
|
onClick={handleClick}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onReplyHover={onReplyHover}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'subagent_input': {
|
||||||
|
const inputContent = item.content;
|
||||||
|
const inputTokenCount = item.tokenCount;
|
||||||
|
element = (
|
||||||
|
<BaseItem
|
||||||
|
icon={<MailOpen className="size-4" />}
|
||||||
|
label="Input"
|
||||||
|
summary={truncateText(inputContent, previewMaxLength ?? 80)}
|
||||||
|
tokenCount={inputTokenCount}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
timestampFormat={timestampFormat}
|
||||||
|
titleText={
|
||||||
|
showItemMetaTooltip
|
||||||
|
? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={handleClick}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
>
|
||||||
|
<MarkdownViewer
|
||||||
|
content={inputContent}
|
||||||
|
copyable
|
||||||
|
itemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||||
|
searchQueryOverride={searchQueryOverride}
|
||||||
|
/>
|
||||||
|
</BaseItem>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'compact_boundary': {
|
||||||
|
const compactContent = item.content;
|
||||||
|
element = (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: TOOL_CALL_BG,
|
||||||
|
border: `1px solid ${TOOL_CALL_BORDER}`,
|
||||||
|
}}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5" style={{ color: TOOL_CALL_TEXT }}>
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
/>
|
||||||
|
<Layers size={14} />
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
|
||||||
|
Compacted
|
||||||
|
</span>
|
||||||
|
{item.tokenDelta && (
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate text-[11px] tabular-nums"
|
||||||
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
|
>
|
||||||
|
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} →{' '}
|
||||||
|
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
|
||||||
|
<span style={{ color: '#4ade80' }}>
|
||||||
|
{' '}
|
||||||
|
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
color: '#818cf8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Phase {item.phaseNumber}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
{format(new Date(item.timestamp), 'h:mm:ss a')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && compactContent && (
|
||||||
|
<div
|
||||||
|
className="mt-1 overflow-hidden rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: CODE_BG,
|
||||||
|
border: `1px solid ${CODE_BORDER}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
|
||||||
|
style={{ borderColor: 'var(--chat-ai-border)' }}
|
||||||
|
>
|
||||||
|
<MarkdownViewer content={compactContent} copyable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
hasReplyLink ? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' } : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{element}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Main component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a flat list of AIGroupDisplayItem[] into the appropriate components.
|
* Renders a flat list of AIGroupDisplayItem[] into the appropriate components.
|
||||||
*
|
*
|
||||||
|
|
@ -87,7 +423,7 @@ function truncateText(text: string, maxLength: number): string {
|
||||||
*
|
*
|
||||||
* The list is completely flat with no nested toggles or hierarchies.
|
* The list is completely flat with no nested toggles or hierarchies.
|
||||||
*/
|
*/
|
||||||
export const DisplayItemList = ({
|
export const DisplayItemList = React.memo(function DisplayItemList({
|
||||||
items,
|
items,
|
||||||
onItemClick,
|
onItemClick,
|
||||||
expandedItemIds,
|
expandedItemIds,
|
||||||
|
|
@ -101,23 +437,13 @@ export const DisplayItemList = ({
|
||||||
previewMaxLength,
|
previewMaxLength,
|
||||||
timestampFormat,
|
timestampFormat,
|
||||||
showItemMetaTooltip = false,
|
showItemMetaTooltip = false,
|
||||||
}: Readonly<DisplayItemListProps>): React.JSX.Element => {
|
}: Readonly<DisplayItemListProps>): React.JSX.Element {
|
||||||
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
|
|
||||||
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
|
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleReplyHover = useCallback((toolId: string | null) => {
|
const handleReplyHover = useCallback((toolId: string | null) => {
|
||||||
setReplyLinkToolId(toolId);
|
setReplyLinkToolId(toolId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Check if an item is part of the currently highlighted reply link */
|
|
||||||
const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => {
|
|
||||||
if (!replyLinkToolId) return false;
|
|
||||||
if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true;
|
|
||||||
if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId)
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
|
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
|
||||||
|
|
@ -133,299 +459,39 @@ export const DisplayItemList = ({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
let itemKey = '';
|
const itemKey = getItemKey(item, index);
|
||||||
let element: React.ReactNode = null;
|
const isExpanded = expandedItemIds.has(itemKey);
|
||||||
|
|
||||||
switch (item.type) {
|
const isInReplyLink =
|
||||||
case 'thinking': {
|
replyLinkToolId !== null &&
|
||||||
itemKey = `thinking-${index}`;
|
((item.type === 'tool' && item.tool.id === replyLinkToolId) ||
|
||||||
const thinkingStep = {
|
(item.type === 'teammate_message' &&
|
||||||
id: itemKey,
|
item.teammateMessage.replyToToolId === replyLinkToolId));
|
||||||
type: 'thinking' as const,
|
const isDimmed = replyLinkToolId !== null && !isInReplyLink;
|
||||||
startTime: item.timestamp,
|
|
||||||
endTime: item.timestamp,
|
|
||||||
durationMs: 0,
|
|
||||||
content: { thinkingText: item.content, tokenCount: item.tokenCount },
|
|
||||||
tokens: { input: 0, output: item.tokenCount ?? 0 },
|
|
||||||
context: 'main' as const,
|
|
||||||
};
|
|
||||||
element = (
|
|
||||||
<ThinkingItem
|
|
||||||
step={thinkingStep}
|
|
||||||
preview={truncateText(item.content, previewMaxLength ?? 150)}
|
|
||||||
onClick={() => onItemClick(itemKey)}
|
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
|
||||||
timestamp={item.timestamp}
|
|
||||||
timestampFormat={timestampFormat}
|
|
||||||
titleText={
|
|
||||||
showItemMetaTooltip
|
|
||||||
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
|
||||||
searchQueryOverride={searchQueryOverride}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'output': {
|
|
||||||
itemKey = `output-${index}`;
|
|
||||||
const textStep = {
|
|
||||||
id: itemKey,
|
|
||||||
type: 'output' as const,
|
|
||||||
startTime: item.timestamp,
|
|
||||||
endTime: item.timestamp,
|
|
||||||
durationMs: 0,
|
|
||||||
content: { outputText: item.content, tokenCount: item.tokenCount },
|
|
||||||
tokens: { input: 0, output: item.tokenCount ?? 0 },
|
|
||||||
context: 'main' as const,
|
|
||||||
};
|
|
||||||
element = (
|
|
||||||
<TextItem
|
|
||||||
step={textStep}
|
|
||||||
preview={truncateText(item.content, previewMaxLength ?? 150)}
|
|
||||||
onClick={() => onItemClick(itemKey)}
|
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
|
||||||
timestamp={item.timestamp}
|
|
||||||
timestampFormat={timestampFormat}
|
|
||||||
titleText={
|
|
||||||
showItemMetaTooltip
|
|
||||||
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
|
||||||
searchQueryOverride={searchQueryOverride}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool': {
|
|
||||||
itemKey = `tool-${item.tool.id}-${index}`;
|
|
||||||
element = (
|
|
||||||
<LinkedToolItem
|
|
||||||
linkedTool={item.tool}
|
|
||||||
onClick={() => onItemClick(itemKey)}
|
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
|
||||||
timestamp={item.tool.startTime}
|
|
||||||
timestampFormat={timestampFormat}
|
|
||||||
titleText={
|
|
||||||
showItemMetaTooltip
|
|
||||||
? buildItemMetaTooltip(
|
|
||||||
item.tool.startTime,
|
|
||||||
getToolContextTokens(item.tool),
|
|
||||||
'tokens'
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
searchQueryOverride={searchQueryOverride}
|
|
||||||
isHighlighted={highlightToolUseId === item.tool.id}
|
|
||||||
highlightColor={highlightColor}
|
|
||||||
notificationDotColor={notificationColorMap?.get(item.tool.id)}
|
|
||||||
registerRef={
|
|
||||||
registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'subagent': {
|
|
||||||
itemKey = `subagent-${item.subagent.id}-${index}`;
|
|
||||||
const subagentStep = {
|
|
||||||
id: itemKey,
|
|
||||||
type: 'subagent' as const,
|
|
||||||
startTime: item.subagent.startTime,
|
|
||||||
endTime: item.subagent.endTime,
|
|
||||||
durationMs: item.subagent.durationMs,
|
|
||||||
content: {
|
|
||||||
subagentId: item.subagent.id,
|
|
||||||
subagentDescription: item.subagent.description,
|
|
||||||
},
|
|
||||||
isParallel: item.subagent.isParallel,
|
|
||||||
context: 'main' as const,
|
|
||||||
};
|
|
||||||
element = (
|
|
||||||
<SubagentItem
|
|
||||||
step={subagentStep}
|
|
||||||
subagent={item.subagent}
|
|
||||||
onClick={() => onItemClick(itemKey)}
|
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
|
||||||
aiGroupId={aiGroupId}
|
|
||||||
highlightToolUseId={highlightToolUseId}
|
|
||||||
highlightColor={highlightColor}
|
|
||||||
notificationColorMap={notificationColorMap}
|
|
||||||
registerToolRef={registerToolRef}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'slash': {
|
|
||||||
itemKey = `slash-${item.slash.name}-${index}`;
|
|
||||||
element = (
|
|
||||||
<SlashItem
|
|
||||||
slash={item.slash}
|
|
||||||
onClick={() => onItemClick(itemKey)}
|
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
|
||||||
timestamp={item.slash.timestamp}
|
|
||||||
timestampFormat={timestampFormat}
|
|
||||||
titleText={
|
|
||||||
showItemMetaTooltip
|
|
||||||
? buildItemMetaTooltip(
|
|
||||||
item.slash.timestamp,
|
|
||||||
item.slash.instructionsTokenCount,
|
|
||||||
'tokens'
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'teammate_message': {
|
|
||||||
itemKey = `teammate-${item.teammateMessage.id}-${index}`;
|
|
||||||
element = (
|
|
||||||
<TeammateMessageItem
|
|
||||||
teammateMessage={item.teammateMessage}
|
|
||||||
onClick={() => onItemClick(itemKey)}
|
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
|
||||||
onReplyHover={handleReplyHover}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'subagent_input': {
|
|
||||||
itemKey = `input-${index}`;
|
|
||||||
const inputContent = item.content;
|
|
||||||
const inputTokenCount = item.tokenCount;
|
|
||||||
element = (
|
|
||||||
<BaseItem
|
|
||||||
icon={<MailOpen className="size-4" />}
|
|
||||||
label="Input"
|
|
||||||
summary={truncateText(inputContent, previewMaxLength ?? 80)}
|
|
||||||
tokenCount={inputTokenCount}
|
|
||||||
timestamp={item.timestamp}
|
|
||||||
timestampFormat={timestampFormat}
|
|
||||||
titleText={
|
|
||||||
showItemMetaTooltip
|
|
||||||
? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => onItemClick(itemKey)}
|
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
|
||||||
>
|
|
||||||
<MarkdownViewer
|
|
||||||
content={inputContent}
|
|
||||||
copyable
|
|
||||||
itemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
|
||||||
searchQueryOverride={searchQueryOverride}
|
|
||||||
/>
|
|
||||||
</BaseItem>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'compact_boundary': {
|
|
||||||
itemKey = `compact-${index}`;
|
|
||||||
const compactContent = item.content;
|
|
||||||
const compactExpanded = expandedItemIds.has(itemKey);
|
|
||||||
element = (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => onItemClick(itemKey)}
|
|
||||||
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
|
|
||||||
style={{
|
|
||||||
backgroundColor: TOOL_CALL_BG,
|
|
||||||
border: `1px solid ${TOOL_CALL_BORDER}`,
|
|
||||||
}}
|
|
||||||
aria-expanded={compactExpanded}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex shrink-0 items-center gap-1.5"
|
|
||||||
style={{ color: TOOL_CALL_TEXT }}
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
size={14}
|
|
||||||
className={`transition-transform duration-200 ${compactExpanded ? 'rotate-90' : ''}`}
|
|
||||||
/>
|
|
||||||
<Layers size={14} />
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
|
|
||||||
Compacted
|
|
||||||
</span>
|
|
||||||
{item.tokenDelta && (
|
|
||||||
<span
|
|
||||||
className="min-w-0 truncate text-[11px] tabular-nums"
|
|
||||||
style={{ color: COLOR_TEXT_MUTED }}
|
|
||||||
>
|
|
||||||
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} →{' '}
|
|
||||||
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
|
|
||||||
<span style={{ color: '#4ade80' }}>
|
|
||||||
{' '}
|
|
||||||
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(99, 102, 241, 0.15)',
|
|
||||||
color: '#818cf8',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Phase {item.phaseNumber}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="ml-auto shrink-0 text-[11px]"
|
|
||||||
style={{ color: COLOR_TEXT_MUTED }}
|
|
||||||
>
|
|
||||||
{format(new Date(item.timestamp), 'h:mm:ss a')}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{compactExpanded && compactContent && (
|
|
||||||
<div
|
|
||||||
className="mt-1 overflow-hidden rounded-lg"
|
|
||||||
style={{
|
|
||||||
backgroundColor: CODE_BG,
|
|
||||||
border: `1px solid ${CODE_BORDER}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
|
|
||||||
style={{ borderColor: 'var(--chat-ai-border)' }}
|
|
||||||
>
|
|
||||||
<MarkdownViewer content={compactContent} copyable />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply reply-link spotlight: dim items not in the highlighted pair
|
|
||||||
const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DisplayItemRow
|
||||||
key={itemKey}
|
key={itemKey}
|
||||||
style={
|
item={item}
|
||||||
replyLinkToolId !== null
|
index={index}
|
||||||
? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' }
|
itemKey={itemKey}
|
||||||
: undefined
|
isExpanded={isExpanded}
|
||||||
}
|
isDimmed={isDimmed}
|
||||||
>
|
hasReplyLink={replyLinkToolId !== null}
|
||||||
{element}
|
onItemClick={onItemClick}
|
||||||
</div>
|
onReplyHover={handleReplyHover}
|
||||||
|
aiGroupId={aiGroupId}
|
||||||
|
searchQueryOverride={searchQueryOverride}
|
||||||
|
highlightToolUseId={highlightToolUseId}
|
||||||
|
highlightColor={highlightColor}
|
||||||
|
notificationColorMap={notificationColorMap}
|
||||||
|
registerToolRef={registerToolRef}
|
||||||
|
previewMaxLength={previewMaxLength}
|
||||||
|
timestampFormat={timestampFormat}
|
||||||
|
showItemMetaTooltip={showItemMetaTooltip}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables';
|
import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors';
|
import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors';
|
||||||
|
|
@ -57,14 +57,14 @@ interface BaseItemProps {
|
||||||
/**
|
/**
|
||||||
* Small status dot indicator.
|
* Small status dot indicator.
|
||||||
*/
|
*/
|
||||||
export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => {
|
export const StatusDot = memo(function StatusDot({ status }: { status: ItemStatus }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="base-item-status-dot inline-block size-1.5 shrink-0 rounded-full"
|
className="base-item-status-dot inline-block size-1.5 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: getStatusDotColor(status) }}
|
style={{ backgroundColor: getStatusDotColor(status) }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Main Component
|
// Main Component
|
||||||
|
|
@ -79,135 +79,140 @@ export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => {
|
||||||
*
|
*
|
||||||
* Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem
|
* Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem
|
||||||
*/
|
*/
|
||||||
export const BaseItem: React.FC<BaseItemProps> = ({
|
export const BaseItem = memo(
|
||||||
icon,
|
({
|
||||||
label,
|
icon,
|
||||||
summary,
|
label,
|
||||||
tokenCount,
|
summary,
|
||||||
tokenLabel = 'tokens',
|
tokenCount,
|
||||||
status,
|
tokenLabel = 'tokens',
|
||||||
durationMs,
|
status,
|
||||||
timestamp,
|
durationMs,
|
||||||
timestampFormat = 'HH:mm:ss',
|
timestamp,
|
||||||
titleText,
|
timestampFormat = 'HH:mm:ss',
|
||||||
onClick,
|
titleText,
|
||||||
isExpanded,
|
onClick,
|
||||||
hasExpandableContent = true,
|
isExpanded,
|
||||||
highlightClasses = '',
|
hasExpandableContent = true,
|
||||||
highlightStyle,
|
highlightClasses = '',
|
||||||
notificationDotColor,
|
highlightStyle,
|
||||||
children,
|
notificationDotColor,
|
||||||
}) => {
|
children,
|
||||||
return (
|
}: BaseItemProps): React.JSX.Element => {
|
||||||
<div
|
return (
|
||||||
className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
|
||||||
style={highlightStyle}
|
|
||||||
>
|
|
||||||
{/* Clickable Header */}
|
|
||||||
<div
|
<div
|
||||||
role="button"
|
className={`rounded transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
||||||
tabIndex={0}
|
style={highlightStyle}
|
||||||
title={titleText}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
|
||||||
style={{ backgroundColor: 'transparent' }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
Object.assign(e.currentTarget.style, { backgroundColor: 'var(--tool-item-hover-bg)' })
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Clickable Header */}
|
||||||
<span className="size-4 shrink-0" style={{ color: TOOL_ITEM_MUTED }}>
|
<div
|
||||||
{icon}
|
role="button"
|
||||||
</span>
|
tabIndex={0}
|
||||||
|
title={titleText}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
||||||
|
style={{ backgroundColor: 'transparent' }}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
Object.assign(e.currentTarget.style, { backgroundColor: 'var(--tool-item-hover-bg)' })
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<span className="size-4 shrink-0" style={{ color: TOOL_ITEM_MUTED }}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--tool-item-name)' }}>
|
<span className="text-sm font-medium" style={{ color: 'var(--tool-item-name)' }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Separator and Summary */}
|
{/* Separator and Summary */}
|
||||||
{summary && (
|
{summary && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm" style={{ color: TOOL_ITEM_MUTED }}>
|
<span className="text-sm" style={{ color: TOOL_ITEM_MUTED }}>
|
||||||
-
|
-
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="flex-1 truncate text-sm"
|
||||||
|
style={{ color: 'var(--tool-item-summary)' }}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spacer if no summary */}
|
||||||
|
{!summary && <span className="flex-1" />}
|
||||||
|
|
||||||
|
{/* Token count badge */}
|
||||||
|
{tokenCount != null && tokenCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="base-item-tokens shrink-0 rounded px-1.5 py-0.5 text-xs"
|
||||||
|
style={{
|
||||||
|
color: TOOL_ITEM_MUTED,
|
||||||
|
backgroundColor: 'var(--tool-item-badge-bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
~{formatTokens(tokenCount)} {tokenLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-sm" style={{ color: 'var(--tool-item-summary)' }}>
|
)}
|
||||||
{summary}
|
|
||||||
|
{/* Status indicator - hidden when notification dot replaces it */}
|
||||||
|
{status && !notificationDotColor && <StatusDot status={status} />}
|
||||||
|
|
||||||
|
{/* Notification dot (replaces status dot when present) */}
|
||||||
|
{notificationDotColor && (
|
||||||
|
<span
|
||||||
|
className="base-item-notification-dot inline-block size-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: getTriggerColorDef(notificationDotColor).hex }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
{durationMs !== undefined && (
|
||||||
|
<span className="shrink-0 text-xs" style={{ color: TOOL_ITEM_MUTED }}>
|
||||||
|
{formatDuration(durationMs)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spacer if no summary */}
|
{/* Timestamp — rightmost info element */}
|
||||||
{!summary && <span className="flex-1" />}
|
{timestamp && (
|
||||||
|
<span
|
||||||
|
className="base-item-timestamp shrink-0 text-[11px] tabular-nums"
|
||||||
|
style={{ color: TOOL_ITEM_MUTED }}
|
||||||
|
>
|
||||||
|
{format(timestamp, timestampFormat)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Token count badge */}
|
{/* Expand/collapse chevron */}
|
||||||
{tokenCount != null && tokenCount > 0 && (
|
{hasExpandableContent && (
|
||||||
<span
|
<ChevronRight
|
||||||
className="base-item-tokens shrink-0 rounded px-1.5 py-0.5 text-xs"
|
className={`base-item-chevron size-3 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
style={{
|
style={{ color: TOOL_ITEM_MUTED }}
|
||||||
color: TOOL_ITEM_MUTED,
|
/>
|
||||||
backgroundColor: 'var(--tool-item-badge-bg)',
|
)}
|
||||||
}}
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && children && (
|
||||||
|
<div
|
||||||
|
className="ml-2 mt-2 min-w-0 space-y-3 pl-6"
|
||||||
|
style={{ borderLeft: '2px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
~{formatTokens(tokenCount)} {tokenLabel}
|
{children}
|
||||||
</span>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status indicator - hidden when notification dot replaces it */}
|
|
||||||
{status && !notificationDotColor && <StatusDot status={status} />}
|
|
||||||
|
|
||||||
{/* Notification dot (replaces status dot when present) */}
|
|
||||||
{notificationDotColor && (
|
|
||||||
<span
|
|
||||||
className="base-item-notification-dot inline-block size-1.5 shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: getTriggerColorDef(notificationDotColor).hex }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Duration */}
|
|
||||||
{durationMs !== undefined && (
|
|
||||||
<span className="shrink-0 text-xs" style={{ color: TOOL_ITEM_MUTED }}>
|
|
||||||
{formatDuration(durationMs)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamp — rightmost info element */}
|
|
||||||
{timestamp && (
|
|
||||||
<span
|
|
||||||
className="base-item-timestamp shrink-0 text-[11px] tabular-nums"
|
|
||||||
style={{ color: TOOL_ITEM_MUTED }}
|
|
||||||
>
|
|
||||||
{format(timestamp, timestampFormat)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Expand/collapse chevron */}
|
|
||||||
{hasExpandableContent && (
|
|
||||||
<ChevronRight
|
|
||||||
className={`base-item-chevron size-3 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
|
||||||
style={{ color: TOOL_ITEM_MUTED }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{/* Expanded Content */}
|
}
|
||||||
{isExpanded && children && (
|
);
|
||||||
<div
|
|
||||||
className="ml-2 mt-2 min-w-0 space-y-3 pl-6"
|
|
||||||
style={{ borderLeft: '2px solid var(--color-border)' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -46,234 +46,239 @@ interface ExecutionTraceProps {
|
||||||
// Execution Trace Component
|
// Execution Trace Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
export const ExecutionTrace: React.FC<ExecutionTraceProps> = React.memo(
|
||||||
items,
|
({
|
||||||
aiGroupId: _aiGroupId,
|
items,
|
||||||
highlightToolUseId,
|
aiGroupId: _aiGroupId,
|
||||||
highlightColor,
|
highlightToolUseId,
|
||||||
notificationColorMap,
|
highlightColor,
|
||||||
searchExpandedItemId,
|
notificationColorMap,
|
||||||
registerToolRef,
|
searchExpandedItemId,
|
||||||
}): React.JSX.Element => {
|
registerToolRef,
|
||||||
const [manualExpandedItemId, setManualExpandedItemId] = useState<string | null>(null);
|
}): React.JSX.Element => {
|
||||||
|
const [manualExpandedItemId, setManualExpandedItemId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Use searchExpandedItemId if set, otherwise use manually expanded item
|
// Use searchExpandedItemId if set, otherwise use manually expanded item
|
||||||
const expandedItemId = searchExpandedItemId ?? manualExpandedItemId;
|
const expandedItemId = searchExpandedItemId ?? manualExpandedItemId;
|
||||||
|
|
||||||
const handleItemClick = (itemId: string): void => {
|
const handleItemClick = (itemId: string): void => {
|
||||||
setManualExpandedItemId((prev) => (prev === itemId ? null : itemId));
|
setManualExpandedItemId((prev) => (prev === itemId ? null : itemId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2 text-xs" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
No execution items
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 text-xs" style={{ color: CARD_ICON_MUTED }}>
|
<div className="space-y-1">
|
||||||
No execution items
|
{items.map((item, index) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'thinking': {
|
||||||
|
const itemId = `subagent-thinking-${index}`;
|
||||||
|
const thinkingStep = {
|
||||||
|
id: itemId,
|
||||||
|
type: 'thinking' as const,
|
||||||
|
startTime: item.timestamp,
|
||||||
|
endTime: item.timestamp,
|
||||||
|
durationMs: 0,
|
||||||
|
content: { thinkingText: item.content, tokenCount: item.tokenCount },
|
||||||
|
tokens: { input: 0, output: item.tokenCount ?? 0 },
|
||||||
|
context: 'subagent' as const,
|
||||||
|
};
|
||||||
|
const preview = truncateText(item.content, 150);
|
||||||
|
const isExpanded = expandedItemId === itemId;
|
||||||
|
return (
|
||||||
|
<ThinkingItem
|
||||||
|
key={itemId}
|
||||||
|
step={thinkingStep}
|
||||||
|
preview={preview}
|
||||||
|
onClick={() => handleItemClick(itemId)}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'output': {
|
||||||
|
const itemId = `subagent-output-${index}`;
|
||||||
|
const textStep = {
|
||||||
|
id: itemId,
|
||||||
|
type: 'output' as const,
|
||||||
|
startTime: item.timestamp,
|
||||||
|
endTime: item.timestamp,
|
||||||
|
durationMs: 0,
|
||||||
|
content: { outputText: item.content, tokenCount: item.tokenCount },
|
||||||
|
tokens: { input: 0, output: item.tokenCount ?? 0 },
|
||||||
|
context: 'subagent' as const,
|
||||||
|
};
|
||||||
|
const preview = truncateText(item.content, 150);
|
||||||
|
const isExpanded = expandedItemId === itemId;
|
||||||
|
return (
|
||||||
|
<TextItem
|
||||||
|
key={itemId}
|
||||||
|
step={textStep}
|
||||||
|
preview={preview}
|
||||||
|
onClick={() => handleItemClick(itemId)}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool': {
|
||||||
|
const itemId = `subagent-tool-${item.tool.id}`;
|
||||||
|
const isExpanded = expandedItemId === itemId;
|
||||||
|
const isHighlighted = highlightToolUseId === item.tool.id;
|
||||||
|
return (
|
||||||
|
<LinkedToolItem
|
||||||
|
key={itemId}
|
||||||
|
linkedTool={item.tool}
|
||||||
|
onClick={() => handleItemClick(itemId)}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.tool.startTime}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
highlightColor={highlightColor}
|
||||||
|
notificationDotColor={notificationColorMap?.get(item.tool.id)}
|
||||||
|
registerRef={
|
||||||
|
registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'subagent':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`nested-subagent-${index}`}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
Nested: {item.subagent.description ?? item.subagent.id}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'subagent_input': {
|
||||||
|
const itemId = `subagent-input-${index}`;
|
||||||
|
const isExpanded = expandedItemId === itemId;
|
||||||
|
return (
|
||||||
|
<BaseItem
|
||||||
|
key={itemId}
|
||||||
|
icon={<MailOpen className="size-4" />}
|
||||||
|
label="Input"
|
||||||
|
summary={truncateText(item.content, 80)}
|
||||||
|
tokenCount={item.tokenCount}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
onClick={() => handleItemClick(itemId)}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
>
|
||||||
|
<MarkdownViewer content={item.content} copyable />
|
||||||
|
</BaseItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'teammate_message': {
|
||||||
|
const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`;
|
||||||
|
const isExpanded = expandedItemId === itemId;
|
||||||
|
return (
|
||||||
|
<TeammateMessageItem
|
||||||
|
key={itemId}
|
||||||
|
teammateMessage={item.teammateMessage}
|
||||||
|
onClick={() => handleItemClick(itemId)}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'compact_boundary': {
|
||||||
|
const itemId = `subagent-compact-${index}`;
|
||||||
|
const isExpanded = expandedItemId === itemId;
|
||||||
|
return (
|
||||||
|
<div key={itemId}>
|
||||||
|
{/* Header — matches CompactBoundary.tsx amber styling */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleItemClick(itemId)}
|
||||||
|
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: TOOL_CALL_BG,
|
||||||
|
border: `1px solid ${TOOL_CALL_BORDER}`,
|
||||||
|
}}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-1.5"
|
||||||
|
style={{ color: TOOL_CALL_TEXT }}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
/>
|
||||||
|
<Layers size={14} />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-xs font-medium"
|
||||||
|
style={{ color: TOOL_CALL_TEXT }}
|
||||||
|
>
|
||||||
|
Compacted
|
||||||
|
</span>
|
||||||
|
{item.tokenDelta && (
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate text-[11px] tabular-nums"
|
||||||
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
|
>
|
||||||
|
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} →{' '}
|
||||||
|
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
|
||||||
|
<span style={{ color: '#4ade80' }}>
|
||||||
|
{' '}
|
||||||
|
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.15)',
|
||||||
|
color: '#818cf8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Phase {item.phaseNumber}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto shrink-0 text-[11px]"
|
||||||
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
|
>
|
||||||
|
{format(new Date(item.timestamp), 'h:mm:ss a')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* Expanded content */}
|
||||||
|
{isExpanded && item.content && (
|
||||||
|
<div
|
||||||
|
className="mt-1 overflow-hidden rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: CODE_BG,
|
||||||
|
border: `1px solid ${CODE_BORDER}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
|
||||||
|
style={{ borderColor: 'var(--chat-ai-border)' }}
|
||||||
|
>
|
||||||
|
<MarkdownViewer content={item.content} copyable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
switch (item.type) {
|
|
||||||
case 'thinking': {
|
|
||||||
const itemId = `subagent-thinking-${index}`;
|
|
||||||
const thinkingStep = {
|
|
||||||
id: itemId,
|
|
||||||
type: 'thinking' as const,
|
|
||||||
startTime: item.timestamp,
|
|
||||||
endTime: item.timestamp,
|
|
||||||
durationMs: 0,
|
|
||||||
content: { thinkingText: item.content, tokenCount: item.tokenCount },
|
|
||||||
tokens: { input: 0, output: item.tokenCount ?? 0 },
|
|
||||||
context: 'subagent' as const,
|
|
||||||
};
|
|
||||||
const preview = truncateText(item.content, 150);
|
|
||||||
const isExpanded = expandedItemId === itemId;
|
|
||||||
return (
|
|
||||||
<ThinkingItem
|
|
||||||
key={itemId}
|
|
||||||
step={thinkingStep}
|
|
||||||
preview={preview}
|
|
||||||
onClick={() => handleItemClick(itemId)}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
timestamp={item.timestamp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'output': {
|
|
||||||
const itemId = `subagent-output-${index}`;
|
|
||||||
const textStep = {
|
|
||||||
id: itemId,
|
|
||||||
type: 'output' as const,
|
|
||||||
startTime: item.timestamp,
|
|
||||||
endTime: item.timestamp,
|
|
||||||
durationMs: 0,
|
|
||||||
content: { outputText: item.content, tokenCount: item.tokenCount },
|
|
||||||
tokens: { input: 0, output: item.tokenCount ?? 0 },
|
|
||||||
context: 'subagent' as const,
|
|
||||||
};
|
|
||||||
const preview = truncateText(item.content, 150);
|
|
||||||
const isExpanded = expandedItemId === itemId;
|
|
||||||
return (
|
|
||||||
<TextItem
|
|
||||||
key={itemId}
|
|
||||||
step={textStep}
|
|
||||||
preview={preview}
|
|
||||||
onClick={() => handleItemClick(itemId)}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
timestamp={item.timestamp}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool': {
|
|
||||||
const itemId = `subagent-tool-${item.tool.id}`;
|
|
||||||
const isExpanded = expandedItemId === itemId;
|
|
||||||
const isHighlighted = highlightToolUseId === item.tool.id;
|
|
||||||
return (
|
|
||||||
<LinkedToolItem
|
|
||||||
key={itemId}
|
|
||||||
linkedTool={item.tool}
|
|
||||||
onClick={() => handleItemClick(itemId)}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
timestamp={item.tool.startTime}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
highlightColor={highlightColor}
|
|
||||||
notificationDotColor={notificationColorMap?.get(item.tool.id)}
|
|
||||||
registerRef={
|
|
||||||
registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'subagent':
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`nested-subagent-${index}`}
|
|
||||||
className="px-2 py-1 text-xs"
|
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
|
||||||
>
|
|
||||||
Nested: {item.subagent.description ?? item.subagent.id}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'subagent_input': {
|
|
||||||
const itemId = `subagent-input-${index}`;
|
|
||||||
const isExpanded = expandedItemId === itemId;
|
|
||||||
return (
|
|
||||||
<BaseItem
|
|
||||||
key={itemId}
|
|
||||||
icon={<MailOpen className="size-4" />}
|
|
||||||
label="Input"
|
|
||||||
summary={truncateText(item.content, 80)}
|
|
||||||
tokenCount={item.tokenCount}
|
|
||||||
timestamp={item.timestamp}
|
|
||||||
onClick={() => handleItemClick(itemId)}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
>
|
|
||||||
<MarkdownViewer content={item.content} copyable />
|
|
||||||
</BaseItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'teammate_message': {
|
|
||||||
const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`;
|
|
||||||
const isExpanded = expandedItemId === itemId;
|
|
||||||
return (
|
|
||||||
<TeammateMessageItem
|
|
||||||
key={itemId}
|
|
||||||
teammateMessage={item.teammateMessage}
|
|
||||||
onClick={() => handleItemClick(itemId)}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'compact_boundary': {
|
|
||||||
const itemId = `subagent-compact-${index}`;
|
|
||||||
const isExpanded = expandedItemId === itemId;
|
|
||||||
return (
|
|
||||||
<div key={itemId}>
|
|
||||||
{/* Header — matches CompactBoundary.tsx amber styling */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleItemClick(itemId)}
|
|
||||||
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
|
|
||||||
style={{
|
|
||||||
backgroundColor: TOOL_CALL_BG,
|
|
||||||
border: `1px solid ${TOOL_CALL_BORDER}`,
|
|
||||||
}}
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex shrink-0 items-center gap-1.5"
|
|
||||||
style={{ color: TOOL_CALL_TEXT }}
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
size={14}
|
|
||||||
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
|
||||||
/>
|
|
||||||
<Layers size={14} />
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
|
|
||||||
Compacted
|
|
||||||
</span>
|
|
||||||
{item.tokenDelta && (
|
|
||||||
<span
|
|
||||||
className="min-w-0 truncate text-[11px] tabular-nums"
|
|
||||||
style={{ color: COLOR_TEXT_MUTED }}
|
|
||||||
>
|
|
||||||
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} →{' '}
|
|
||||||
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
|
|
||||||
<span style={{ color: '#4ade80' }}>
|
|
||||||
{' '}
|
|
||||||
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'rgba(99, 102, 241, 0.15)',
|
|
||||||
color: '#818cf8',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Phase {item.phaseNumber}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="ml-auto shrink-0 text-[11px]"
|
|
||||||
style={{ color: COLOR_TEXT_MUTED }}
|
|
||||||
>
|
|
||||||
{format(new Date(item.timestamp), 'h:mm:ss a')}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/* Expanded content */}
|
|
||||||
{isExpanded && item.content && (
|
|
||||||
<div
|
|
||||||
className="mt-1 overflow-hidden rounded-lg"
|
|
||||||
style={{
|
|
||||||
backgroundColor: CODE_BG,
|
|
||||||
border: `1px solid ${CODE_BORDER}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
|
|
||||||
style={{ borderColor: 'var(--chat-ai-border)' }}
|
|
||||||
>
|
|
||||||
<MarkdownViewer content={item.content} copyable />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* for summary generation and token calculation.
|
* for summary generation and token calculation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef } from 'react';
|
import React, { memo, useRef } from 'react';
|
||||||
|
|
||||||
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
|
|
@ -64,173 +64,175 @@ interface LinkedToolItemProps {
|
||||||
titleText?: string;
|
titleText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
export const LinkedToolItem = memo(
|
||||||
linkedTool,
|
({
|
||||||
onClick,
|
linkedTool,
|
||||||
isExpanded,
|
onClick,
|
||||||
timestamp,
|
isExpanded,
|
||||||
timestampFormat,
|
timestamp,
|
||||||
searchQueryOverride,
|
timestampFormat,
|
||||||
isHighlighted,
|
searchQueryOverride,
|
||||||
highlightColor,
|
isHighlighted,
|
||||||
notificationDotColor,
|
highlightColor,
|
||||||
registerRef,
|
notificationDotColor,
|
||||||
titleText,
|
registerRef,
|
||||||
}) => {
|
titleText,
|
||||||
const status = getToolStatus(linkedTool);
|
}: LinkedToolItemProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const status = getToolStatus(linkedTool);
|
||||||
const summary = getToolSummary(linkedTool.name, linkedTool.input);
|
const { isLight } = useTheme();
|
||||||
const normalizedToolName = linkedTool.name.toLowerCase();
|
const summary = getToolSummary(linkedTool.name, linkedTool.input);
|
||||||
const summaryNode =
|
const normalizedToolName = linkedTool.name.toLowerCase();
|
||||||
searchQueryOverride && searchQueryOverride.trim().length > 0
|
const summaryNode =
|
||||||
? highlightQueryInText(
|
searchQueryOverride && searchQueryOverride.trim().length > 0
|
||||||
summary,
|
? highlightQueryInText(
|
||||||
searchQueryOverride,
|
summary,
|
||||||
`${linkedTool.id ?? linkedTool.name}:summary`,
|
searchQueryOverride,
|
||||||
{
|
`${linkedTool.id ?? linkedTool.name}:summary`,
|
||||||
forceAllActive: true,
|
{
|
||||||
}
|
forceAllActive: true,
|
||||||
)
|
}
|
||||||
: summary;
|
)
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
: summary;
|
||||||
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Combined ref callback - handles both internal ref and external registration
|
// Combined ref callback - handles both internal ref and external registration
|
||||||
const handleRef = (el: HTMLDivElement | null): void => {
|
const handleRef = (el: HTMLDivElement | null): void => {
|
||||||
// Update internal ref
|
// Update internal ref
|
||||||
(elementRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
(elementRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||||
// Call external registration if provided
|
// Call external registration if provided
|
||||||
registerRef?.(el);
|
registerRef?.(el);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render teammate_spawned results as a minimal inline row
|
// Render teammate_spawned results as a minimal inline row
|
||||||
const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned';
|
const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned';
|
||||||
if (isTeammateSpawned) {
|
if (isTeammateSpawned) {
|
||||||
const teamResult = linkedTool.result!.toolUseResult!;
|
const teamResult = linkedTool.result!.toolUseResult!;
|
||||||
const name = (teamResult.name as string) || 'teammate';
|
const name = (teamResult.name as string) || 'teammate';
|
||||||
const color = (teamResult.color as string) || '';
|
const color = (teamResult.color as string) || '';
|
||||||
const colors = getTeamColorSet(color);
|
const colors = getTeamColorSet(color);
|
||||||
return (
|
return (
|
||||||
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
|
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
|
||||||
<span className="size-2.5 rounded-full" style={{ backgroundColor: colors.border }} />
|
<span className="size-2.5 rounded-full" style={{ backgroundColor: colors.border }} />
|
||||||
<span
|
<span
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
style={{ backgroundColor: getThemedBadge(colors, isLight), color: colors.text }}
|
style={{ backgroundColor: getThemedBadge(colors, isLight), color: colors.text }}
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
Teammate spawned
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render SendMessage shutdown_request as a minimal inline row
|
|
||||||
const isShutdownRequest =
|
|
||||||
linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request';
|
|
||||||
if (isShutdownRequest) {
|
|
||||||
const target = (linkedTool.input?.recipient as string) || 'teammate';
|
|
||||||
return (
|
|
||||||
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
|
|
||||||
<span className="size-2 rounded-full bg-zinc-500" />
|
|
||||||
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
Shutdown requested →{' '}
|
|
||||||
<span className="font-medium text-text-secondary">{target}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: We no longer scroll locally - the navigation coordinator handles this
|
|
||||||
// via the registered ref. This prevents double-scroll issues.
|
|
||||||
|
|
||||||
// Highlight animation for error deep linking (supports custom hex)
|
|
||||||
const effectiveColor = highlightColor ?? 'red';
|
|
||||||
let highlightClasses = '';
|
|
||||||
let highlightStyle: React.CSSProperties | undefined;
|
|
||||||
if (isHighlighted) {
|
|
||||||
if (isPresetColorKey(effectiveColor)) {
|
|
||||||
highlightClasses = TOOL_HIGHLIGHT_CLASSES[effectiveColor];
|
|
||||||
} else {
|
|
||||||
const hp = getToolHighlightProps(effectiveColor);
|
|
||||||
highlightClasses = hp.className;
|
|
||||||
highlightStyle = hp.style;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which specialized viewer to use
|
|
||||||
const useReadViewer =
|
|
||||||
normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError;
|
|
||||||
const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool);
|
|
||||||
const useWriteViewer =
|
|
||||||
normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError;
|
|
||||||
const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool);
|
|
||||||
const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer;
|
|
||||||
|
|
||||||
// Check if we should show error display for Read/Write tools
|
|
||||||
const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError;
|
|
||||||
const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={handleRef}>
|
|
||||||
<BaseItem
|
|
||||||
icon={
|
|
||||||
<Wrench
|
|
||||||
className="size-4"
|
|
||||||
style={{ color: isHighlighted ? getTriggerColorDef(highlightColor).hex : undefined }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={linkedTool.name}
|
|
||||||
summary={summaryNode}
|
|
||||||
tokenCount={getToolContextTokens(linkedTool)}
|
|
||||||
status={status}
|
|
||||||
durationMs={linkedTool.durationMs}
|
|
||||||
timestamp={timestamp}
|
|
||||||
timestampFormat={timestampFormat}
|
|
||||||
titleText={titleText}
|
|
||||||
onClick={onClick}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
highlightClasses={highlightClasses}
|
|
||||||
highlightStyle={highlightStyle}
|
|
||||||
notificationDotColor={notificationDotColor}
|
|
||||||
>
|
|
||||||
{/* Read tool with CodeBlockViewer */}
|
|
||||||
{useReadViewer && <ReadToolViewer linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Edit tool with DiffViewer */}
|
|
||||||
{useEditViewer && <EditToolViewer linkedTool={linkedTool} status={status} />}
|
|
||||||
|
|
||||||
{/* Write tool */}
|
|
||||||
{useWriteViewer && <WriteToolViewer linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Skill tool with instructions */}
|
|
||||||
{useSkillViewer && <SkillToolViewer linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Default rendering for other tools */}
|
|
||||||
{useDefaultViewer && <DefaultToolViewer linkedTool={linkedTool} status={status} />}
|
|
||||||
|
|
||||||
{/* Error output for Read tool */}
|
|
||||||
{showReadError && <ToolErrorDisplay linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Error output for Write tool */}
|
|
||||||
{showWriteError && <ToolErrorDisplay linkedTool={linkedTool} />}
|
|
||||||
|
|
||||||
{/* Orphaned indicator */}
|
|
||||||
{linkedTool.isOrphaned && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 text-xs italic"
|
|
||||||
style={{ color: 'var(--tool-item-muted)' }}
|
|
||||||
>
|
>
|
||||||
<StatusDot status="orphaned" />
|
{name}
|
||||||
No result received
|
</span>
|
||||||
</div>
|
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
|
||||||
)}
|
Teammate spawned
|
||||||
|
</span>
|
||||||
{/* Timing */}
|
|
||||||
<div className="text-xs" style={{ color: 'var(--tool-item-muted)' }}>
|
|
||||||
Duration: {formatDuration(linkedTool.durationMs)}
|
|
||||||
</div>
|
</div>
|
||||||
</BaseItem>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
};
|
// Render SendMessage shutdown_request as a minimal inline row
|
||||||
|
const isShutdownRequest =
|
||||||
|
linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request';
|
||||||
|
if (isShutdownRequest) {
|
||||||
|
const target = (linkedTool.input?.recipient as string) || 'teammate';
|
||||||
|
return (
|
||||||
|
<div ref={handleRef} className="flex items-center gap-2 px-3 py-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-zinc-500" />
|
||||||
|
<span className="text-xs" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
Shutdown requested →{' '}
|
||||||
|
<span className="font-medium text-text-secondary">{target}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: We no longer scroll locally - the navigation coordinator handles this
|
||||||
|
// via the registered ref. This prevents double-scroll issues.
|
||||||
|
|
||||||
|
// Highlight animation for error deep linking (supports custom hex)
|
||||||
|
const effectiveColor = highlightColor ?? 'red';
|
||||||
|
let highlightClasses = '';
|
||||||
|
let highlightStyle: React.CSSProperties | undefined;
|
||||||
|
if (isHighlighted) {
|
||||||
|
if (isPresetColorKey(effectiveColor)) {
|
||||||
|
highlightClasses = TOOL_HIGHLIGHT_CLASSES[effectiveColor];
|
||||||
|
} else {
|
||||||
|
const hp = getToolHighlightProps(effectiveColor);
|
||||||
|
highlightClasses = hp.className;
|
||||||
|
highlightStyle = hp.style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which specialized viewer to use
|
||||||
|
const useReadViewer =
|
||||||
|
normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError;
|
||||||
|
const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool);
|
||||||
|
const useWriteViewer =
|
||||||
|
normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError;
|
||||||
|
const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool);
|
||||||
|
const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer;
|
||||||
|
|
||||||
|
// Check if we should show error display for Read/Write tools
|
||||||
|
const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError;
|
||||||
|
const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={handleRef}>
|
||||||
|
<BaseItem
|
||||||
|
icon={
|
||||||
|
<Wrench
|
||||||
|
className="size-4"
|
||||||
|
style={{ color: isHighlighted ? getTriggerColorDef(highlightColor).hex : undefined }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={linkedTool.name}
|
||||||
|
summary={summaryNode}
|
||||||
|
tokenCount={getToolContextTokens(linkedTool)}
|
||||||
|
status={status}
|
||||||
|
durationMs={linkedTool.durationMs}
|
||||||
|
timestamp={timestamp}
|
||||||
|
timestampFormat={timestampFormat}
|
||||||
|
titleText={titleText}
|
||||||
|
onClick={onClick}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
highlightClasses={highlightClasses}
|
||||||
|
highlightStyle={highlightStyle}
|
||||||
|
notificationDotColor={notificationDotColor}
|
||||||
|
>
|
||||||
|
{/* Read tool with CodeBlockViewer */}
|
||||||
|
{useReadViewer && <ReadToolViewer linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Edit tool with DiffViewer */}
|
||||||
|
{useEditViewer && <EditToolViewer linkedTool={linkedTool} status={status} />}
|
||||||
|
|
||||||
|
{/* Write tool */}
|
||||||
|
{useWriteViewer && <WriteToolViewer linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Skill tool with instructions */}
|
||||||
|
{useSkillViewer && <SkillToolViewer linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Default rendering for other tools */}
|
||||||
|
{useDefaultViewer && <DefaultToolViewer linkedTool={linkedTool} status={status} />}
|
||||||
|
|
||||||
|
{/* Error output for Read tool */}
|
||||||
|
{showReadError && <ToolErrorDisplay linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Error output for Write tool */}
|
||||||
|
{showWriteError && <ToolErrorDisplay linkedTool={linkedTool} />}
|
||||||
|
|
||||||
|
{/* Orphaned indicator */}
|
||||||
|
{linkedTool.isOrphaned && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-xs italic"
|
||||||
|
style={{ color: 'var(--tool-item-muted)' }}
|
||||||
|
>
|
||||||
|
<StatusDot status="orphaned" />
|
||||||
|
No result received
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timing */}
|
||||||
|
<div className="text-xs" style={{ color: 'var(--tool-item-muted)' }}>
|
||||||
|
Duration: {formatDuration(linkedTool.durationMs)}
|
||||||
|
</div>
|
||||||
|
</BaseItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,175 +41,177 @@ interface MetricsPillProps {
|
||||||
// Unified Metrics Pill - Compact monospace pill with tooltip
|
// Unified Metrics Pill - Compact monospace pill with tooltip
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const MetricsPill = ({
|
export const MetricsPill = memo(
|
||||||
mainSessionImpact,
|
({
|
||||||
lastUsage,
|
mainSessionImpact,
|
||||||
isolatedLabel,
|
lastUsage,
|
||||||
isolatedOverride,
|
isolatedLabel,
|
||||||
phaseBreakdown,
|
isolatedOverride,
|
||||||
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
|
phaseBreakdown,
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
|
||||||
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
||||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0;
|
const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0;
|
||||||
const hasIsolated =
|
const hasIsolated =
|
||||||
isolatedOverride != null
|
isolatedOverride != null
|
||||||
? isolatedOverride > 0
|
? isolatedOverride > 0
|
||||||
: lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
|
: lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
|
||||||
|
|
||||||
const isolatedTotal =
|
const isolatedTotal =
|
||||||
isolatedOverride ??
|
isolatedOverride ??
|
||||||
(lastUsage
|
(lastUsage
|
||||||
? lastUsage.input_tokens +
|
? lastUsage.input_tokens +
|
||||||
lastUsage.output_tokens +
|
lastUsage.output_tokens +
|
||||||
(lastUsage.cache_read_input_tokens ?? 0) +
|
(lastUsage.cache_read_input_tokens ?? 0) +
|
||||||
(lastUsage.cache_creation_input_tokens ?? 0)
|
(lastUsage.cache_creation_input_tokens ?? 0)
|
||||||
: 0);
|
: 0);
|
||||||
|
|
||||||
const hasPhases = phaseBreakdown && phaseBreakdown.length > 1;
|
const hasPhases = phaseBreakdown && phaseBreakdown.length > 1;
|
||||||
|
|
||||||
const clearHideTimeout = (): void => {
|
const clearHideTimeout = (): void => {
|
||||||
if (hideTimeoutRef.current) {
|
if (hideTimeoutRef.current) {
|
||||||
clearTimeout(hideTimeoutRef.current);
|
clearTimeout(hideTimeoutRef.current);
|
||||||
hideTimeoutRef.current = null;
|
hideTimeoutRef.current = null;
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = (): void => {
|
|
||||||
clearHideTimeout();
|
|
||||||
setShowTooltip(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = (): void => {
|
|
||||||
clearHideTimeout();
|
|
||||||
hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showTooltip && containerRef.current) {
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
|
||||||
const tooltipWidth = 220;
|
|
||||||
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
|
||||||
if (left < 8) left = 8;
|
|
||||||
if (left + tooltipWidth > window.innerWidth - 8) {
|
|
||||||
left = window.innerWidth - tooltipWidth - 8;
|
|
||||||
}
|
}
|
||||||
setTooltipStyle({
|
};
|
||||||
position: 'fixed',
|
|
||||||
bottom: window.innerHeight - rect.top + 6,
|
const handleMouseEnter = (): void => {
|
||||||
left,
|
clearHideTimeout();
|
||||||
width: tooltipWidth,
|
setShowTooltip(true);
|
||||||
zIndex: 99999,
|
};
|
||||||
});
|
|
||||||
|
const handleMouseLeave = (): void => {
|
||||||
|
clearHideTimeout();
|
||||||
|
hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showTooltip && containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const tooltipWidth = 220;
|
||||||
|
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
if (left + tooltipWidth > window.innerWidth - 8) {
|
||||||
|
left = window.innerWidth - tooltipWidth - 8;
|
||||||
|
}
|
||||||
|
setTooltipStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: window.innerHeight - rect.top + 6,
|
||||||
|
left,
|
||||||
|
width: tooltipWidth,
|
||||||
|
zIndex: 99999,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [showTooltip]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showTooltip) return;
|
||||||
|
const handleScroll = (): void => setShowTooltip(false);
|
||||||
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll, true);
|
||||||
|
}, [showTooltip]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearHideTimeout();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!hasMainImpact && !hasIsolated) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}, [showTooltip]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null;
|
||||||
if (!showTooltip) return;
|
const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null;
|
||||||
const handleScroll = (): void => setShowTooltip(false);
|
const rightLabel = isolatedLabel ?? 'Subagent Context';
|
||||||
window.addEventListener('scroll', handleScroll, true);
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll, true);
|
|
||||||
}, [showTooltip]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
return () => clearHideTimeout();
|
<>
|
||||||
}, []);
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
role="tooltip"
|
||||||
|
className="inline-flex cursor-default items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[11px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: TAG_BG,
|
||||||
|
border: `1px solid ${TAG_BORDER}`,
|
||||||
|
color: TAG_TEXT,
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{mainValue && <span className="tabular-nums">{mainValue}</span>}
|
||||||
|
{mainValue && isolatedValue && <span style={{ color: CARD_SEPARATOR }}>|</span>}
|
||||||
|
{isolatedValue && <span className="tabular-nums">{isolatedValue}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
if (!hasMainImpact && !hasIsolated) {
|
{showTooltip &&
|
||||||
return null;
|
createPortal(
|
||||||
}
|
<div
|
||||||
|
role="tooltip"
|
||||||
const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null;
|
className="rounded-md bg-surface-overlay p-2 text-[11px] shadow-xl"
|
||||||
const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null;
|
style={{
|
||||||
const rightLabel = isolatedLabel ?? 'Subagent Context';
|
...tooltipStyle,
|
||||||
|
border: `1px solid ${TAG_BORDER}`,
|
||||||
return (
|
}}
|
||||||
<>
|
onMouseEnter={handleMouseEnter}
|
||||||
<div
|
onMouseLeave={handleMouseLeave}
|
||||||
ref={containerRef}
|
>
|
||||||
role="tooltip"
|
<div className="space-y-1">
|
||||||
className="inline-flex cursor-default items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[11px]"
|
{hasMainImpact && (
|
||||||
style={{
|
<div className="flex items-center justify-between gap-3">
|
||||||
backgroundColor: TAG_BG,
|
<span style={{ color: COLOR_TEXT_MUTED }}>Main Context</span>
|
||||||
border: `1px solid ${TAG_BORDER}`,
|
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
color: TAG_TEXT,
|
{mainSessionImpact.totalTokens.toLocaleString()}
|
||||||
}}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{mainValue && <span className="tabular-nums">{mainValue}</span>}
|
|
||||||
{mainValue && isolatedValue && <span style={{ color: CARD_SEPARATOR }}>|</span>}
|
|
||||||
{isolatedValue && <span className="tabular-nums">{isolatedValue}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTooltip &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
role="tooltip"
|
|
||||||
className="rounded-md bg-surface-overlay p-2 text-[11px] shadow-xl"
|
|
||||||
style={{
|
|
||||||
...tooltipStyle,
|
|
||||||
border: `1px solid ${TAG_BORDER}`,
|
|
||||||
}}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{hasMainImpact && (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span style={{ color: COLOR_TEXT_MUTED }}>Main Context</span>
|
|
||||||
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
|
|
||||||
{mainSessionImpact.totalTokens.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasIsolated && (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span style={{ color: COLOR_TEXT_MUTED }}>{rightLabel}</span>
|
|
||||||
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
|
|
||||||
{isolatedTotal.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasPhases &&
|
|
||||||
phaseBreakdown.map((phase) => (
|
|
||||||
<div
|
|
||||||
key={phase.phaseNumber}
|
|
||||||
className="flex items-center justify-between gap-3 pl-2"
|
|
||||||
>
|
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
Phase {phase.phaseNumber}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="font-mono text-[10px] tabular-nums"
|
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
|
||||||
>
|
|
||||||
{formatTokensCompact(phase.peakTokens)}
|
|
||||||
{phase.postCompaction != null && (
|
|
||||||
<span style={{ color: '#4ade80' }}>
|
|
||||||
{' '}
|
|
||||||
→ {formatTokensCompact(phase.postCompaction)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
<div
|
{hasIsolated && (
|
||||||
className="mt-1 pt-1.5 text-[10px]"
|
<div className="flex items-center justify-between gap-3">
|
||||||
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }}
|
<span style={{ color: COLOR_TEXT_MUTED }}>{rightLabel}</span>
|
||||||
>
|
<span className="font-mono tabular-nums" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
{hasMainImpact && hasIsolated
|
{isolatedTotal.toLocaleString()}
|
||||||
? 'Left: parent injection · Right: internal'
|
</span>
|
||||||
: hasMainImpact
|
</div>
|
||||||
? 'Tokens injected to parent'
|
)}
|
||||||
: 'Internal token usage'}
|
{hasPhases &&
|
||||||
|
phaseBreakdown.map((phase) => (
|
||||||
|
<div
|
||||||
|
key={phase.phaseNumber}
|
||||||
|
className="flex items-center justify-between gap-3 pl-2"
|
||||||
|
>
|
||||||
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
Phase {phase.phaseNumber}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] tabular-nums"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{formatTokensCompact(phase.peakTokens)}
|
||||||
|
{phase.postCompaction != null && (
|
||||||
|
<span style={{ color: '#4ade80' }}>
|
||||||
|
{' '}
|
||||||
|
→ {formatTokensCompact(phase.postCompaction)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="mt-1 pt-1.5 text-[10px]"
|
||||||
|
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{hasMainImpact && hasIsolated
|
||||||
|
? 'Left: parent injection · Right: internal'
|
||||||
|
: hasMainImpact
|
||||||
|
? 'Tokens injected to parent'
|
||||||
|
: 'Internal token usage'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body
|
||||||
document.body
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
);
|
||||||
);
|
}
|
||||||
};
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { Slash } from 'lucide-react';
|
import { Slash } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -34,48 +34,50 @@ interface SlashItemProps {
|
||||||
* - MCP commands
|
* - MCP commands
|
||||||
* - User-defined commands
|
* - User-defined commands
|
||||||
*/
|
*/
|
||||||
export const SlashItem: React.FC<SlashItemProps> = ({
|
export const SlashItem = memo(
|
||||||
slash,
|
({
|
||||||
onClick,
|
slash,
|
||||||
isExpanded,
|
onClick,
|
||||||
timestamp,
|
isExpanded,
|
||||||
timestampFormat,
|
timestamp,
|
||||||
highlightClasses,
|
timestampFormat,
|
||||||
highlightStyle,
|
highlightClasses,
|
||||||
notificationDotColor,
|
highlightStyle,
|
||||||
titleText,
|
notificationDotColor,
|
||||||
}) => {
|
titleText,
|
||||||
const hasInstructions = !!slash.instructions;
|
}: SlashItemProps): React.JSX.Element => {
|
||||||
|
const hasInstructions = !!slash.instructions;
|
||||||
|
|
||||||
// Display args or message as the description
|
// Display args or message as the description
|
||||||
const description = slash.args ?? slash.message;
|
const description = slash.args ?? slash.message;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseItem
|
<BaseItem
|
||||||
icon={<Slash className="size-4" />}
|
icon={<Slash className="size-4" />}
|
||||||
label={`/${slash.name}`}
|
label={`/${slash.name}`}
|
||||||
summary={description}
|
summary={description}
|
||||||
tokenCount={slash.instructionsTokenCount}
|
tokenCount={slash.instructionsTokenCount}
|
||||||
tokenLabel="tokens"
|
tokenLabel="tokens"
|
||||||
status={hasInstructions ? 'ok' : undefined}
|
status={hasInstructions ? 'ok' : undefined}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
timestampFormat={timestampFormat}
|
timestampFormat={timestampFormat}
|
||||||
titleText={titleText}
|
titleText={titleText}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
hasExpandableContent={hasInstructions}
|
hasExpandableContent={hasInstructions}
|
||||||
highlightClasses={highlightClasses}
|
highlightClasses={highlightClasses}
|
||||||
highlightStyle={highlightStyle}
|
highlightStyle={highlightStyle}
|
||||||
notificationDotColor={notificationDotColor}
|
notificationDotColor={notificationDotColor}
|
||||||
>
|
>
|
||||||
{hasInstructions && (
|
{hasInstructions && (
|
||||||
<MarkdownViewer
|
<MarkdownViewer
|
||||||
content={slash.instructions!}
|
content={slash.instructions!}
|
||||||
label="Slash Output"
|
label="Slash Output"
|
||||||
maxHeight="max-h-96"
|
maxHeight="max-h-96"
|
||||||
copyable
|
copyable
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</BaseItem>
|
</BaseItem>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
|
|
@ -75,187 +75,192 @@ function isResendMessage(message: TeammateMessage): boolean {
|
||||||
*
|
*
|
||||||
* Operational noise (idle/shutdown/terminated) renders as minimal inline text.
|
* Operational noise (idle/shutdown/terminated) renders as minimal inline text.
|
||||||
*/
|
*/
|
||||||
export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
export const TeammateMessageItem = memo(
|
||||||
teammateMessage,
|
({
|
||||||
onClick,
|
teammateMessage,
|
||||||
isExpanded,
|
onClick,
|
||||||
onReplyHover,
|
isExpanded,
|
||||||
highlightClasses = '',
|
onReplyHover,
|
||||||
highlightStyle,
|
highlightClasses = '',
|
||||||
}) => {
|
highlightStyle,
|
||||||
const colors = getTeamColorSet(teammateMessage.color);
|
}: TeammateMessageItemProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const colors = getTeamColorSet(teammateMessage.color);
|
||||||
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
// Get team members for @mention highlighting
|
// Get team members for @mention highlighting
|
||||||
const members = useStore(
|
const members = useStore(
|
||||||
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
|
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
|
||||||
);
|
);
|
||||||
const memberColorMap = useMemo(
|
const memberColorMap = useMemo(
|
||||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||||
[members]
|
[members]
|
||||||
);
|
|
||||||
|
|
||||||
// Get team names for @team linkification
|
|
||||||
const teams = useStore(useShallow((s) => s.teams));
|
|
||||||
const teamNames = useMemo(
|
|
||||||
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
|
||||||
[teams]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Detect operational noise
|
|
||||||
const noiseLabel = useMemo(
|
|
||||||
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
|
|
||||||
[teammateMessage.content, teammateMessage.teammateId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Detect resent/duplicate messages
|
|
||||||
const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]);
|
|
||||||
|
|
||||||
const plainSummary = useMemo(
|
|
||||||
() => extractMarkdownPlainText(teammateMessage.summary),
|
|
||||||
[teammateMessage.summary]
|
|
||||||
);
|
|
||||||
const plainReplyToSummary = useMemo(
|
|
||||||
() =>
|
|
||||||
teammateMessage.replyToSummary
|
|
||||||
? extractMarkdownPlainText(teammateMessage.replyToSummary)
|
|
||||||
: undefined,
|
|
||||||
[teammateMessage.replyToSummary]
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayContent = useMemo(() => {
|
|
||||||
const stripped = stripAgentBlocks(teammateMessage.content);
|
|
||||||
return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames);
|
|
||||||
}, [teammateMessage.content, memberColorMap, teamNames]);
|
|
||||||
|
|
||||||
// Noise: minimal inline row (no card, no expand)
|
|
||||||
if (noiseLabel) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}>
|
|
||||||
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: colors.border }} />
|
|
||||||
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
{teammateMessage.teammateId}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
{noiseLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const truncatedSummary =
|
// Get team names for @team linkification
|
||||||
plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary;
|
const teams = useStore(useShallow((s) => s.teams));
|
||||||
|
const teamNames = useMemo(
|
||||||
|
() => teams.filter((t) => !t.deletedAt).map((t) => t.teamName),
|
||||||
|
[teams]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
// Detect operational noise
|
||||||
<div
|
const noiseLabel = useMemo(
|
||||||
className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
() => detectOperationalNoise(teammateMessage.content, teammateMessage.teammateId),
|
||||||
style={{
|
[teammateMessage.content, teammateMessage.teammateId]
|
||||||
backgroundColor: CARD_BG,
|
);
|
||||||
border: CARD_BORDER_STYLE,
|
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
// Detect resent/duplicate messages
|
||||||
opacity: isResend ? 0.6 : undefined,
|
const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]);
|
||||||
...highlightStyle,
|
|
||||||
}}
|
const plainSummary = useMemo(
|
||||||
>
|
() => extractMarkdownPlainText(teammateMessage.summary),
|
||||||
{/* Header */}
|
[teammateMessage.summary]
|
||||||
|
);
|
||||||
|
const plainReplyToSummary = useMemo(
|
||||||
|
() =>
|
||||||
|
teammateMessage.replyToSummary
|
||||||
|
? extractMarkdownPlainText(teammateMessage.replyToSummary)
|
||||||
|
: undefined,
|
||||||
|
[teammateMessage.replyToSummary]
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayContent = useMemo(() => {
|
||||||
|
const stripped = stripAgentBlocks(teammateMessage.content);
|
||||||
|
return linkifyAllMentionsInMarkdown(stripped, memberColorMap, teamNames);
|
||||||
|
}, [teammateMessage.content, memberColorMap, teamNames]);
|
||||||
|
|
||||||
|
// Noise: minimal inline row (no card, no expand)
|
||||||
|
if (noiseLabel) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}>
|
||||||
|
<span
|
||||||
|
className="size-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: colors.border }}
|
||||||
|
/>
|
||||||
|
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{teammateMessage.teammateId}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{noiseLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncatedSummary =
|
||||||
|
plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
className={`overflow-hidden rounded-md transition-[background-color,box-shadow] duration-300 ${highlightClasses}`}
|
||||||
tabIndex={0}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent',
|
backgroundColor: CARD_BG,
|
||||||
borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none',
|
border: CARD_BORDER_STYLE,
|
||||||
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
|
opacity: isResend ? 0.6 : undefined,
|
||||||
|
...highlightStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronRight
|
{/* Header */}
|
||||||
className={`size-3.5 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
<div
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
role="button"
|
||||||
/>
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
{/* Message icon — distinguishes from SubagentItem's Bot/dot icon */}
|
onKeyDown={(e) => {
|
||||||
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
{/* Teammate name badge */}
|
onClick();
|
||||||
<span
|
}
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: getThemedBadge(colors, isLight),
|
backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent',
|
||||||
color: colors.text,
|
borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none',
|
||||||
border: `1px solid ${colors.border}40`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{teammateMessage.teammateId}
|
<ChevronRight
|
||||||
</span>
|
className={`size-3.5 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
|
||||||
{/* "Message" type label — parallels SubagentItem's model info */}
|
|
||||||
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
|
||||||
Message
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Reply indicator — shows which SendMessage triggered this response */}
|
|
||||||
{plainReplyToSummary && (
|
|
||||||
<span
|
|
||||||
role="presentation"
|
|
||||||
className="flex cursor-default items-center gap-1 text-[10px]"
|
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
onMouseEnter={() => onReplyHover?.(teammateMessage.replyToToolId ?? null)}
|
/>
|
||||||
onMouseLeave={() => onReplyHover?.(null)}
|
|
||||||
|
{/* Message icon — distinguishes from SubagentItem's Bot/dot icon */}
|
||||||
|
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
||||||
|
|
||||||
|
{/* Teammate name badge */}
|
||||||
|
<span
|
||||||
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getThemedBadge(colors, isLight),
|
||||||
|
color: colors.text,
|
||||||
|
border: `1px solid ${colors.border}40`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CornerDownLeft className="size-2.5" />
|
{teammateMessage.teammateId}
|
||||||
<span className="truncate" style={{ maxWidth: '180px' }}>
|
</span>
|
||||||
{plainReplyToSummary}
|
|
||||||
|
{/* "Message" type label — parallels SubagentItem's model info */}
|
||||||
|
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
Message
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Reply indicator — shows which SendMessage triggered this response */}
|
||||||
|
{plainReplyToSummary && (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className="flex cursor-default items-center gap-1 text-[10px]"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
onMouseEnter={() => onReplyHover?.(teammateMessage.replyToToolId ?? null)}
|
||||||
|
onMouseLeave={() => onReplyHover?.(null)}
|
||||||
|
>
|
||||||
|
<CornerDownLeft className="size-2.5" />
|
||||||
|
<span className="truncate" style={{ maxWidth: '180px' }}>
|
||||||
|
{plainReplyToSummary}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resend badge — marks duplicate/resent messages */}
|
||||||
|
{isResend && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-0.5 text-[10px]"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-2.5" />
|
||||||
|
Resent
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
|
{truncatedSummary || 'Teammate message'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Resend badge — marks duplicate/resent messages */}
|
{/* Context impact — tokens injected into main session */}
|
||||||
{isResend && (
|
{teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className="flex items-center gap-0.5 text-[10px]"
|
className="shrink-0 font-mono text-[11px] tabular-nums"
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-2.5" />
|
~{formatTokensCompact(teammateMessage.tokenCount)} tokens
|
||||||
Resent
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Timestamp — rightmost info element */}
|
||||||
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
|
||||||
{truncatedSummary || 'Teammate message'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Context impact — tokens injected into main session */}
|
|
||||||
{teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && (
|
|
||||||
<span
|
<span
|
||||||
className="shrink-0 font-mono text-[11px] tabular-nums"
|
className="shrink-0 font-mono text-[11px] tabular-nums"
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
>
|
>
|
||||||
~{formatTokensCompact(teammateMessage.tokenCount)} tokens
|
{format(teammateMessage.timestamp, 'HH:mm:ss')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamp — rightmost info element */}
|
|
||||||
<span
|
|
||||||
className="shrink-0 font-mono text-[11px] tabular-nums"
|
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
|
||||||
>
|
|
||||||
{format(teammateMessage.timestamp, 'HH:mm:ss')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="p-3">
|
|
||||||
<MarkdownViewer content={displayContent} copyable />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Expanded content */}
|
||||||
);
|
{isExpanded && (
|
||||||
};
|
<div className="p-3">
|
||||||
|
<MarkdownViewer content={displayContent} copyable />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,52 +31,54 @@ interface TextItemProps {
|
||||||
titleText?: string;
|
titleText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextItem: React.FC<TextItemProps> = ({
|
export const TextItem: React.FC<TextItemProps> = React.memo(
|
||||||
step,
|
({
|
||||||
preview,
|
step,
|
||||||
onClick,
|
preview,
|
||||||
isExpanded,
|
onClick,
|
||||||
timestamp,
|
isExpanded,
|
||||||
timestampFormat,
|
timestamp,
|
||||||
searchQueryOverride,
|
timestampFormat,
|
||||||
markdownItemId,
|
searchQueryOverride,
|
||||||
highlightClasses,
|
markdownItemId,
|
||||||
highlightStyle,
|
highlightClasses,
|
||||||
notificationDotColor,
|
highlightStyle,
|
||||||
titleText,
|
notificationDotColor,
|
||||||
}) => {
|
titleText,
|
||||||
const fullContent = step.content.outputText ?? preview;
|
}) => {
|
||||||
const summary = searchQueryOverride
|
const fullContent = step.content.outputText ?? preview;
|
||||||
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
const summary = searchQueryOverride
|
||||||
forceAllActive: true,
|
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
||||||
})
|
forceAllActive: true,
|
||||||
: preview;
|
})
|
||||||
|
: preview;
|
||||||
|
|
||||||
// Get token count from step.tokens.output or step.content.tokenCount
|
// Get token count from step.tokens.output or step.content.tokenCount
|
||||||
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseItem
|
<BaseItem
|
||||||
icon={<MessageSquare className="size-4" />}
|
icon={<MessageSquare className="size-4" />}
|
||||||
label="Output"
|
label="Output"
|
||||||
summary={summary}
|
summary={summary}
|
||||||
tokenCount={tokenCount}
|
tokenCount={tokenCount}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
timestampFormat={timestampFormat}
|
timestampFormat={timestampFormat}
|
||||||
titleText={titleText}
|
titleText={titleText}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
highlightClasses={highlightClasses}
|
highlightClasses={highlightClasses}
|
||||||
highlightStyle={highlightStyle}
|
highlightStyle={highlightStyle}
|
||||||
notificationDotColor={notificationDotColor}
|
notificationDotColor={notificationDotColor}
|
||||||
>
|
>
|
||||||
<MarkdownViewer
|
<MarkdownViewer
|
||||||
content={fullContent}
|
content={fullContent}
|
||||||
maxHeight="max-h-96"
|
maxHeight="max-h-96"
|
||||||
copyable
|
copyable
|
||||||
itemId={markdownItemId}
|
itemId={markdownItemId}
|
||||||
searchQueryOverride={searchQueryOverride}
|
searchQueryOverride={searchQueryOverride}
|
||||||
/>
|
/>
|
||||||
</BaseItem>
|
</BaseItem>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,52 +31,54 @@ interface ThinkingItemProps {
|
||||||
titleText?: string;
|
titleText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
export const ThinkingItem: React.FC<ThinkingItemProps> = React.memo(
|
||||||
step,
|
({
|
||||||
preview,
|
step,
|
||||||
onClick,
|
preview,
|
||||||
isExpanded,
|
onClick,
|
||||||
timestamp,
|
isExpanded,
|
||||||
timestampFormat,
|
timestamp,
|
||||||
searchQueryOverride,
|
timestampFormat,
|
||||||
markdownItemId,
|
searchQueryOverride,
|
||||||
highlightClasses,
|
markdownItemId,
|
||||||
highlightStyle,
|
highlightClasses,
|
||||||
notificationDotColor,
|
highlightStyle,
|
||||||
titleText,
|
notificationDotColor,
|
||||||
}) => {
|
titleText,
|
||||||
const fullContent = step.content.thinkingText ?? preview;
|
}) => {
|
||||||
const summary = searchQueryOverride
|
const fullContent = step.content.thinkingText ?? preview;
|
||||||
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
const summary = searchQueryOverride
|
||||||
forceAllActive: true,
|
? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
||||||
})
|
forceAllActive: true,
|
||||||
: preview;
|
})
|
||||||
|
: preview;
|
||||||
|
|
||||||
// Get token count from step.tokens.output or step.content.tokenCount
|
// Get token count from step.tokens.output or step.content.tokenCount
|
||||||
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseItem
|
<BaseItem
|
||||||
icon={<Brain className="size-4" />}
|
icon={<Brain className="size-4" />}
|
||||||
label="Thinking"
|
label="Thinking"
|
||||||
summary={summary}
|
summary={summary}
|
||||||
tokenCount={tokenCount}
|
tokenCount={tokenCount}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
timestampFormat={timestampFormat}
|
timestampFormat={timestampFormat}
|
||||||
titleText={titleText}
|
titleText={titleText}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
highlightClasses={highlightClasses}
|
highlightClasses={highlightClasses}
|
||||||
highlightStyle={highlightStyle}
|
highlightStyle={highlightStyle}
|
||||||
notificationDotColor={notificationDotColor}
|
notificationDotColor={notificationDotColor}
|
||||||
>
|
>
|
||||||
<MarkdownViewer
|
<MarkdownViewer
|
||||||
content={fullContent}
|
content={fullContent}
|
||||||
maxHeight="max-h-96"
|
maxHeight="max-h-96"
|
||||||
copyable
|
copyable
|
||||||
itemId={markdownItemId}
|
itemId={markdownItemId}
|
||||||
searchQueryOverride={searchQueryOverride}
|
searchQueryOverride={searchQueryOverride}
|
||||||
/>
|
/>
|
||||||
</BaseItem>
|
</BaseItem>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Shows a clickable header with label, StatusDot, and chevron toggle.
|
* Shows a clickable header with label, StatusDot, and chevron toggle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
|
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -18,40 +18,44 @@ interface CollapsibleOutputSectionProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> = ({
|
export const CollapsibleOutputSection = memo(
|
||||||
status,
|
({ status, children, label = 'Output' }: CollapsibleOutputSectionProps): React.JSX.Element => {
|
||||||
children,
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
label = 'Output',
|
|
||||||
}) => {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mb-1 flex items-center gap-2 text-xs"
|
className="mb-1 flex items-center gap-2 text-xs"
|
||||||
style={{ color: 'var(--tool-item-muted)', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
|
||||||
onClick={() => setIsExpanded((prev) => !prev)}
|
|
||||||
>
|
|
||||||
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
|
||||||
{label}
|
|
||||||
<StatusDot status={status} />
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
|
||||||
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--code-bg)',
|
color: 'var(--tool-item-muted)',
|
||||||
border: '1px solid var(--code-border)',
|
background: 'none',
|
||||||
color:
|
border: 'none',
|
||||||
status === 'error'
|
padding: 0,
|
||||||
? 'var(--tool-result-error-text)'
|
cursor: 'pointer',
|
||||||
: 'var(--color-text-secondary)',
|
|
||||||
}}
|
}}
|
||||||
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{children}
|
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||||
</div>
|
{label}
|
||||||
)}
|
<StatusDot status={status} />
|
||||||
</div>
|
</button>
|
||||||
);
|
{isExpanded && (
|
||||||
};
|
<div
|
||||||
|
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--code-bg)',
|
||||||
|
border: '1px solid var(--code-border)',
|
||||||
|
color:
|
||||||
|
status === 'error'
|
||||||
|
? 'var(--tool-result-error-text)'
|
||||||
|
: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Default rendering for tools that don't have specialized viewers.
|
* Default rendering for tools that don't have specialized viewers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { type ItemStatus } from '../BaseItem';
|
import { type ItemStatus } from '../BaseItem';
|
||||||
|
|
||||||
|
|
@ -23,7 +23,10 @@ interface DefaultToolViewerProps {
|
||||||
status: ItemStatus;
|
status: ItemStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool, status }) => {
|
export const DefaultToolViewer = memo(function DefaultToolViewer({
|
||||||
|
linkedTool,
|
||||||
|
status,
|
||||||
|
}: DefaultToolViewerProps) {
|
||||||
const displayOutputContent = linkedTool.result
|
const displayOutputContent = linkedTool.result
|
||||||
? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content)
|
? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -64,4 +67,4 @@ export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Renders the Edit tool with DiffViewer.
|
* Renders the Edit tool with DiffViewer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { DiffViewer } from '@renderer/components/chat/viewers';
|
import { DiffViewer } from '@renderer/components/chat/viewers';
|
||||||
|
|
||||||
|
|
@ -20,7 +20,10 @@ interface EditToolViewerProps {
|
||||||
status: ItemStatus;
|
status: ItemStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditToolViewer: React.FC<EditToolViewerProps> = ({ linkedTool, status }) => {
|
export const EditToolViewer = memo(function EditToolViewer({
|
||||||
|
linkedTool,
|
||||||
|
status,
|
||||||
|
}: EditToolViewerProps) {
|
||||||
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
|
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string);
|
const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string);
|
||||||
|
|
@ -71,4 +74,4 @@ export const EditToolViewer: React.FC<EditToolViewerProps> = ({ linkedTool, stat
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Renders the Read tool result using CodeBlockViewer.
|
* Renders the Read tool result using CodeBlockViewer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface ReadToolViewerProps {
|
||||||
linkedTool: LinkedToolItem;
|
linkedTool: LinkedToolItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) => {
|
export const ReadToolViewer = memo(function ReadToolViewer({ linkedTool }: ReadToolViewerProps) {
|
||||||
const filePath = linkedTool.input.file_path as string;
|
const filePath = linkedTool.input.file_path as string;
|
||||||
|
|
||||||
// Prefer enriched toolUseResult data
|
// Prefer enriched toolUseResult data
|
||||||
|
|
@ -55,7 +55,9 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const isMarkdownFile = /\.mdx?$/i.test(filePath);
|
const isMarkdownFile = /\.mdx?$/i.test(filePath);
|
||||||
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code');
|
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(
|
||||||
|
isMarkdownFile ? 'preview' : 'code'
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -99,4 +101,4 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Renders the Skill tool with its instructions in a code block viewer style.
|
* Renders the Skill tool with its instructions in a code block viewer style.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { CodeBlockViewer } from '@renderer/components/chat/viewers';
|
import { CodeBlockViewer } from '@renderer/components/chat/viewers';
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface SkillToolViewerProps {
|
||||||
linkedTool: LinkedToolItem;
|
linkedTool: LinkedToolItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SkillToolViewer: React.FC<SkillToolViewerProps> = ({ linkedTool }) => {
|
export const SkillToolViewer = memo(function SkillToolViewer({ linkedTool }: SkillToolViewerProps) {
|
||||||
const skillInstructions = linkedTool.skillInstructions;
|
const skillInstructions = linkedTool.skillInstructions;
|
||||||
const skillName = (linkedTool.input.skill as string) || 'Unknown Skill';
|
const skillName = (linkedTool.input.skill as string) || 'Unknown Skill';
|
||||||
|
|
||||||
|
|
@ -64,4 +64,4 @@ export const SkillToolViewer: React.FC<SkillToolViewerProps> = ({ linkedTool })
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Displays error output for tool results.
|
* Displays error output for tool results.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { StatusDot } from '../BaseItem';
|
import { StatusDot } from '../BaseItem';
|
||||||
|
|
||||||
|
|
@ -16,7 +16,9 @@ interface ToolErrorDisplayProps {
|
||||||
linkedTool: LinkedToolItem;
|
linkedTool: LinkedToolItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolErrorDisplay: React.FC<ToolErrorDisplayProps> = ({ linkedTool }) => {
|
export const ToolErrorDisplay = memo(function ToolErrorDisplay({
|
||||||
|
linkedTool,
|
||||||
|
}: ToolErrorDisplayProps) {
|
||||||
if (!linkedTool.result?.isError) return null;
|
if (!linkedTool.result?.isError) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -40,4 +42,4 @@ export const ToolErrorDisplay: React.FC<ToolErrorDisplayProps> = ({ linkedTool }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Renders the Write tool result.
|
* Renders the Write tool result.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
|
|
||||||
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface WriteToolViewerProps {
|
||||||
linkedTool: LinkedToolItem;
|
linkedTool: LinkedToolItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool }) => {
|
export const WriteToolViewer = memo(function WriteToolViewer({ linkedTool }: WriteToolViewerProps) {
|
||||||
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
|
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
const filePath =
|
const filePath =
|
||||||
|
|
@ -74,4 +74,4 @@ export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool })
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { memo, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -117,14 +117,14 @@ function inferLanguage(fileName: string): string {
|
||||||
// Component
|
// Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const CodeBlockViewer: React.FC<CodeBlockViewerProps> = ({
|
export const CodeBlockViewer = memo(function CodeBlockViewer({
|
||||||
fileName,
|
fileName,
|
||||||
content,
|
content,
|
||||||
language,
|
language,
|
||||||
startLine = 1,
|
startLine = 1,
|
||||||
endLine,
|
endLine,
|
||||||
maxHeight = 'max-h-96',
|
maxHeight = 'max-h-96',
|
||||||
}): React.JSX.Element => {
|
}: CodeBlockViewerProps): React.JSX.Element {
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
||||||
// Infer language from file extension if not provided
|
// Infer language from file extension if not provided
|
||||||
|
|
@ -241,4 +241,4 @@ export const CodeBlockViewer: React.FC<CodeBlockViewerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CODE_BG,
|
CODE_BG,
|
||||||
|
|
@ -349,14 +349,14 @@ const DiffLineRow: React.FC<DiffLineRowProps> = ({ line, highlightedHtml }): Rea
|
||||||
// Main Component
|
// Main Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const DiffViewer: React.FC<DiffViewerProps> = ({
|
export const DiffViewer = memo(function DiffViewer({
|
||||||
fileName,
|
fileName,
|
||||||
oldString,
|
oldString,
|
||||||
newString,
|
newString,
|
||||||
maxHeight = 'max-h-96',
|
maxHeight = 'max-h-96',
|
||||||
tokenCount,
|
tokenCount,
|
||||||
syntaxHighlight = false,
|
syntaxHighlight = false,
|
||||||
}): React.JSX.Element => {
|
}: DiffViewerProps): React.JSX.Element {
|
||||||
// Compute diff
|
// Compute diff
|
||||||
const oldLines = oldString.split(/\r?\n/);
|
const oldLines = oldString.split(/\r?\n/);
|
||||||
const newLines = newString.split(/\r?\n/);
|
const newLines = newString.split(/\r?\n/);
|
||||||
|
|
@ -456,4 +456,4 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -946,7 +946,7 @@ export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = Rea
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(function MarkdownViewer({
|
||||||
content,
|
content,
|
||||||
maxHeight = 'max-h-96',
|
maxHeight = 'max-h-96',
|
||||||
className = '',
|
className = '',
|
||||||
|
|
@ -958,7 +958,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
baseDir,
|
baseDir,
|
||||||
teamColorByName: providedTeamColorByName,
|
teamColorByName: providedTeamColorByName,
|
||||||
onTeamClick: providedOnTeamClick,
|
onTeamClick: providedOnTeamClick,
|
||||||
}) => {
|
}) {
|
||||||
const [showRaw, setShowRaw] = React.useState(false);
|
const [showRaw, setShowRaw] = React.useState(false);
|
||||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
@ -1169,12 +1169,36 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
|
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{copyable && (
|
<span className="flex-1" />
|
||||||
<>
|
<button
|
||||||
<span className="flex-1" />
|
type="button"
|
||||||
<CopyButton text={content} inline />
|
className="text-xs underline"
|
||||||
</>
|
style={{ color: PROSE_LINK }}
|
||||||
)}
|
onClick={() => setShowRaw(true)}
|
||||||
|
title="Show raw"
|
||||||
|
>
|
||||||
|
Show raw
|
||||||
|
</button>
|
||||||
|
{copyable && <CopyButton text={content} inline />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show raw toggle for no-label path (skip in bare mode) */}
|
||||||
|
{!label && !bare && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-3 py-1 text-xs"
|
||||||
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="underline"
|
||||||
|
style={{ color: PROSE_LINK }}
|
||||||
|
onClick={() => setShowRaw(true)}
|
||||||
|
title="Show raw"
|
||||||
|
>
|
||||||
|
Show raw
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1195,4 +1219,4 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
|
|
@ -24,13 +24,18 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
|
|
||||||
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
||||||
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
||||||
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
||||||
|
|
||||||
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
|
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
|
||||||
|
|
||||||
|
const LaunchTeamDialog = lazy(() =>
|
||||||
|
import('@renderer/components/team/dialogs/LaunchTeamDialog').then((m) => ({
|
||||||
|
default: m.LaunchTeamDialog,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Constants
|
// Constants
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -562,13 +567,17 @@ export const SchedulesView = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
<LaunchTeamDialog
|
{dialogOpen && (
|
||||||
mode="schedule"
|
<Suspense fallback={null}>
|
||||||
open={dialogOpen}
|
<LaunchTeamDialog
|
||||||
teamName={editingSchedule?.teamName}
|
mode="schedule"
|
||||||
schedule={editingSchedule}
|
open={dialogOpen}
|
||||||
onClose={handleClose}
|
teamName={editingSchedule?.teamName}
|
||||||
/>
|
schedule={editingSchedule}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Supports multi-select with bulk actions and hidden session filtering.
|
* Supports multi-select with bulk actions and hidden session filtering.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
|
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
|
||||||
|
|
@ -184,7 +184,7 @@ function matchesSessionSearch(session: Session, query: string): boolean {
|
||||||
return haystack.includes(query);
|
return haystack.includes(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DateGroupedSessions = (): React.JSX.Element => {
|
export const DateGroupedSessions = memo((): React.JSX.Element => {
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
|
|
@ -202,7 +202,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
toggleShowHiddenSessions,
|
toggleShowHiddenSessions,
|
||||||
sidebarSelectedSessionIds,
|
sidebarSelectedSessionIds,
|
||||||
sidebarMultiSelectActive,
|
sidebarMultiSelectActive,
|
||||||
toggleSidebarSessionSelection,
|
|
||||||
clearSidebarSelection,
|
clearSidebarSelection,
|
||||||
toggleSidebarMultiSelect,
|
toggleSidebarMultiSelect,
|
||||||
hideMultipleSessions,
|
hideMultipleSessions,
|
||||||
|
|
@ -239,7 +238,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
toggleShowHiddenSessions: s.toggleShowHiddenSessions,
|
toggleShowHiddenSessions: s.toggleShowHiddenSessions,
|
||||||
sidebarSelectedSessionIds: s.sidebarSelectedSessionIds,
|
sidebarSelectedSessionIds: s.sidebarSelectedSessionIds,
|
||||||
sidebarMultiSelectActive: s.sidebarMultiSelectActive,
|
sidebarMultiSelectActive: s.sidebarMultiSelectActive,
|
||||||
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
|
|
||||||
clearSidebarSelection: s.clearSidebarSelection,
|
clearSidebarSelection: s.clearSidebarSelection,
|
||||||
toggleSidebarMultiSelect: s.toggleSidebarMultiSelect,
|
toggleSidebarMultiSelect: s.toggleSidebarMultiSelect,
|
||||||
hideMultipleSessions: s.hideMultipleSessions,
|
hideMultipleSessions: s.hideMultipleSessions,
|
||||||
|
|
@ -1104,7 +1102,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
isHidden={item.isHidden}
|
isHidden={item.isHidden}
|
||||||
multiSelectActive={sidebarMultiSelectActive}
|
multiSelectActive={sidebarMultiSelectActive}
|
||||||
isSelected={selectedSet.has(item.session.id)}
|
isSelected={selectedSet.has(item.session.id)}
|
||||||
onToggleSelect={() => toggleSidebarSessionSelection(item.session.id)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1114,4 +1111,4 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
@ -173,13 +173,13 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl
|
||||||
return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized);
|
return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GlobalTaskList = ({
|
export const GlobalTaskList = memo(function GlobalTaskList({
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
filters: externalFilters,
|
filters: externalFilters,
|
||||||
onFiltersChange: externalOnFiltersChange,
|
onFiltersChange: externalOnFiltersChange,
|
||||||
filtersPopoverOpen: externalFiltersPopoverOpen,
|
filtersPopoverOpen: externalFiltersPopoverOpen,
|
||||||
onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange,
|
onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange,
|
||||||
}: GlobalTaskListProps = {}): React.JSX.Element => {
|
}: GlobalTaskListProps = {}): React.JSX.Element {
|
||||||
const {
|
const {
|
||||||
globalTasks,
|
globalTasks,
|
||||||
globalTasksLoading,
|
globalTasksLoading,
|
||||||
|
|
@ -271,37 +271,43 @@ export const GlobalTaskList = ({
|
||||||
saveSortMode(mode);
|
saveSortMode(mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => {
|
const handleRenameComplete = useCallback(
|
||||||
taskLocalState.renameTask(teamName, taskId, newSubject);
|
(teamName: string, taskId: string, newSubject: string): void => {
|
||||||
setRenamingTaskKey(null);
|
taskLocalState.renameTask(teamName, taskId, newSubject);
|
||||||
};
|
setRenamingTaskKey(null);
|
||||||
|
},
|
||||||
|
[taskLocalState]
|
||||||
|
);
|
||||||
|
|
||||||
const handleRenameCancel = (): void => {
|
const handleRenameCancel = useCallback((): void => {
|
||||||
setRenamingTaskKey(null);
|
setRenamingTaskKey(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDeleteTask = async (teamName: string, taskId: string): Promise<void> => {
|
const handleDeleteTask = useCallback(
|
||||||
const confirmed = await confirm({
|
async (teamName: string, taskId: string): Promise<void> => {
|
||||||
title: 'Delete task',
|
const confirmed = await confirm({
|
||||||
message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
|
title: 'Delete task',
|
||||||
confirmLabel: 'Delete',
|
message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
|
||||||
cancelLabel: 'Cancel',
|
confirmLabel: 'Delete',
|
||||||
variant: 'danger',
|
cancelLabel: 'Cancel',
|
||||||
});
|
variant: 'danger',
|
||||||
if (confirmed) {
|
});
|
||||||
try {
|
if (confirmed) {
|
||||||
await softDeleteTask(teamName, taskId);
|
try {
|
||||||
await fetchAllTasks();
|
await softDeleteTask(teamName, taskId);
|
||||||
} catch (err) {
|
await fetchAllTasks();
|
||||||
void confirm({
|
} catch (err) {
|
||||||
title: 'Failed to delete task',
|
void confirm({
|
||||||
message: err instanceof Error ? err.message : 'An unexpected error occurred',
|
title: 'Failed to delete task',
|
||||||
confirmLabel: 'OK',
|
message: err instanceof Error ? err.message : 'An unexpected error occurred',
|
||||||
variant: 'danger',
|
confirmLabel: 'OK',
|
||||||
});
|
variant: 'danger',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[fetchAllTasks, softDeleteTask]
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch tasks on mount — loading guard in the store action prevents
|
// Fetch tasks on mount — loading guard in the store action prevents
|
||||||
// duplicate IPC calls when the centralized init chain is already fetching.
|
// duplicate IPC calls when the centralized init chain is already fetching.
|
||||||
|
|
@ -850,4 +856,4 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Supports right-click context menu for pane management.
|
* Supports right-click context menu for pane management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { memo, useCallback, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
|
|
@ -30,7 +30,6 @@ interface SessionItemProps {
|
||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
multiSelectActive?: boolean;
|
multiSelectActive?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onToggleSelect?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -156,15 +155,14 @@ const SessionRuntimeBadge = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SessionItem = ({
|
export const SessionItem = memo(function SessionItem({
|
||||||
session,
|
session,
|
||||||
isActive,
|
isActive,
|
||||||
isPinned,
|
isPinned,
|
||||||
isHidden,
|
isHidden,
|
||||||
multiSelectActive,
|
multiSelectActive,
|
||||||
isSelected,
|
isSelected,
|
||||||
onToggleSelect,
|
}: Readonly<SessionItemProps>): React.JSX.Element {
|
||||||
}: Readonly<SessionItemProps>): React.JSX.Element => {
|
|
||||||
const {
|
const {
|
||||||
openTab,
|
openTab,
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
|
|
@ -173,6 +171,7 @@ export const SessionItem = ({
|
||||||
splitPane,
|
splitPane,
|
||||||
togglePinSession,
|
togglePinSession,
|
||||||
toggleHideSession,
|
toggleHideSession,
|
||||||
|
toggleSidebarSessionSelection,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
openTab: s.openTab,
|
openTab: s.openTab,
|
||||||
|
|
@ -182,6 +181,7 @@ export const SessionItem = ({
|
||||||
splitPane: s.splitPane,
|
splitPane: s.splitPane,
|
||||||
togglePinSession: s.togglePinSession,
|
togglePinSession: s.togglePinSession,
|
||||||
toggleHideSession: s.toggleHideSession,
|
toggleHideSession: s.toggleHideSession,
|
||||||
|
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -191,8 +191,8 @@ export const SessionItem = ({
|
||||||
if (!activeProjectId) return;
|
if (!activeProjectId) return;
|
||||||
|
|
||||||
// In multi-select mode, clicks toggle selection
|
// In multi-select mode, clicks toggle selection
|
||||||
if (multiSelectActive && onToggleSelect) {
|
if (multiSelectActive) {
|
||||||
onToggleSelect();
|
toggleSidebarSessionSelection(session.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,7 +290,7 @@ export const SessionItem = ({
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected ?? false}
|
checked={isSelected ?? false}
|
||||||
onChange={() => onToggleSelect?.()}
|
onChange={() => toggleSidebarSessionSelection(session.id)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="size-3.5 shrink-0 accent-blue-500"
|
className="size-3.5 shrink-0 accent-blue-500"
|
||||||
/>
|
/>
|
||||||
|
|
@ -390,4 +390,4 @@ export const SessionItem = ({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
|
@ -69,7 +69,7 @@ interface SidebarTaskItemProps {
|
||||||
getDisplaySubject?: (task: GlobalTask) => string | undefined;
|
getDisplaySubject?: (task: GlobalTask) => string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidebarTaskItem = ({
|
export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||||
task,
|
task,
|
||||||
hideTeamName,
|
hideTeamName,
|
||||||
showTeamName,
|
showTeamName,
|
||||||
|
|
@ -77,7 +77,7 @@ export const SidebarTaskItem = ({
|
||||||
onRenameComplete,
|
onRenameComplete,
|
||||||
onRenameCancel,
|
onRenameCancel,
|
||||||
getDisplaySubject,
|
getDisplaySubject,
|
||||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
}: SidebarTaskItemProps): React.JSX.Element {
|
||||||
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
||||||
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
|
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
|
||||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||||
|
|
@ -283,4 +283,4 @@ export const SidebarTaskItem = ({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
@ -44,7 +44,7 @@ interface CollapsibleTeamSectionProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CollapsibleTeamSection = ({
|
export const CollapsibleTeamSection = memo(function CollapsibleTeamSection({
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
badge,
|
badge,
|
||||||
|
|
@ -63,7 +63,7 @@ export const CollapsibleTeamSection = ({
|
||||||
headerSurfaceClassName,
|
headerSurfaceClassName,
|
||||||
keepMounted,
|
keepMounted,
|
||||||
children,
|
children,
|
||||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
}: CollapsibleTeamSectionProps): React.JSX.Element {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const isOpen = forceOpen ? true : open;
|
const isOpen = forceOpen ? true : open;
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
|
@ -174,4 +174,4 @@ export const CollapsibleTeamSection = ({
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getTeamColorSet,
|
getTeamColorSet,
|
||||||
|
|
@ -37,81 +37,83 @@ interface MemberBadgeProps {
|
||||||
* When onClick is provided, both avatar and badge are clickable as one unit.
|
* When onClick is provided, both avatar and badge are clickable as one unit.
|
||||||
* Wrapped in MemberHoverCard to show member info on hover.
|
* Wrapped in MemberHoverCard to show member info on hover.
|
||||||
*/
|
*/
|
||||||
export const MemberBadge = ({
|
export const MemberBadge = memo(
|
||||||
name,
|
({
|
||||||
color,
|
name,
|
||||||
teamName,
|
color,
|
||||||
size = 'sm',
|
teamName,
|
||||||
hideAvatar,
|
size = 'sm',
|
||||||
onClick,
|
hideAvatar,
|
||||||
disableHoverCard,
|
onClick,
|
||||||
}: MemberBadgeProps): React.JSX.Element => {
|
disableHoverCard,
|
||||||
const colors = getTeamColorSet(color ?? '');
|
}: MemberBadgeProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const colors = getTeamColorSet(color ?? '');
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
const { isLight } = useTheme();
|
||||||
const effectiveTeamName = teamName ?? selectedTeamName;
|
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||||
const teamMembers = useStore((s) =>
|
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||||
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
|
const teamMembers = useStore((s) =>
|
||||||
);
|
effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : []
|
||||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
);
|
||||||
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
|
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||||
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
|
const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18;
|
||||||
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
|
const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4';
|
||||||
const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5';
|
const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]';
|
||||||
|
const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5';
|
||||||
|
|
||||||
const badgeStyle = {
|
const badgeStyle = {
|
||||||
backgroundColor: getThemedBadge(colors, isLight),
|
backgroundColor: getThemedBadge(colors, isLight),
|
||||||
color: getThemedText(colors, isLight),
|
color: getThemedText(colors, isLight),
|
||||||
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatar = (
|
const avatar = (
|
||||||
<img
|
<img
|
||||||
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
|
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
|
||||||
alt=""
|
alt=""
|
||||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const badge = (
|
const badge = (
|
||||||
<span
|
<span
|
||||||
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
|
className={`rounded ${paddingClass} ${textClass} font-medium tracking-wide`}
|
||||||
style={badgeStyle}
|
style={badgeStyle}
|
||||||
>
|
>
|
||||||
{displayMemberName(name)}
|
{displayMemberName(name)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Skip hover card for "user" and "system" pseudo-members
|
// Skip hover card for "user" and "system" pseudo-members
|
||||||
const skipHoverCard = disableHoverCard || name === 'user' || name === 'system';
|
const skipHoverCard = disableHoverCard || name === 'user' || name === 'system';
|
||||||
|
|
||||||
const content = onClick ? (
|
const content = onClick ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick(name);
|
onClick(name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!hideAvatar && avatar}
|
{!hideAvatar && avatar}
|
||||||
{badge}
|
{badge}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{!hideAvatar && avatar}
|
{!hideAvatar && avatar}
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (skipHoverCard) {
|
if (skipHoverCard) {
|
||||||
return content;
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MemberHoverCard name={name} color={color} teamName={teamName}>
|
||||||
|
{content}
|
||||||
|
</MemberHoverCard>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return (
|
|
||||||
<MemberHoverCard name={name} color={color} teamName={teamName}>
|
|
||||||
{content}
|
|
||||||
</MemberHoverCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
|
@ -65,12 +65,12 @@ interface TaskTooltipProps {
|
||||||
* Tooltip that shows task summary on hover over any #taskId link.
|
* Tooltip that shows task summary on hover over any #taskId link.
|
||||||
* Reads task data from the current team in the store.
|
* Reads task data from the current team in the store.
|
||||||
*/
|
*/
|
||||||
export const TaskTooltip = ({
|
export const TaskTooltip = memo(function TaskTooltip({
|
||||||
taskId,
|
taskId,
|
||||||
teamName,
|
teamName,
|
||||||
children,
|
children,
|
||||||
side = 'top',
|
side = 'top',
|
||||||
}: TaskTooltipProps): React.JSX.Element => {
|
}: TaskTooltipProps): React.JSX.Element {
|
||||||
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
||||||
useStore(
|
useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
|
|
@ -194,4 +194,4 @@ export const TaskTooltip = ({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -78,12 +78,8 @@ import {
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||||||
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
|
||||||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||||
import { LaunchTeamDialog, type TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
|
|
||||||
import { ReviewDialog } from './dialogs/ReviewDialog';
|
import { ReviewDialog } from './dialogs/ReviewDialog';
|
||||||
import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
|
||||||
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
|
||||||
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
||||||
import { KanbanBoard } from './kanban/KanbanBoard';
|
import { KanbanBoard } from './kanban/KanbanBoard';
|
||||||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||||||
|
|
@ -93,9 +89,13 @@ import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||||
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
|
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
|
||||||
|
|
||||||
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
|
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
|
||||||
|
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
|
||||||
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||||||
import type { ComponentProps, CSSProperties } from 'react';
|
import type { ComponentProps, CSSProperties } from 'react';
|
||||||
|
|
||||||
|
const LaunchTeamDialog = lazy(() =>
|
||||||
|
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
|
||||||
|
);
|
||||||
const ProjectEditorOverlay = lazy(() =>
|
const ProjectEditorOverlay = lazy(() =>
|
||||||
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
|
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
|
||||||
);
|
);
|
||||||
|
|
@ -104,9 +104,20 @@ const TeamGraphOverlay = lazy(() =>
|
||||||
default: m.TeamGraphOverlay,
|
default: m.TeamGraphOverlay,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
const TaskDetailDialog = lazy(() =>
|
||||||
|
import('./dialogs/TaskDetailDialog').then((m) => ({ default: m.TaskDetailDialog }))
|
||||||
|
);
|
||||||
|
const SendMessageDialog = lazy(() =>
|
||||||
|
import('./dialogs/SendMessageDialog').then((m) => ({ default: m.SendMessageDialog }))
|
||||||
|
);
|
||||||
|
const CreateTaskDialog = lazy(() =>
|
||||||
|
import('./dialogs/CreateTaskDialog').then((m) => ({ default: m.CreateTaskDialog }))
|
||||||
|
);
|
||||||
|
const ChangeReviewDialog = lazy(() =>
|
||||||
|
import('./review/ChangeReviewDialog').then((m) => ({ default: m.ChangeReviewDialog }))
|
||||||
|
);
|
||||||
import { MemberList } from './members/MemberList';
|
import { MemberList } from './members/MemberList';
|
||||||
import { MessagesPanel } from './messages/MessagesPanel';
|
import { MessagesPanel } from './messages/MessagesPanel';
|
||||||
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
|
|
||||||
import { ScheduleSection } from './schedule/ScheduleSection';
|
import { ScheduleSection } from './schedule/ScheduleSection';
|
||||||
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
|
||||||
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
|
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
|
||||||
|
|
@ -874,10 +885,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TeamDetailView = ({
|
export const TeamDetailView = memo(function TeamDetailView({
|
||||||
teamName,
|
teamName,
|
||||||
isPaneFocused = false,
|
isPaneFocused = false,
|
||||||
}: TeamDetailViewProps): React.JSX.Element => {
|
}: TeamDetailViewProps): React.JSX.Element {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||||||
|
|
@ -2077,18 +2088,22 @@ export const TeamDetailView = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LaunchTeamDialog
|
{launchDialogOpen && (
|
||||||
mode={launchDialogState.mode}
|
<Suspense fallback={null}>
|
||||||
open={launchDialogOpen}
|
<LaunchTeamDialog
|
||||||
teamName={teamName}
|
mode={launchDialogState.mode}
|
||||||
members={[]}
|
open={launchDialogOpen}
|
||||||
defaultProjectPath={draftTeamSummary?.projectPath}
|
teamName={teamName}
|
||||||
provisioningError={provisioningError}
|
members={[]}
|
||||||
clearProvisioningError={clearProvisioningError}
|
defaultProjectPath={draftTeamSummary?.projectPath}
|
||||||
onClose={closeLaunchDialog}
|
provisioningError={provisioningError}
|
||||||
onLaunch={handleLaunchDialogSubmit}
|
clearProvisioningError={clearProvisioningError}
|
||||||
onRelaunch={handleRelaunchDialogSubmit}
|
onClose={closeLaunchDialog}
|
||||||
/>
|
onLaunch={handleLaunchDialogSubmit}
|
||||||
|
onRelaunch={handleRelaunchDialogSubmit}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2744,21 +2759,25 @@ export const TeamDetailView = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateTaskDialog
|
{createTaskDialog.open && (
|
||||||
open={createTaskDialog.open}
|
<Suspense fallback={null}>
|
||||||
teamName={teamName}
|
<CreateTaskDialog
|
||||||
members={activeMembers}
|
open={createTaskDialog.open}
|
||||||
tasks={data.tasks}
|
teamName={teamName}
|
||||||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
members={activeMembers}
|
||||||
defaultSubject={createTaskDialog.defaultSubject}
|
tasks={data.tasks}
|
||||||
defaultDescription={createTaskDialog.defaultDescription}
|
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||||
defaultOwner={createTaskDialog.defaultOwner}
|
defaultSubject={createTaskDialog.defaultSubject}
|
||||||
defaultStartImmediately={createTaskDialog.defaultStartImmediately}
|
defaultDescription={createTaskDialog.defaultDescription}
|
||||||
defaultChip={createTaskDialog.defaultChip}
|
defaultOwner={createTaskDialog.defaultOwner}
|
||||||
onClose={closeCreateTaskDialog}
|
defaultStartImmediately={createTaskDialog.defaultStartImmediately}
|
||||||
onSubmit={handleCreateTask}
|
defaultChip={createTaskDialog.defaultChip}
|
||||||
submitting={creatingTask}
|
onClose={closeCreateTaskDialog}
|
||||||
/>
|
onSubmit={handleCreateTask}
|
||||||
|
submitting={creatingTask}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
<EditTeamDialog
|
<EditTeamDialog
|
||||||
open={editDialogOpen}
|
open={editDialogOpen}
|
||||||
|
|
@ -2864,117 +2883,129 @@ export const TeamDetailView = ({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<LaunchTeamDialog
|
{launchDialogOpen && (
|
||||||
mode={launchDialogState.mode}
|
<Suspense fallback={null}>
|
||||||
open={launchDialogOpen}
|
<LaunchTeamDialog
|
||||||
teamName={teamName}
|
mode={launchDialogState.mode}
|
||||||
members={membersWithLiveBranches}
|
open={launchDialogOpen}
|
||||||
defaultProjectPath={data.config.projectPath}
|
teamName={teamName}
|
||||||
provisioningError={provisioningError}
|
members={membersWithLiveBranches}
|
||||||
clearProvisioningError={clearProvisioningError}
|
defaultProjectPath={data.config.projectPath}
|
||||||
activeTeams={activeTeamsForLaunch}
|
provisioningError={provisioningError}
|
||||||
onClose={closeLaunchDialog}
|
clearProvisioningError={clearProvisioningError}
|
||||||
onLaunch={handleLaunchDialogSubmit}
|
activeTeams={activeTeamsForLaunch}
|
||||||
onRelaunch={handleRelaunchDialogSubmit}
|
onClose={closeLaunchDialog}
|
||||||
/>
|
onLaunch={handleLaunchDialogSubmit}
|
||||||
|
onRelaunch={handleRelaunchDialogSubmit}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
<SendMessageDialog
|
{sendDialogOpen && (
|
||||||
open={sendDialogOpen}
|
<Suspense fallback={null}>
|
||||||
teamName={teamName}
|
<SendMessageDialog
|
||||||
members={activeMembers}
|
open={sendDialogOpen}
|
||||||
defaultRecipient={sendDialogRecipient}
|
teamName={teamName}
|
||||||
defaultText={sendDialogDefaultText}
|
members={activeMembers}
|
||||||
defaultChip={sendDialogDefaultChip}
|
defaultRecipient={sendDialogRecipient}
|
||||||
quotedMessage={replyQuote}
|
defaultText={sendDialogDefaultText}
|
||||||
isTeamAlive={data.isAlive}
|
defaultChip={sendDialogDefaultChip}
|
||||||
sending={sendingMessage}
|
quotedMessage={replyQuote}
|
||||||
sendError={sendMessageError}
|
isTeamAlive={data.isAlive}
|
||||||
sendWarning={sendMessageWarning}
|
sending={sendingMessage}
|
||||||
sendDebugDetails={sendMessageDebugDetails}
|
sendError={sendMessageError}
|
||||||
lastResult={lastSendMessageResult}
|
sendWarning={sendMessageWarning}
|
||||||
onSend={async (member, text, summary, attachments, actionMode, taskRefs) => {
|
sendDebugDetails={sendMessageDebugDetails}
|
||||||
const sentAtMs = Date.now();
|
lastResult={lastSendMessageResult}
|
||||||
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
onSend={async (member, text, summary, attachments, actionMode, taskRefs) => {
|
||||||
try {
|
const sentAtMs = Date.now();
|
||||||
const result = await sendTeamMessage(teamName, {
|
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||||||
member,
|
try {
|
||||||
text,
|
const result = await sendTeamMessage(teamName, {
|
||||||
summary,
|
member,
|
||||||
attachments,
|
text,
|
||||||
actionMode,
|
summary,
|
||||||
taskRefs,
|
attachments,
|
||||||
});
|
actionMode,
|
||||||
if (
|
taskRefs,
|
||||||
result?.runtimeDelivery?.attempted === true &&
|
});
|
||||||
result.runtimeDelivery.delivered === false
|
if (
|
||||||
) {
|
result?.runtimeDelivery?.attempted === true &&
|
||||||
setPendingRepliesByMember((prev) => {
|
result.runtimeDelivery.delivered === false
|
||||||
if (prev[member] !== sentAtMs) return prev;
|
) {
|
||||||
const next = { ...prev };
|
setPendingRepliesByMember((prev) => {
|
||||||
delete next[member];
|
if (prev[member] !== sentAtMs) return prev;
|
||||||
return next;
|
const next = { ...prev };
|
||||||
});
|
delete next[member];
|
||||||
}
|
return next;
|
||||||
return result;
|
});
|
||||||
} catch (error) {
|
}
|
||||||
setPendingRepliesByMember((prev) => {
|
return result;
|
||||||
if (prev[member] !== sentAtMs) return prev;
|
} catch (error) {
|
||||||
const next = { ...prev };
|
setPendingRepliesByMember((prev) => {
|
||||||
delete next[member];
|
if (prev[member] !== sentAtMs) return prev;
|
||||||
return next;
|
const next = { ...prev };
|
||||||
});
|
delete next[member];
|
||||||
throw error;
|
return next;
|
||||||
}
|
});
|
||||||
}}
|
throw error;
|
||||||
onClose={() => {
|
}
|
||||||
setSendDialogOpen(false);
|
}}
|
||||||
setReplyQuote(undefined);
|
onClose={() => {
|
||||||
setSendDialogDefaultText(undefined);
|
setSendDialogOpen(false);
|
||||||
setSendDialogDefaultChip(undefined);
|
setReplyQuote(undefined);
|
||||||
}}
|
setSendDialogDefaultText(undefined);
|
||||||
/>
|
setSendDialogDefaultChip(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
<TaskDetailDialog
|
{selectedTask !== null && (
|
||||||
open={selectedTask !== null}
|
<Suspense fallback={null}>
|
||||||
task={selectedTask}
|
<TaskDetailDialog
|
||||||
teamName={teamName}
|
open={selectedTask !== null}
|
||||||
kanbanTaskState={
|
task={selectedTask}
|
||||||
selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined
|
teamName={teamName}
|
||||||
}
|
kanbanTaskState={
|
||||||
taskMap={taskMap}
|
selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined
|
||||||
members={activeMembers}
|
|
||||||
onClose={() => setSelectedTask(null)}
|
|
||||||
onScrollToTask={(taskId) => {
|
|
||||||
setSelectedTask(null);
|
|
||||||
const el = document.querySelector(`[data-task-id="${taskId}"]`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
el.classList.remove('kanban-card-focus-pulse');
|
|
||||||
void (el as HTMLElement).offsetWidth;
|
|
||||||
el.classList.add('kanban-card-focus-pulse');
|
|
||||||
el.addEventListener(
|
|
||||||
'animationend',
|
|
||||||
() => el.classList.remove('kanban-card-focus-pulse'),
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onOwnerChange={(taskId, owner) => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await updateTaskOwner(teamName, taskId, owner);
|
|
||||||
} catch {
|
|
||||||
// error via store
|
|
||||||
}
|
}
|
||||||
})();
|
taskMap={taskMap}
|
||||||
}}
|
members={activeMembers}
|
||||||
onViewChanges={handleViewChangesForFile}
|
onClose={() => setSelectedTask(null)}
|
||||||
onOpenInEditor={(filePath) => {
|
onScrollToTask={(taskId) => {
|
||||||
const { revealFileInEditor } = useStore.getState();
|
setSelectedTask(null);
|
||||||
revealFileInEditor(filePath);
|
const el = document.querySelector(`[data-task-id="${taskId}"]`);
|
||||||
}}
|
if (el) {
|
||||||
onDeleteTask={handleDeleteTask}
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
/>
|
el.classList.remove('kanban-card-focus-pulse');
|
||||||
|
void (el as HTMLElement).offsetWidth;
|
||||||
|
el.classList.add('kanban-card-focus-pulse');
|
||||||
|
el.addEventListener(
|
||||||
|
'animationend',
|
||||||
|
() => el.classList.remove('kanban-card-focus-pulse'),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOwnerChange={(taskId, owner) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await updateTaskOwner(teamName, taskId, owner);
|
||||||
|
} catch {
|
||||||
|
// error via store
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
onViewChanges={handleViewChangesForFile}
|
||||||
|
onOpenInEditor={(filePath) => {
|
||||||
|
const { revealFileInEditor } = useStore.getState();
|
||||||
|
revealFileInEditor(filePath);
|
||||||
|
}}
|
||||||
|
onDeleteTask={handleDeleteTask}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
<TrashDialog
|
<TrashDialog
|
||||||
open={trashOpen}
|
open={trashOpen}
|
||||||
|
|
@ -2991,26 +3022,30 @@ export const TeamDetailView = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChangeReviewDialog
|
{reviewDialogState.open && (
|
||||||
open={reviewDialogState.open}
|
<Suspense fallback={null}>
|
||||||
onOpenChange={(open) =>
|
<ChangeReviewDialog
|
||||||
setReviewDialogState((prev) => ({
|
open={reviewDialogState.open}
|
||||||
...prev,
|
onOpenChange={(open) =>
|
||||||
open,
|
setReviewDialogState((prev) => ({
|
||||||
...(open
|
...prev,
|
||||||
? {}
|
open,
|
||||||
: { initialFilePath: undefined, taskChangeRequestOptions: undefined }),
|
...(open
|
||||||
}))
|
? {}
|
||||||
}
|
: { initialFilePath: undefined, taskChangeRequestOptions: undefined }),
|
||||||
teamName={teamName}
|
}))
|
||||||
mode={reviewDialogState.mode}
|
}
|
||||||
memberName={reviewDialogState.memberName}
|
teamName={teamName}
|
||||||
taskId={reviewDialogState.taskId}
|
mode={reviewDialogState.mode}
|
||||||
initialFilePath={reviewDialogState.initialFilePath}
|
memberName={reviewDialogState.memberName}
|
||||||
taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions}
|
taskId={reviewDialogState.taskId}
|
||||||
projectPath={data.config.projectPath}
|
initialFilePath={reviewDialogState.initialFilePath}
|
||||||
onEditorAction={handleEditorAction}
|
taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions}
|
||||||
/>
|
projectPath={data.config.projectPath}
|
||||||
|
onEditorAction={handleEditorAction}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={setMessagesPanelMountPoint}
|
ref={setMessagesPanelMountPoint}
|
||||||
|
|
@ -3077,4 +3112,4 @@ export const TeamDetailView = ({
|
||||||
{renderBody()}
|
{renderBody()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
|
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
|
|
@ -45,8 +45,6 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
|
||||||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
|
||||||
import { TeamEmptyState } from './TeamEmptyState';
|
import { TeamEmptyState } from './TeamEmptyState';
|
||||||
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
||||||
import {
|
import {
|
||||||
|
|
@ -67,6 +65,13 @@ import type {
|
||||||
TeamSummaryMember,
|
TeamSummaryMember,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
const CreateTeamDialog = lazy(() =>
|
||||||
|
import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog }))
|
||||||
|
);
|
||||||
|
const LaunchTeamDialog = lazy(() =>
|
||||||
|
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
|
||||||
|
);
|
||||||
|
|
||||||
function generateUniqueName(sourceName: string, existingNames: string[]): string {
|
function generateUniqueName(sourceName: string, existingNames: string[]): string {
|
||||||
const base = sourceName.replace(/-\d+$/, '');
|
const base = sourceName.replace(/-\d+$/, '');
|
||||||
const existing = new Set(existingNames);
|
const existing = new Set(existingNames);
|
||||||
|
|
@ -233,7 +238,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TeamListView = (): React.JSX.Element => {
|
export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const electronMode = isElectronMode();
|
const electronMode = isElectronMode();
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
|
@ -731,36 +736,40 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createDialogElement = (
|
const createDialogElement = showCreateDialog && (
|
||||||
<CreateTeamDialog
|
<Suspense fallback={null}>
|
||||||
open={showCreateDialog}
|
<CreateTeamDialog
|
||||||
canCreate={canCreate}
|
open={showCreateDialog}
|
||||||
provisioningErrorsByTeam={provisioningErrorByTeam}
|
canCreate={canCreate}
|
||||||
clearProvisioningError={clearProvisioningError}
|
provisioningErrorsByTeam={provisioningErrorByTeam}
|
||||||
existingTeamNames={teams.map((t) => t.teamName)}
|
clearProvisioningError={clearProvisioningError}
|
||||||
provisioningTeamNames={provisioningTeamNames}
|
existingTeamNames={teams.map((t) => t.teamName)}
|
||||||
activeTeams={activeTeams}
|
provisioningTeamNames={provisioningTeamNames}
|
||||||
initialData={copyData ?? undefined}
|
activeTeams={activeTeams}
|
||||||
defaultProjectPath={currentProjectPath}
|
initialData={copyData ?? undefined}
|
||||||
onClose={handleCreateDialogClose}
|
defaultProjectPath={currentProjectPath}
|
||||||
onCreate={handleCreateSubmit}
|
onClose={handleCreateDialogClose}
|
||||||
onOpenTeam={openTeamTab}
|
onCreate={handleCreateSubmit}
|
||||||
/>
|
onOpenTeam={openTeamTab}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
const launchDialogElement = (
|
const launchDialogElement = launchDialogOpen && (
|
||||||
<LaunchTeamDialog
|
<Suspense fallback={null}>
|
||||||
mode="launch"
|
<LaunchTeamDialog
|
||||||
open={launchDialogOpen}
|
mode="launch"
|
||||||
teamName={launchDialogTeamName}
|
open={launchDialogOpen}
|
||||||
members={launchDialogMembers}
|
teamName={launchDialogTeamName}
|
||||||
defaultProjectPath={launchDialogDefaultPath}
|
members={launchDialogMembers}
|
||||||
provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null}
|
defaultProjectPath={launchDialogDefaultPath}
|
||||||
clearProvisioningError={clearProvisioningError}
|
provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null}
|
||||||
activeTeams={activeTeams}
|
clearProvisioningError={clearProvisioningError}
|
||||||
onClose={() => setLaunchDialogOpen(false)}
|
activeTeams={activeTeams}
|
||||||
onLaunch={handleLaunchSubmit}
|
onClose={() => setLaunchDialogOpen(false)}
|
||||||
/>
|
onLaunch={handleLaunchSubmit}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderHeader = (): React.JSX.Element => (
|
const renderHeader = (): React.JSX.Element => (
|
||||||
|
|
@ -1177,4 +1186,4 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type ReactNode, useState } from 'react';
|
import { memo, type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
|
|
@ -32,14 +32,14 @@ interface ActivityEntry {
|
||||||
kind: 'working' | 'reviewing';
|
kind: 'working' | 'reviewing';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActiveTasksBlock = ({
|
export const ActiveTasksBlock = memo(function ActiveTasksBlock({
|
||||||
members,
|
members,
|
||||||
tasks,
|
tasks,
|
||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
headerRight,
|
headerRight,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
}: ActiveTasksBlockProps): React.JSX.Element | null => {
|
}: ActiveTasksBlockProps): React.JSX.Element | null {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||||
const colorMap = buildMemberColorMap(members);
|
const colorMap = buildMemberColorMap(members);
|
||||||
|
|
@ -188,4 +188,4 @@ export const ActiveTasksBlock = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
|
|
@ -32,13 +34,13 @@ interface PendingRepliesBlockProps {
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PendingRepliesBlock = ({
|
export const PendingRepliesBlock = memo(function PendingRepliesBlock({
|
||||||
members,
|
members,
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
pendingCrossTeamReplies = [],
|
pendingCrossTeamReplies = [],
|
||||||
headerRight,
|
headerRight,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
}: PendingRepliesBlockProps): React.JSX.Element | null {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
|
||||||
const colorMap = buildMemberColorMap(members);
|
const colorMap = buildMemberColorMap(members);
|
||||||
|
|
@ -270,4 +272,4 @@ export const PendingRepliesBlock = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
|
@ -20,60 +20,62 @@ interface ReplyQuoteBlockProps {
|
||||||
/** Threshold (characters) above which the "more/less" toggle is shown. */
|
/** Threshold (characters) above which the "more/less" toggle is shown. */
|
||||||
const LONG_QUOTE_THRESHOLD = 200;
|
const LONG_QUOTE_THRESHOLD = 200;
|
||||||
|
|
||||||
export const ReplyQuoteBlock = ({
|
export const ReplyQuoteBlock = memo(
|
||||||
reply,
|
({
|
||||||
memberColor,
|
reply,
|
||||||
bodyMaxHeight = 'max-h-56',
|
memberColor,
|
||||||
replyTaskRefs,
|
bodyMaxHeight = 'max-h-56',
|
||||||
}: ReplyQuoteBlockProps): React.JSX.Element => {
|
replyTaskRefs,
|
||||||
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
|
}: ReplyQuoteBlockProps): React.JSX.Element => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]';
|
const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Quote block — styled like SendMessageDialog */}
|
{/* Quote block — styled like SendMessageDialog */}
|
||||||
<div className="relative overflow-hidden rounded-md border border-blue-400/20 bg-blue-100/40 py-2 pl-3 pr-2 dark:border-blue-500/20 dark:bg-blue-950/20">
|
<div className="relative overflow-hidden rounded-md border border-blue-400/20 bg-blue-100/40 py-2 pl-3 pr-2 dark:border-blue-500/20 dark:bg-blue-950/20">
|
||||||
{/* Decorative quotation mark */}
|
{/* Decorative quotation mark */}
|
||||||
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-600/[0.08] dark:text-blue-400/[0.08]">
|
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-600/[0.08] dark:text-blue-400/[0.08]">
|
||||||
“
|
“
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* "Replying to" + MemberBadge */}
|
{/* "Replying to" + MemberBadge */}
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
<span className="text-[10px] text-blue-600/60 dark:text-blue-300/60">Replying to</span>
|
<span className="text-[10px] text-blue-600/60 dark:text-blue-300/60">Replying to</span>
|
||||||
<MemberBadge name={reply.agentName} color={memberColor} size="sm" />
|
<MemberBadge name={reply.agentName} color={memberColor} size="sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quote text */}
|
||||||
|
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}>
|
||||||
|
<MarkdownViewer
|
||||||
|
content={linkifyTaskIdsInMarkdown(reply.originalText)}
|
||||||
|
bare
|
||||||
|
maxHeight={quoteMaxHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* More/less toggle */}
|
||||||
|
{isLong ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-0.5 text-[10px] text-blue-600/60 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded ? 'less' : 'more'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quote text */}
|
{/* Reply text */}
|
||||||
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}>
|
<MarkdownViewer
|
||||||
<MarkdownViewer
|
content={linkifyTaskIdsInMarkdown(reply.replyText, replyTaskRefs)}
|
||||||
content={linkifyTaskIdsInMarkdown(reply.originalText)}
|
maxHeight={bodyMaxHeight}
|
||||||
bare
|
copyable
|
||||||
maxHeight={quoteMaxHeight}
|
bare
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* More/less toggle */}
|
|
||||||
{isLong ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-0.5 text-[10px] text-blue-600/60 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
>
|
|
||||||
{expanded ? 'less' : 'more'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{/* Reply text */}
|
}
|
||||||
<MarkdownViewer
|
);
|
||||||
content={linkifyTaskIdsInMarkdown(reply.replyText, replyTaskRefs)}
|
|
||||||
maxHeight={bodyMaxHeight}
|
|
||||||
copyable
|
|
||||||
bare
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
import { arrayMove } from '@dnd-kit/sortable';
|
import { arrayMove } from '@dnd-kit/sortable';
|
||||||
|
|
@ -311,7 +311,7 @@ const SortableKanbanTaskCard = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KanbanBoard = ({
|
export const KanbanBoard = memo(function KanbanBoard({
|
||||||
tasks,
|
tasks,
|
||||||
teamName,
|
teamName,
|
||||||
kanbanState,
|
kanbanState,
|
||||||
|
|
@ -338,7 +338,7 @@ export const KanbanBoard = ({
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
deletedTaskCount,
|
deletedTaskCount,
|
||||||
onOpenTrash,
|
onOpenTrash,
|
||||||
}: KanbanBoardProps): React.JSX.Element => {
|
}: KanbanBoardProps): React.JSX.Element {
|
||||||
const boardRef = useRef<HTMLDivElement>(null);
|
const boardRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
|
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
|
||||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||||
|
|
@ -422,101 +422,119 @@ export const KanbanBoard = ({
|
||||||
[onColumnOrderChange, groupedOrdered]
|
[onColumnOrderChange, groupedOrdered]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderCards = (
|
const renderCards = useCallback(
|
||||||
columnId: KanbanColumnId,
|
(columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean): React.JSX.Element => {
|
||||||
columnTasks: TeamTask[],
|
const addHandler =
|
||||||
compact?: boolean
|
onAddTask && columnId === 'todo'
|
||||||
): React.JSX.Element => {
|
? () => onAddTask(false)
|
||||||
const addHandler =
|
: onAddTask && columnId === 'in_progress'
|
||||||
onAddTask && columnId === 'todo'
|
? () => onAddTask(true)
|
||||||
? () => onAddTask(false)
|
: undefined;
|
||||||
: onAddTask && columnId === 'in_progress'
|
|
||||||
? () => onAddTask(true)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const addButton = addHandler ? (
|
const addButton = addHandler ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addHandler}
|
onClick={addHandler}
|
||||||
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
|
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
|
||||||
>
|
>
|
||||||
<Plus size={13} />
|
<Plus size={13} />
|
||||||
Add task
|
Add task
|
||||||
</button>
|
</button>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (columnTasks.length === 0) {
|
if (columnTasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
addButton ?? (
|
addButton ?? (
|
||||||
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
|
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
|
||||||
No tasks
|
No tasks
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (enableTaskSorting) {
|
if (enableTaskSorting) {
|
||||||
const itemIds = columnTasks.map((t) => t.id);
|
const itemIds = columnTasks.map((t) => t.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{columnTasks.map((task) => (
|
||||||
|
<SortableKanbanTaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
columnId={columnId}
|
||||||
|
teamName={teamName}
|
||||||
|
kanbanState={kanbanState}
|
||||||
|
compact={compact}
|
||||||
|
taskMap={taskMap}
|
||||||
|
memberColorMap={memberColorMap}
|
||||||
|
onRequestReview={onRequestReview}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onRequestChanges={onRequestChanges}
|
||||||
|
onMoveBackToDone={onMoveBackToDone}
|
||||||
|
onStartTask={onStartTask}
|
||||||
|
onCompleteTask={onCompleteTask}
|
||||||
|
onCancelTask={onCancelTask}
|
||||||
|
onScrollToTask={onScrollToTask}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
onViewChanges={onViewChanges}
|
||||||
|
onDeleteTask={onDeleteTask}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
{addButton}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
{columnTasks.map((task) => (
|
||||||
{columnTasks.map((task) => (
|
<KanbanTaskCard
|
||||||
<SortableKanbanTaskCard
|
key={task.id}
|
||||||
key={task.id}
|
task={task}
|
||||||
task={task}
|
teamName={teamName}
|
||||||
columnId={columnId}
|
columnId={columnId}
|
||||||
teamName={teamName}
|
kanbanTaskState={kanbanState.tasks[task.id]}
|
||||||
kanbanState={kanbanState}
|
hasReviewers={hasReviewers}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
taskMap={taskMap}
|
taskMap={taskMap}
|
||||||
memberColorMap={memberColorMap}
|
memberColorMap={memberColorMap}
|
||||||
onRequestReview={onRequestReview}
|
onRequestReview={onRequestReview}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onRequestChanges={onRequestChanges}
|
onRequestChanges={onRequestChanges}
|
||||||
onMoveBackToDone={onMoveBackToDone}
|
onMoveBackToDone={onMoveBackToDone}
|
||||||
onStartTask={onStartTask}
|
onStartTask={onStartTask}
|
||||||
onCompleteTask={onCompleteTask}
|
onCompleteTask={onCompleteTask}
|
||||||
onCancelTask={onCancelTask}
|
onCancelTask={onCancelTask}
|
||||||
onScrollToTask={onScrollToTask}
|
onScrollToTask={onScrollToTask}
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
onViewChanges={onViewChanges}
|
onViewChanges={onViewChanges}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
|
||||||
{addButton}
|
{addButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return (
|
[
|
||||||
<>
|
enableTaskSorting,
|
||||||
{columnTasks.map((task) => (
|
hasReviewers,
|
||||||
<KanbanTaskCard
|
kanbanState,
|
||||||
key={task.id}
|
memberColorMap,
|
||||||
task={task}
|
onAddTask,
|
||||||
teamName={teamName}
|
onApprove,
|
||||||
columnId={columnId}
|
onCancelTask,
|
||||||
kanbanTaskState={kanbanState.tasks[task.id]}
|
onCompleteTask,
|
||||||
hasReviewers={hasReviewers}
|
onDeleteTask,
|
||||||
compact={compact}
|
onMoveBackToDone,
|
||||||
taskMap={taskMap}
|
onRequestChanges,
|
||||||
memberColorMap={memberColorMap}
|
onRequestReview,
|
||||||
onRequestReview={onRequestReview}
|
onScrollToTask,
|
||||||
onApprove={onApprove}
|
onStartTask,
|
||||||
onRequestChanges={onRequestChanges}
|
onTaskClick,
|
||||||
onMoveBackToDone={onMoveBackToDone}
|
onViewChanges,
|
||||||
onStartTask={onStartTask}
|
taskMap,
|
||||||
onCompleteTask={onCompleteTask}
|
teamName,
|
||||||
onCancelTask={onCancelTask}
|
]
|
||||||
onScrollToTask={onScrollToTask}
|
);
|
||||||
onTaskClick={onTaskClick}
|
|
||||||
onViewChanges={onViewChanges}
|
|
||||||
onDeleteTask={onDeleteTask}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{addButton}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibleColumns = useMemo(
|
const visibleColumns = useMemo(
|
||||||
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
|
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
|
||||||
|
|
@ -591,6 +609,29 @@ export const KanbanBoard = ({
|
||||||
[scheduleScrollRestore, viewMode]
|
[scheduleScrollRestore, viewMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const gridColumns = useMemo(
|
||||||
|
() =>
|
||||||
|
visibleColumns.map((column) => {
|
||||||
|
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||||
|
const accent = COLUMN_ACCENTS[column.id];
|
||||||
|
return {
|
||||||
|
id: column.id,
|
||||||
|
title: column.title,
|
||||||
|
count: columnTasks.length,
|
||||||
|
icon: accent.icon,
|
||||||
|
headerBg: accent.headerBg,
|
||||||
|
bodyBg: accent.bodyBg,
|
||||||
|
content: renderCards(column.id, columnTasks),
|
||||||
|
showAddButton: columnSupportsAddButton(column.id, onAddTask),
|
||||||
|
skeletonCards: columnTasks.map((task) => ({
|
||||||
|
key: task.id,
|
||||||
|
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers]
|
||||||
|
);
|
||||||
|
|
||||||
const boardContent = (
|
const boardContent = (
|
||||||
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
|
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
|
||||||
<div
|
<div
|
||||||
|
|
@ -682,25 +723,7 @@ export const KanbanBoard = ({
|
||||||
primaryColumnId={primaryVisibleColumnId}
|
primaryColumnId={primaryVisibleColumnId}
|
||||||
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
|
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
|
||||||
skeletonDelayMs={gridSkeletonDelayMs}
|
skeletonDelayMs={gridSkeletonDelayMs}
|
||||||
columns={visibleColumns.map((column) => {
|
columns={gridColumns}
|
||||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
|
||||||
const accent = COLUMN_ACCENTS[column.id];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: column.id,
|
|
||||||
title: column.title,
|
|
||||||
count: columnTasks.length,
|
|
||||||
icon: accent.icon,
|
|
||||||
headerBg: accent.headerBg,
|
|
||||||
bodyBg: accent.bodyBg,
|
|
||||||
content: renderCards(column.id, columnTasks),
|
|
||||||
showAddButton: columnSupportsAddButton(column.id, onAddTask),
|
|
||||||
skeletonCards: columnTasks.map((task) => ({
|
|
||||||
key: task.id,
|
|
||||||
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden px-1 pb-6 pr-4 pt-2">
|
<div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden px-1 pb-6 pr-4 pt-2">
|
||||||
|
|
@ -752,4 +775,4 @@ export const KanbanBoard = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return boardContent;
|
return boardContent;
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */
|
/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
|
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
|
||||||
|
|
||||||
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
|
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
|
||||||
|
|
@ -387,74 +387,76 @@ const LoadedKanbanGridLayout = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KanbanGridLayout = ({
|
export const KanbanGridLayout = memo(
|
||||||
columns,
|
({
|
||||||
allColumnIds,
|
columns,
|
||||||
primaryColumnId,
|
allColumnIds,
|
||||||
onPrimaryColumnWidthChange,
|
primaryColumnId,
|
||||||
skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
|
onPrimaryColumnWidthChange,
|
||||||
}: KanbanGridLayoutProps): React.JSX.Element => {
|
skeletonDelayMs = SKELETON_HIDE_DELAY_MS,
|
||||||
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
}: KanbanGridLayoutProps): React.JSX.Element => {
|
||||||
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
|
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
||||||
scopeKey: GRID_SCOPE_KEY,
|
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
|
||||||
allItemIds: allColumnIds,
|
scopeKey: GRID_SCOPE_KEY,
|
||||||
visibleItemIds: visibleColumnIds,
|
allItemIds: allColumnIds,
|
||||||
cols: GRID_COLS,
|
visibleItemIds: visibleColumnIds,
|
||||||
repository: browserGridLayoutRepository,
|
cols: GRID_COLS,
|
||||||
buildDefaultItems,
|
repository: browserGridLayoutRepository,
|
||||||
});
|
buildDefaultItems,
|
||||||
const [showResolvedLayout, setShowResolvedLayout] = useState(false);
|
});
|
||||||
|
const [showResolvedLayout, setShowResolvedLayout] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showResolvedLayout) return;
|
if (showResolvedLayout) return;
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
setShowResolvedLayout(true);
|
setShowResolvedLayout(true);
|
||||||
}, skeletonDelayMs);
|
}, skeletonDelayMs);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [showResolvedLayout, skeletonDelayMs]);
|
}, [showResolvedLayout, skeletonDelayMs]);
|
||||||
|
|
||||||
const applyReactGridLayout = useCallback(
|
const applyReactGridLayout = useCallback(
|
||||||
(layout: Layout, options?: { persist?: boolean }) => {
|
(layout: Layout, options?: { persist?: boolean }) => {
|
||||||
if (options?.persist) {
|
if (options?.persist) {
|
||||||
applyVisibleItems(fromReactGridLayout(layout), options);
|
applyVisibleItems(fromReactGridLayout(layout), options);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[applyVisibleItems]
|
[applyVisibleItems]
|
||||||
);
|
);
|
||||||
const showSkeletonOverlay = !showResolvedLayout || !isLoaded;
|
const showSkeletonOverlay = !showResolvedLayout || !isLoaded;
|
||||||
|
|
||||||
const gridKey = visibleItems.map((item) => item.id).join('|');
|
const gridKey = visibleItems.map((item) => item.id).join('|');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-w-0 max-w-full">
|
<div className="relative min-w-0 max-w-full">
|
||||||
<LoadedKanbanGridLayout
|
<LoadedKanbanGridLayout
|
||||||
key={gridKey}
|
key={gridKey}
|
||||||
columns={columns}
|
|
||||||
visibleItems={visibleItems}
|
|
||||||
onPersistLayout={applyReactGridLayout}
|
|
||||||
primaryColumnId={primaryColumnId}
|
|
||||||
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
|
|
||||||
className={cn(
|
|
||||||
'transition-opacity duration-150',
|
|
||||||
showSkeletonOverlay ? 'pointer-events-none opacity-0' : 'opacity-100'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{showSkeletonOverlay ? (
|
|
||||||
<LoadingKanbanGridLayout
|
|
||||||
columns={columns}
|
columns={columns}
|
||||||
visibleItems={visibleItems}
|
visibleItems={visibleItems}
|
||||||
|
onPersistLayout={applyReactGridLayout}
|
||||||
primaryColumnId={primaryColumnId}
|
primaryColumnId={primaryColumnId}
|
||||||
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
|
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
|
||||||
className="pointer-events-none absolute inset-0 z-10"
|
className={cn(
|
||||||
|
'transition-opacity duration-150',
|
||||||
|
showSkeletonOverlay ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
{showSkeletonOverlay ? (
|
||||||
</div>
|
<LoadingKanbanGridLayout
|
||||||
);
|
columns={columns}
|
||||||
};
|
visibleItems={visibleItems}
|
||||||
|
primaryColumnId={primaryColumnId}
|
||||||
|
onPrimaryColumnWidthChange={onPrimaryColumnWidthChange}
|
||||||
|
className="pointer-events-none absolute inset-0 z-10"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH };
|
export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH };
|
||||||
/* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */
|
/* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
|
|
@ -15,42 +17,44 @@ interface CurrentTaskIndicatorProps {
|
||||||
* Inline indicator showing a spinning loader + "working on" + task label button.
|
* Inline indicator showing a spinning loader + "working on" + task label button.
|
||||||
* Shared between MemberCard and MemberHoverCard.
|
* Shared between MemberCard and MemberHoverCard.
|
||||||
*/
|
*/
|
||||||
export const CurrentTaskIndicator = ({
|
export const CurrentTaskIndicator = memo(
|
||||||
task,
|
({
|
||||||
borderColor,
|
task,
|
||||||
maxSubjectLength,
|
borderColor,
|
||||||
activityLabel = 'working on',
|
maxSubjectLength,
|
||||||
onOpenTask,
|
activityLabel = 'working on',
|
||||||
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
onOpenTask,
|
||||||
const subjectText =
|
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
||||||
typeof maxSubjectLength === 'number' &&
|
const subjectText =
|
||||||
maxSubjectLength > 0 &&
|
typeof maxSubjectLength === 'number' &&
|
||||||
task.subject.length > maxSubjectLength
|
maxSubjectLength > 0 &&
|
||||||
? `${task.subject.slice(0, maxSubjectLength)}…`
|
task.subject.length > maxSubjectLength
|
||||||
: task.subject;
|
? `${task.subject.slice(0, maxSubjectLength)}…`
|
||||||
|
: task.subject;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
|
<SyncedLoader2 className="size-3 shrink-0" style={{ color: borderColor }} />
|
||||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||||
title="Open task"
|
title="Open task"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
|
||||||
onOpenTask?.();
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenTask?.();
|
onOpenTask?.();
|
||||||
}
|
}}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
||||||
{formatTaskDisplayLabel(task)} {subjectText}
|
e.preventDefault();
|
||||||
</button>
|
e.stopPropagation();
|
||||||
</div>
|
onOpenTask?.();
|
||||||
);
|
}
|
||||||
};
|
}}
|
||||||
|
>
|
||||||
|
{formatTaskDisplayLabel(task)} {subjectText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
||||||
|
|
@ -91,7 +91,7 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberCard = ({
|
export const MemberCard = memo(function MemberCard({
|
||||||
member,
|
member,
|
||||||
memberColor,
|
memberColor,
|
||||||
runtimeSummary,
|
runtimeSummary,
|
||||||
|
|
@ -119,7 +119,7 @@ export const MemberCard = ({
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onRestartMember,
|
onRestartMember,
|
||||||
onSkipMemberForLaunch,
|
onSkipMemberForLaunch,
|
||||||
}: MemberCardProps): React.JSX.Element => {
|
}: MemberCardProps): React.JSX.Element {
|
||||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||||
// const teamName = useStore((s) => s.selectedTeamName);
|
// const teamName = useStore((s) => s.selectedTeamName);
|
||||||
// const leadContext = useStore((s) =>
|
// const leadContext = useStore((s) =>
|
||||||
|
|
@ -711,4 +711,4 @@ export const MemberCard = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card';
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card';
|
||||||
import {
|
import {
|
||||||
|
|
@ -57,13 +59,13 @@ interface MemberHoverCardProps {
|
||||||
* Reads member data from the team snapshot + resolved member selectors.
|
* Reads member data from the team snapshot + resolved member selectors.
|
||||||
* Falls back to a simple wrapper when member data is unavailable.
|
* Falls back to a simple wrapper when member data is unavailable.
|
||||||
*/
|
*/
|
||||||
export const MemberHoverCard = ({
|
export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
teamName,
|
teamName,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
children,
|
children,
|
||||||
}: MemberHoverCardProps): React.JSX.Element => {
|
}: MemberHoverCardProps): React.JSX.Element {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||||
const effectiveTeamName = teamName ?? selectedTeamName;
|
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||||
|
|
@ -289,4 +291,4 @@ export const MemberHoverCard = ({
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
MemberLaunchState,
|
||||||
|
MemberSpawnLivenessSource,
|
||||||
|
MemberSpawnStatus,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
|
|
@ -255,6 +258,115 @@ function areMemberListPropsEqual(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-member row wrapper — creates stable callbacks so MemberCard memo holds
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MemberCardRowProps {
|
||||||
|
member: ResolvedTeamMember;
|
||||||
|
isRemoved: boolean;
|
||||||
|
memberColor: string;
|
||||||
|
currentTask: TeamTaskWithKanban | null;
|
||||||
|
reviewTask: TeamTaskWithKanban | null;
|
||||||
|
awaitingReply: boolean;
|
||||||
|
taskCounts?: TaskStatusCounts | null;
|
||||||
|
runtimeSummary?: string;
|
||||||
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
runtimeRunId?: string | null;
|
||||||
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
spawnEntry?: MemberSpawnStatusEntry;
|
||||||
|
spawnError?: string;
|
||||||
|
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||||
|
spawnLaunchState?: MemberLaunchState;
|
||||||
|
spawnRuntimeAlive?: boolean;
|
||||||
|
isTeamAlive?: boolean;
|
||||||
|
isTeamProvisioning?: boolean;
|
||||||
|
leadActivity?: LeadActivityState;
|
||||||
|
isLaunchSettling?: boolean;
|
||||||
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
|
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||||
|
onRestartMember?: (memberName: string) => Promise<void> | void;
|
||||||
|
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemberCardRow = memo(function MemberCardRow({
|
||||||
|
member,
|
||||||
|
isRemoved,
|
||||||
|
memberColor,
|
||||||
|
currentTask,
|
||||||
|
reviewTask,
|
||||||
|
awaitingReply,
|
||||||
|
taskCounts,
|
||||||
|
runtimeSummary,
|
||||||
|
runtimeEntry,
|
||||||
|
runtimeRunId,
|
||||||
|
spawnStatus,
|
||||||
|
spawnEntry,
|
||||||
|
spawnError,
|
||||||
|
spawnLivenessSource,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive,
|
||||||
|
isTeamAlive,
|
||||||
|
isTeamProvisioning,
|
||||||
|
leadActivity,
|
||||||
|
isLaunchSettling,
|
||||||
|
onOpenTask,
|
||||||
|
onMemberClick,
|
||||||
|
onSendMessage,
|
||||||
|
onAssignTask,
|
||||||
|
onRestartMember,
|
||||||
|
onSkipMemberForLaunch,
|
||||||
|
}: MemberCardRowProps): React.JSX.Element {
|
||||||
|
const currentTaskId = currentTask?.id;
|
||||||
|
const reviewTaskId = reviewTask?.id;
|
||||||
|
|
||||||
|
const handleOpenTask = useCallback(() => {
|
||||||
|
if (currentTaskId) onOpenTask?.(currentTaskId);
|
||||||
|
}, [onOpenTask, currentTaskId]);
|
||||||
|
|
||||||
|
const handleOpenReviewTask = useCallback(() => {
|
||||||
|
if (reviewTaskId) onOpenTask?.(reviewTaskId);
|
||||||
|
}, [onOpenTask, reviewTaskId]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => onMemberClick?.(member), [onMemberClick, member]);
|
||||||
|
const handleSendMessage = useCallback(() => onSendMessage?.(member), [onSendMessage, member]);
|
||||||
|
const handleAssignTask = useCallback(() => onAssignTask?.(member), [onAssignTask, member]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MemberCard
|
||||||
|
member={member}
|
||||||
|
memberColor={memberColor}
|
||||||
|
taskCounts={taskCounts}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
leadActivity={isLeadMember(member) ? leadActivity : undefined}
|
||||||
|
currentTask={currentTask}
|
||||||
|
reviewTask={reviewTask}
|
||||||
|
isAwaitingReply={awaitingReply}
|
||||||
|
isRemoved={isRemoved}
|
||||||
|
runtimeSummary={runtimeSummary}
|
||||||
|
runtimeEntry={runtimeEntry}
|
||||||
|
runtimeRunId={runtimeRunId}
|
||||||
|
spawnStatus={spawnStatus}
|
||||||
|
spawnEntry={spawnEntry}
|
||||||
|
spawnError={spawnError}
|
||||||
|
spawnLivenessSource={spawnLivenessSource}
|
||||||
|
spawnLaunchState={spawnLaunchState}
|
||||||
|
spawnRuntimeAlive={spawnRuntimeAlive}
|
||||||
|
isLaunchSettling={isLaunchSettling}
|
||||||
|
onOpenTask={currentTask ? handleOpenTask : undefined}
|
||||||
|
onOpenReviewTask={reviewTask ? handleOpenReviewTask : undefined}
|
||||||
|
onClick={handleClick}
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
onAssignTask={handleAssignTask}
|
||||||
|
onRestartMember={onRestartMember}
|
||||||
|
onSkipMemberForLaunch={onSkipMemberForLaunch}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const MemberList = memo(function MemberList({
|
export const MemberList = memo(function MemberList({
|
||||||
members,
|
members,
|
||||||
memberTaskCounts,
|
memberTaskCounts,
|
||||||
|
|
@ -340,63 +452,89 @@ export const MemberList = memo(function MemberList({
|
||||||
return result;
|
return result;
|
||||||
}, [taskMap]);
|
}, [taskMap]);
|
||||||
|
|
||||||
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
|
|
||||||
const currentTask =
|
|
||||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
|
||||||
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
|
||||||
const reviewTask =
|
|
||||||
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
|
|
||||||
const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]);
|
|
||||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
|
||||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
|
||||||
return (
|
|
||||||
<MemberCard
|
|
||||||
key={member.name}
|
|
||||||
member={member}
|
|
||||||
memberColor={colorMap.get(member.name) ?? 'blue'}
|
|
||||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
|
||||||
isTeamAlive={isTeamAlive}
|
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
|
||||||
leadActivity={isLeadMember(member) ? leadActivity : undefined}
|
|
||||||
currentTask={isRemoved ? null : currentTask}
|
|
||||||
reviewTask={isRemoved ? null : reviewTask}
|
|
||||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
|
||||||
isRemoved={isRemoved}
|
|
||||||
runtimeSummary={buildRuntimeSummary(
|
|
||||||
member,
|
|
||||||
isRemoved ? undefined : spawnEntry,
|
|
||||||
isRemoved ? undefined : runtimeEntry
|
|
||||||
)}
|
|
||||||
runtimeEntry={isRemoved ? undefined : runtimeEntry}
|
|
||||||
runtimeRunId={isRemoved ? undefined : runtimeRunId}
|
|
||||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
|
||||||
spawnEntry={isRemoved ? undefined : spawnEntry}
|
|
||||||
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
|
|
||||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
|
||||||
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
|
||||||
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
|
|
||||||
isLaunchSettling={isRemoved ? false : isLaunchSettling}
|
|
||||||
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask.id) : undefined}
|
|
||||||
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask.id) : undefined}
|
|
||||||
onClick={() => onMemberClick?.(member)}
|
|
||||||
onSendMessage={() => onSendMessage?.(member)}
|
|
||||||
onAssignTask={() => onAssignTask?.(member)}
|
|
||||||
onRestartMember={isRemoved ? undefined : onRestartMember}
|
|
||||||
onSkipMemberForLaunch={isRemoved ? undefined : onSkipMemberForLaunch}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex flex-col gap-1">
|
<div ref={containerRef} className="flex flex-col gap-1">
|
||||||
<div className={gridClass}>{activeMembers.map((member) => renderCard(member, false))}</div>
|
<div className={gridClass}>
|
||||||
|
{activeMembers.map((member) => {
|
||||||
|
const currentTask =
|
||||||
|
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||||
|
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
||||||
|
const reviewTask =
|
||||||
|
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
|
||||||
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
|
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||||
|
return (
|
||||||
|
<MemberCardRow
|
||||||
|
key={member.name}
|
||||||
|
member={member}
|
||||||
|
isRemoved={false}
|
||||||
|
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||||
|
currentTask={currentTask}
|
||||||
|
reviewTask={reviewTask}
|
||||||
|
awaitingReply={
|
||||||
|
isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name])
|
||||||
|
}
|
||||||
|
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||||
|
runtimeSummary={buildRuntimeSummary(member, spawnEntry, runtimeEntry)}
|
||||||
|
runtimeEntry={runtimeEntry}
|
||||||
|
runtimeRunId={runtimeRunId}
|
||||||
|
spawnStatus={spawnEntry?.status}
|
||||||
|
spawnEntry={spawnEntry}
|
||||||
|
spawnError={spawnEntry?.error ?? spawnEntry?.hardFailureReason}
|
||||||
|
spawnLivenessSource={spawnEntry?.livenessSource}
|
||||||
|
spawnLaunchState={spawnEntry?.launchState}
|
||||||
|
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
leadActivity={leadActivity}
|
||||||
|
isLaunchSettling={isLaunchSettling}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
|
onMemberClick={onMemberClick}
|
||||||
|
onSendMessage={onSendMessage}
|
||||||
|
onAssignTask={onAssignTask}
|
||||||
|
onRestartMember={onRestartMember}
|
||||||
|
onSkipMemberForLaunch={onSkipMemberForLaunch}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
{removedMembers.length > 0 && (
|
{removedMembers.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
Removed ({removedMembers.length})
|
Removed ({removedMembers.length})
|
||||||
</div>
|
</div>
|
||||||
<div className={gridClass}>
|
<div className={gridClass}>
|
||||||
{removedMembers.map((member) => renderCard(member, true))}
|
{removedMembers.map((member) => (
|
||||||
|
<MemberCardRow
|
||||||
|
key={member.name}
|
||||||
|
member={member}
|
||||||
|
isRemoved={true}
|
||||||
|
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||||
|
currentTask={null}
|
||||||
|
reviewTask={null}
|
||||||
|
awaitingReply={false}
|
||||||
|
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||||
|
runtimeSummary={buildRuntimeSummary(member, undefined, undefined)}
|
||||||
|
runtimeEntry={undefined}
|
||||||
|
runtimeRunId={undefined}
|
||||||
|
spawnStatus={undefined}
|
||||||
|
spawnEntry={undefined}
|
||||||
|
spawnError={undefined}
|
||||||
|
spawnLivenessSource={undefined}
|
||||||
|
spawnLaunchState={undefined}
|
||||||
|
spawnRuntimeAlive={undefined}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
leadActivity={leadActivity}
|
||||||
|
isLaunchSettling={false}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
|
onMemberClick={onMemberClick}
|
||||||
|
onSendMessage={onSendMessage}
|
||||||
|
onAssignTask={onAssignTask}
|
||||||
|
onRestartMember={undefined}
|
||||||
|
onSkipMemberForLaunch={undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
|
import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
||||||
|
|
@ -8,21 +10,20 @@ interface MemberPresenceDotProps {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberPresenceDot = ({
|
export const MemberPresenceDot = memo(
|
||||||
className,
|
({ className, label }: MemberPresenceDotProps): React.JSX.Element => {
|
||||||
label,
|
const shouldSyncPulse = className?.includes('animate-pulse') === true;
|
||||||
}: MemberPresenceDotProps): React.JSX.Element => {
|
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
|
||||||
const shouldSyncPulse = className?.includes('animate-pulse') === true;
|
|
||||||
const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]',
|
'absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-[var(--color-surface)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={syncedPulseStyle}
|
style={syncedPulseStyle}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
|
|
@ -18,13 +18,15 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog';
|
|
||||||
|
|
||||||
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
||||||
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
|
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
|
||||||
import { ScheduleRunRow } from './ScheduleRunRow';
|
import { ScheduleRunRow } from './ScheduleRunRow';
|
||||||
import { ScheduleStatusBadge } from './ScheduleStatusBadge';
|
import { ScheduleStatusBadge } from './ScheduleStatusBadge';
|
||||||
|
|
||||||
|
const LaunchTeamDialog = lazy(() =>
|
||||||
|
import('../dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
|
||||||
|
);
|
||||||
|
|
||||||
import type { Schedule, ScheduleRun } from '@shared/types';
|
import type { Schedule, ScheduleRun } from '@shared/types';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -305,13 +307,17 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
<LaunchTeamDialog
|
{dialogOpen && (
|
||||||
mode="schedule"
|
<Suspense fallback={null}>
|
||||||
open={dialogOpen}
|
<LaunchTeamDialog
|
||||||
teamName={teamName}
|
mode="schedule"
|
||||||
schedule={editingSchedule}
|
open={dialogOpen}
|
||||||
onClose={handleClose}
|
teamName={teamName}
|
||||||
/>
|
schedule={editingSchedule}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
KANBAN_COLUMN_DISPLAY,
|
KANBAN_COLUMN_DISPLAY,
|
||||||
REVIEW_STATE_DISPLAY,
|
REVIEW_STATE_DISPLAY,
|
||||||
|
|
@ -12,7 +14,7 @@ interface TaskRowProps {
|
||||||
task: TeamTaskWithKanban;
|
task: TeamTaskWithKanban;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
|
||||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||||
const kanbanColumn = getTaskKanbanColumn(task);
|
const kanbanColumn = getTaskKanbanColumn(task);
|
||||||
|
|
@ -62,4 +64,4 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||||
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
|
import {
|
||||||
|
TeamMcpConfigBuilder,
|
||||||
|
clearResolvedNodePathForTests,
|
||||||
|
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||||
|
|
||||||
describe('TeamMcpConfigBuilder', () => {
|
describe('TeamMcpConfigBuilder', () => {
|
||||||
const createdPaths: string[] = [];
|
const createdPaths: string[] = [];
|
||||||
|
|
@ -93,7 +96,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
entry: string
|
entry: string
|
||||||
): void {
|
): void {
|
||||||
expect(server?.args).toEqual([entry]);
|
expect(server?.args).toEqual([entry]);
|
||||||
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/);
|
expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectNodeTsxSourceEntry(
|
function expectNodeTsxSourceEntry(
|
||||||
|
|
@ -102,7 +105,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
sourceEntry: string
|
sourceEntry: string
|
||||||
): void {
|
): void {
|
||||||
expect(server?.args).toEqual([tsxCli, sourceEntry]);
|
expect(server?.args).toEqual([tsxCli, sourceEntry]);
|
||||||
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/);
|
expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBuiltWorkspaceEntry(): string {
|
function getBuiltWorkspaceEntry(): string {
|
||||||
|
|
@ -165,6 +168,7 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clearResolvedNodePathForTests();
|
||||||
originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||||
tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-'));
|
tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-'));
|
||||||
createdDirs.push(tempAppData);
|
createdDirs.push(tempAppData);
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@ const hoisted = vi.hoisted(() => {
|
||||||
error.code = 'ENOENT';
|
error.code = 'ENOENT';
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
const size = Buffer.byteLength(data, 'utf8');
|
||||||
return {
|
return {
|
||||||
isFile: () => true,
|
isFile: () => true,
|
||||||
size: Buffer.byteLength(data, 'utf8'),
|
size,
|
||||||
mode: 0o100644,
|
mode: 0o100644,
|
||||||
dev: 1,
|
dev: 1,
|
||||||
ino: 1,
|
ino: 1,
|
||||||
|
|
@ -63,22 +64,20 @@ const hoisted = vi.hoisted(() => {
|
||||||
files.set(sentMessagesPath, JSON.stringify(rows));
|
files.set(sentMessagesPath, JSON.stringify(rows));
|
||||||
return message;
|
return message;
|
||||||
}),
|
}),
|
||||||
sendInboxMessage: vi.fn(
|
sendInboxMessage: vi.fn((teamName: string, message: Record<string, unknown>) => {
|
||||||
(teamName: string, message: Record<string, unknown>) => {
|
const member =
|
||||||
const member =
|
typeof message.member === 'string'
|
||||||
typeof message.member === 'string'
|
? message.member
|
||||||
? message.member
|
: typeof message.to === 'string'
|
||||||
: typeof message.to === 'string'
|
? message.to
|
||||||
? message.to
|
: 'unknown';
|
||||||
: 'unknown';
|
const p = `/mock/teams/${teamName}/inboxes/${member}.json`;
|
||||||
const p = `/mock/teams/${teamName}/inboxes/${member}.json`;
|
const current = files.get(p);
|
||||||
const current = files.get(p);
|
const rows = current ? (JSON.parse(current) as unknown[]) : [];
|
||||||
const rows = current ? (JSON.parse(current) as unknown[]) : [];
|
rows.push(message);
|
||||||
rows.push(message);
|
files.set(p, JSON.stringify(rows));
|
||||||
files.set(p, JSON.stringify(rows));
|
return { deliveredToInbox: true, messageId: 'mock-id', message };
|
||||||
return { deliveredToInbox: true, messageId: 'mock-id', message };
|
}),
|
||||||
}
|
|
||||||
),
|
|
||||||
setAtomicWriteShouldFail: (next: boolean) => {
|
setAtomicWriteShouldFail: (next: boolean) => {
|
||||||
atomicWriteShouldFail = next;
|
atomicWriteShouldFail = next;
|
||||||
},
|
},
|
||||||
|
|
@ -419,7 +418,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||||
expect(payload).toContain('Source: system_notification');
|
expect(payload).toContain('Source: system_notification');
|
||||||
expect(payload).toContain('summary looks like \\"Comment on #...\\"');
|
expect(payload).toContain('summary looks like \\"Comment on #...\\"');
|
||||||
expect(payload).toContain('reply via task_add_comment only when you have a substantive board update');
|
expect(payload).toContain(
|
||||||
|
'reply via task_add_comment only when you have a substantive board update'
|
||||||
|
);
|
||||||
expect(payload).toContain('Do NOT post acknowledgement-only task comments');
|
expect(payload).toContain('Do NOT post acknowledgement-only task comments');
|
||||||
|
|
||||||
(service as any).handleStreamJsonMessage(run, {
|
(service as any).handleStreamJsonMessage(run, {
|
||||||
|
|
@ -540,9 +541,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
runId: 'run-old',
|
runId: 'run-old',
|
||||||
});
|
});
|
||||||
const inboxDeferred = createDeferred<typeof inboxMessages>();
|
const inboxDeferred = createDeferred<typeof inboxMessages>();
|
||||||
const inboxReader = (service as unknown as {
|
const inboxReader = (
|
||||||
inboxReader: { getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages> };
|
service as unknown as {
|
||||||
}).inboxReader;
|
inboxReader: {
|
||||||
|
getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).inboxReader;
|
||||||
const inboxSpy = vi
|
const inboxSpy = vi
|
||||||
.spyOn(inboxReader, 'getMessagesFor')
|
.spyOn(inboxReader, 'getMessagesFor')
|
||||||
.mockImplementationOnce(async () => await inboxDeferred.promise)
|
.mockImplementationOnce(async () => await inboxDeferred.promise)
|
||||||
|
|
@ -586,14 +591,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
|
|
||||||
const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' });
|
const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' });
|
||||||
const inboxDeferred = createDeferred<[typeof permissionMessage]>();
|
const inboxDeferred = createDeferred<[typeof permissionMessage]>();
|
||||||
const inboxReader = (service as unknown as {
|
const inboxReader = (
|
||||||
inboxReader: {
|
service as unknown as {
|
||||||
getMessagesFor: (
|
inboxReader: {
|
||||||
team: string,
|
getMessagesFor: (team: string, member: string) => Promise<[typeof permissionMessage]>;
|
||||||
member: string
|
};
|
||||||
) => Promise<[typeof permissionMessage]>;
|
}
|
||||||
};
|
).inboxReader;
|
||||||
}).inboxReader;
|
|
||||||
const inboxSpy = vi
|
const inboxSpy = vi
|
||||||
.spyOn(inboxReader, 'getMessagesFor')
|
.spyOn(inboxReader, 'getMessagesFor')
|
||||||
.mockImplementationOnce(async () => await inboxDeferred.promise)
|
.mockImplementationOnce(async () => await inboxDeferred.promise)
|
||||||
|
|
@ -702,7 +706,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||||
expect(payload).toContain('Source: cross_team');
|
expect(payload).toContain('Source: cross_team');
|
||||||
expect(payload).toContain('Cross-team conversationId: conv-explicit');
|
expect(payload).toContain('Cross-team conversationId: conv-explicit');
|
||||||
expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"');
|
expect(payload).toContain(
|
||||||
|
'Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"'
|
||||||
|
);
|
||||||
expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"');
|
expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"');
|
||||||
expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"');
|
expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"');
|
||||||
|
|
||||||
|
|
@ -953,7 +959,11 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
attachAliveRun(service, teamName);
|
attachAliveRun(service, teamName);
|
||||||
|
|
||||||
const run = (service as unknown as { runs: Map<string, unknown> }).runs.get('run-1') as {
|
const run = (service as unknown as { runs: Map<string, unknown> }).runs.get('run-1') as {
|
||||||
silentUserDmForward: { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' } | null;
|
silentUserDmForward: {
|
||||||
|
target: string;
|
||||||
|
startedAt: string;
|
||||||
|
mode: 'user_dm' | 'member_inbox_relay';
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
run.silentUserDmForward = {
|
run.silentUserDmForward = {
|
||||||
target: 'alice',
|
target: 'alice',
|
||||||
|
|
@ -1120,9 +1130,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
runId: 'run-old',
|
runId: 'run-old',
|
||||||
});
|
});
|
||||||
const inboxDeferred = createDeferred<typeof inboxMessages>();
|
const inboxDeferred = createDeferred<typeof inboxMessages>();
|
||||||
const inboxReader = (service as unknown as {
|
const inboxReader = (
|
||||||
inboxReader: { getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages> };
|
service as unknown as {
|
||||||
}).inboxReader;
|
inboxReader: {
|
||||||
|
getMessagesFor: (team: string, member: string) => Promise<typeof inboxMessages>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).inboxReader;
|
||||||
const inboxSpy = vi
|
const inboxSpy = vi
|
||||||
.spyOn(inboxReader, 'getMessagesFor')
|
.spyOn(inboxReader, 'getMessagesFor')
|
||||||
.mockImplementationOnce(async () => await inboxDeferred.promise)
|
.mockImplementationOnce(async () => await inboxDeferred.promise)
|
||||||
|
|
@ -1332,11 +1346,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
|
|
||||||
await (service as any).markInboxMessagesRead(teamName, 'alice', [
|
await (service as any).markInboxMessagesRead(teamName, 'alice', [
|
||||||
{
|
{
|
||||||
messageId: buildLegacyInboxMessageId(
|
messageId: buildLegacyInboxMessageId(legacyRow.from, legacyRow.timestamp, legacyRow.text),
|
||||||
legacyRow.from,
|
|
||||||
legacyRow.timestamp,
|
|
||||||
legacyRow.text
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1732,9 +1742,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
|
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const rows = JSON.parse(
|
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
|
||||||
);
|
|
||||||
expect(rows[0].read).toBe(true);
|
expect(rows[0].read).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1780,9 +1788,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
failed: 0,
|
failed: 0,
|
||||||
lastDelivery: { delivered: true, responsePending: true },
|
lastDelivery: { delivered: true, responsePending: true },
|
||||||
});
|
});
|
||||||
const rows = JSON.parse(
|
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
|
||||||
);
|
|
||||||
expect(rows[0].read).toBe(false);
|
expect(rows[0].read).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1914,9 +1920,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
teamName,
|
teamName,
|
||||||
expect.objectContaining({ messageId: 'opencode-terminal-new' })
|
expect.objectContaining({ messageId: 'opencode-terminal-new' })
|
||||||
);
|
);
|
||||||
const rows = JSON.parse(
|
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
|
||||||
);
|
|
||||||
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]);
|
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2000,9 +2004,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
'opencode_attachments_not_supported_for_secondary_runtime'
|
'opencode_attachments_not_supported_for_secondary_runtime'
|
||||||
);
|
);
|
||||||
vi.mocked(console.warn).mockClear();
|
vi.mocked(console.warn).mockClear();
|
||||||
const rows = JSON.parse(
|
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
|
||||||
);
|
|
||||||
expect(rows[0].read).toBe(false);
|
expect(rows[0].read).toBe(false);
|
||||||
expect(records[0]).toMatchObject({
|
expect(records[0]).toMatchObject({
|
||||||
inboxMessageId: 'opencode-attachment-1',
|
inboxMessageId: 'opencode-attachment-1',
|
||||||
|
|
@ -2027,7 +2029,10 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack');
|
const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(
|
||||||
|
teamName,
|
||||||
|
'jack'
|
||||||
|
);
|
||||||
expect(identity.ok).toBe(true);
|
expect(identity.ok).toBe(true);
|
||||||
const laneId = identity.laneId;
|
const laneId = identity.laneId;
|
||||||
const records: any[] = [];
|
const records: any[] = [];
|
||||||
|
|
@ -2048,7 +2053,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
return record;
|
return record;
|
||||||
}),
|
}),
|
||||||
markAcceptanceUnknown: vi.fn(
|
markAcceptanceUnknown: vi.fn(
|
||||||
async (input: { id: string; reason: string; nextAttemptAt: string; markedAt: string }) => {
|
async (input: {
|
||||||
|
id: string;
|
||||||
|
reason: string;
|
||||||
|
nextAttemptAt: string;
|
||||||
|
markedAt: string;
|
||||||
|
}) => {
|
||||||
const record = records.find((candidate) => candidate.id === input.id);
|
const record = records.find((candidate) => candidate.id === input.id);
|
||||||
Object.assign(record, {
|
Object.assign(record, {
|
||||||
status: 'failed_retryable',
|
status: 'failed_retryable',
|
||||||
|
|
@ -2180,9 +2190,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
teamName,
|
teamName,
|
||||||
expect.objectContaining({ messageId: 'opencode-inflight-new' })
|
expect.objectContaining({ messageId: 'opencode-inflight-new' })
|
||||||
);
|
);
|
||||||
const rows = JSON.parse(
|
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
|
||||||
);
|
|
||||||
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, true]);
|
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2353,9 +2361,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
'OpenCode inbox relay failed for jack/opencode-relay-failed-1'
|
'OpenCode inbox relay failed for jack/opencode-relay-failed-1'
|
||||||
);
|
);
|
||||||
vi.mocked(console.warn).mockClear();
|
vi.mocked(console.warn).mockClear();
|
||||||
const rows = JSON.parse(
|
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
|
||||||
);
|
|
||||||
expect(rows[0].read).toBe(false);
|
expect(rows[0].read).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2387,9 +2393,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
delivered: true,
|
delivered: true,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue(
|
vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue(new Error('write failed'));
|
||||||
new Error('write failed')
|
|
||||||
);
|
|
||||||
|
|
||||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||||
|
|
||||||
|
|
@ -2410,9 +2414,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
'opencode_inbox_mark_read_failed_after_delivery'
|
'opencode_inbox_mark_read_failed_after_delivery'
|
||||||
);
|
);
|
||||||
vi.mocked(console.warn).mockClear();
|
vi.mocked(console.warn).mockClear();
|
||||||
const rows = JSON.parse(
|
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
|
||||||
);
|
|
||||||
expect(rows[0].read).toBe(false);
|
expect(rows[0].read).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue