fix(perf): resolve all PR #93 review blockers

- Fix react/display-name in DisplayItemList, MarkdownViewer, SessionItem,
  SidebarTaskItem, TeamDetailView, TeamListView, KanbanBoard, GlobalTaskList,
  MemberCard, TaskRow — all anonymous arrows inside memo() replaced with
  named function form
- Fix simple-import-sort violations in TeamDetailView, TeamListView,
  SchedulesView, ScheduleSection — static imports moved before lazy() consts
- Gate all lazy dialogs in TeamDetailView by their open flag so dynamic
  import() only fires when the dialog is actually opened:
  launchDialogOpen, createTaskDialog.open, sendDialogOpen,
  selectedTask !== null, reviewDialogState.open
This commit is contained in:
Mike 2026-05-03 08:57:59 +05:00
parent 49006ee589
commit 053caed8b6
12 changed files with 4537 additions and 4501 deletions

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { memo, useCallback, useState } from 'react';
import {
CODE_BG,
@ -65,9 +65,6 @@ function buildItemMetaTooltip(
return parts.length > 0 ? parts.join(' • ') : undefined;
}
/**
* Truncates text to a maximum length and adds ellipsis if needed.
*/
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
@ -75,6 +72,345 @@ function truncateText(text: string, maxLength: number): string {
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.
*
@ -87,353 +423,75 @@ function truncateText(text: string, maxLength: number): string {
*
* The list is completely flat with no nested toggles or hierarchies.
*/
export const DisplayItemList = React.memo(
({
items,
onItemClick,
expandedItemIds,
aiGroupId,
order = 'chronological',
searchQueryOverride,
highlightToolUseId,
highlightColor,
notificationColorMap,
registerToolRef,
previewMaxLength,
timestampFormat,
showItemMetaTooltip = false,
}: Readonly<DisplayItemListProps>): React.JSX.Element => {
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
export const DisplayItemList = React.memo(function DisplayItemList({
items,
onItemClick,
expandedItemIds,
aiGroupId,
order = 'chronological',
searchQueryOverride,
highlightToolUseId,
highlightColor,
notificationColorMap,
registerToolRef,
previewMaxLength,
timestampFormat,
showItemMetaTooltip = false,
}: Readonly<DisplayItemListProps>): React.JSX.Element {
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
const handleReplyHover = useCallback((toolId: string | null) => {
setReplyLinkToolId(toolId);
}, []);
/** Check if an item is part of the currently highlighted reply link */
const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => {
if (!replyLinkToolId) return false;
if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true;
if (
item.type === 'teammate_message' &&
item.teammateMessage.replyToToolId === replyLinkToolId
)
return true;
return false;
};
if (!items || items.length === 0) {
return (
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
No items to display
</div>
);
}
const handleReplyHover = useCallback((toolId: string | null) => {
setReplyLinkToolId(toolId);
}, []);
if (!items || items.length === 0) {
return (
<div
className={
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
}
>
{items.map((item, index) => {
let itemKey = '';
let element: React.ReactNode = null;
switch (item.type) {
case 'thinking': {
itemKey = `thinking-${index}`;
const thinkingStep = {
id: itemKey,
type: 'thinking' as const,
startTime: item.timestamp,
endTime: item.timestamp,
durationMs: 0,
content: { thinkingText: item.content, tokenCount: item.tokenCount },
tokens: { input: 0, output: item.tokenCount ?? 0 },
context: 'main' as const,
};
element = (
<ThinkingItem
step={thinkingStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
timestamp={item.timestamp}
timestampFormat={timestampFormat}
titleText={
showItemMetaTooltip
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
: undefined
}
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
);
break;
}
case 'output': {
itemKey = `output-${index}`;
const textStep = {
id: itemKey,
type: 'output' as const,
startTime: item.timestamp,
endTime: item.timestamp,
durationMs: 0,
content: { outputText: item.content, tokenCount: item.tokenCount },
tokens: { input: 0, output: item.tokenCount ?? 0 },
context: 'main' as const,
};
element = (
<TextItem
step={textStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
timestamp={item.timestamp}
timestampFormat={timestampFormat}
titleText={
showItemMetaTooltip
? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens')
: undefined
}
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
);
break;
}
case 'tool': {
itemKey = `tool-${item.tool.id}-${index}`;
element = (
<LinkedToolItem
linkedTool={item.tool}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
timestamp={item.tool.startTime}
timestampFormat={timestampFormat}
titleText={
showItemMetaTooltip
? buildItemMetaTooltip(
item.tool.startTime,
getToolContextTokens(item.tool),
'tokens'
)
: undefined
}
searchQueryOverride={searchQueryOverride}
isHighlighted={highlightToolUseId === item.tool.id}
highlightColor={highlightColor}
notificationDotColor={notificationColorMap?.get(item.tool.id)}
registerRef={
registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined
}
/>
);
break;
}
case 'subagent': {
itemKey = `subagent-${item.subagent.id}-${index}`;
const subagentStep = {
id: itemKey,
type: 'subagent' as const,
startTime: item.subagent.startTime,
endTime: item.subagent.endTime,
durationMs: item.subagent.durationMs,
content: {
subagentId: item.subagent.id,
subagentDescription: item.subagent.description,
},
isParallel: item.subagent.isParallel,
context: 'main' as const,
};
element = (
<SubagentItem
step={subagentStep}
subagent={item.subagent}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
aiGroupId={aiGroupId}
highlightToolUseId={highlightToolUseId}
highlightColor={highlightColor}
notificationColorMap={notificationColorMap}
registerToolRef={registerToolRef}
/>
);
break;
}
case 'slash': {
itemKey = `slash-${item.slash.name}-${index}`;
element = (
<SlashItem
slash={item.slash}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
timestamp={item.slash.timestamp}
timestampFormat={timestampFormat}
titleText={
showItemMetaTooltip
? buildItemMetaTooltip(
item.slash.timestamp,
item.slash.instructionsTokenCount,
'tokens'
)
: undefined
}
/>
);
break;
}
case 'teammate_message': {
itemKey = `teammate-${item.teammateMessage.id}-${index}`;
element = (
<TeammateMessageItem
teammateMessage={item.teammateMessage}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onReplyHover={handleReplyHover}
/>
);
break;
}
case 'subagent_input': {
itemKey = `input-${index}`;
const inputContent = item.content;
const inputTokenCount = item.tokenCount;
element = (
<BaseItem
icon={<MailOpen className="size-4" />}
label="Input"
summary={truncateText(inputContent, previewMaxLength ?? 80)}
tokenCount={inputTokenCount}
timestamp={item.timestamp}
timestampFormat={timestampFormat}
titleText={
showItemMetaTooltip
? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens')
: undefined
}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
>
<MarkdownViewer
content={inputContent}
copyable
itemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem>
);
break;
}
case 'compact_boundary': {
itemKey = `compact-${index}`;
const compactContent = item.content;
const compactExpanded = expandedItemIds.has(itemKey);
element = (
<div>
<button
onClick={() => onItemClick(itemKey)}
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
aria-expanded={compactExpanded}
>
<div
className="flex shrink-0 items-center gap-1.5"
style={{ color: TOOL_CALL_TEXT }}
>
<ChevronRight
size={14}
className={`transition-transform duration-200 ${compactExpanded ? 'rotate-90' : ''}`}
/>
<Layers size={14} />
</div>
<span
className="shrink-0 text-xs font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
Compacted
</span>
{item.tokenDelta && (
<span
className="min-w-0 truncate text-[11px] tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} {' '}
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
<span style={{ color: '#4ade80' }}>
{' '}
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
</span>
</span>
)}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: 'rgba(99, 102, 241, 0.15)',
color: '#818cf8',
}}
>
Phase {item.phaseNumber}
</span>
<span
className="ml-auto shrink-0 text-[11px]"
style={{ color: COLOR_TEXT_MUTED }}
>
{format(new Date(item.timestamp), 'h:mm:ss a')}
</span>
</button>
{compactExpanded && compactContent && (
<div
className="mt-1 overflow-hidden rounded-lg"
style={{
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}}
>
<div
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
style={{ borderColor: 'var(--chat-ai-border)' }}
>
<MarkdownViewer content={compactContent} copyable />
</div>
</div>
)}
</div>
);
break;
}
default:
return null;
}
// Apply reply-link spotlight: dim items not in the highlighted pair
const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item);
return (
<div
key={itemKey}
style={
replyLinkToolId !== null
? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' }
: undefined
}
>
{element}
</div>
);
})}
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
No items to display
</div>
);
}
);
return (
<div
className={
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
}
>
{items.map((item, index) => {
const itemKey = getItemKey(item, index);
const isExpanded = expandedItemIds.has(itemKey);
const isInReplyLink =
replyLinkToolId !== null &&
((item.type === 'tool' && item.tool.id === replyLinkToolId) ||
(item.type === 'teammate_message' &&
item.teammateMessage.replyToToolId === replyLinkToolId));
const isDimmed = replyLinkToolId !== null && !isInReplyLink;
return (
<DisplayItemRow
key={itemKey}
item={item}
index={index}
itemKey={itemKey}
isExpanded={isExpanded}
isDimmed={isDimmed}
hasReplyLink={replyLinkToolId !== null}
onItemClick={onItemClick}
onReplyHover={handleReplyHover}
aiGroupId={aiGroupId}
searchQueryOverride={searchQueryOverride}
highlightToolUseId={highlightToolUseId}
highlightColor={highlightColor}
notificationColorMap={notificationColorMap}
registerToolRef={registerToolRef}
previewMaxLength={previewMaxLength}
timestampFormat={timestampFormat}
showItemMetaTooltip={showItemMetaTooltip}
/>
);
})}
</div>
);
});

View file

@ -946,200 +946,47 @@ export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = Rea
}
);
export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
({
content,
maxHeight = 'max-h-96',
className = '',
label,
itemId,
searchQueryOverride,
copyable = false,
bare = false,
baseDir,
teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick,
}) => {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
providedTeamColorByName,
providedOnTeamClick
);
export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(function MarkdownViewer({
content,
maxHeight = 'max-h-96',
className = '',
label,
itemId,
searchQueryOverride,
copyable = false,
bare = false,
baseDir,
teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick,
}) {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
providedTeamColorByName,
providedOnTeamClick
);
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
// Only re-render if THIS item has search matches
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => {
const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false;
return {
searchQuery: hasMatch ? s.searchQuery : '',
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
currentSearchIndex: hasMatch ? s.currentSearchIndex : -1,
};
})
);
// Guard: very large markdown can freeze the renderer (remark/rehype + highlighting).
// For large content, default to a lightweight raw preview with manual expansion.
if (isTooLarge || showRaw) {
const shown = content.slice(0, Math.min(rawLimit, content.length));
const isTruncated = shown.length < content.length;
return (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
style={
bare
? undefined
: {
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}
}
>
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: CODE_HEADER_BG,
borderBottom: `1px solid ${CODE_BORDER}`,
}}
>
<FileText className="size-4 shrink-0" style={{ color: COLOR_TEXT_MUTED }} />
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
<span className="ml-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Raw
</span>
<span className="flex-1" />
<button
type="button"
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
{copyable && <CopyButton text={content} inline />}
</div>
)}
{!label && (
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
<span>Raw preview</span>
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
</div>
)}
{isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview
to keep the UI responsive.
</div>
)}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<pre
className="min-w-0 whitespace-pre-wrap break-words p-4 font-mono text-xs leading-relaxed"
style={{ color: PROSE_BODY }}
>
{shown}
</pre>
{isTruncated && (
<div className="flex items-center justify-between gap-2 px-4 pb-4 text-xs">
<span style={{ color: COLOR_TEXT_MUTED }}>
Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit((v) => Math.min(content.length, v * 2))}
>
Show more
</button>
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
Show all
</button>
</div>
</div>
)}
</div>
</div>
);
}
// Create search context (fresh each render so counter starts at 0)
const effectiveQuery = (searchQueryOverride ?? searchQuery).trim();
const effectiveMatches = searchQueryOverride ? [] : searchMatches;
const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex;
const searchCtx =
effectiveQuery && itemId
? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex)
: null;
// Local search (Claude logs): use bright highlight for all matches (no "current result" concept).
if (searchCtx && searchQueryOverride) {
searchCtx.forceAllActive = true;
}
// Create markdown components with optional search highlighting
// When search is active, create fresh each render (match counter is stateful and must start at 0)
// useMemo would cache stale closures when parent re-renders without search deps changing
const baseComponents = searchCtx
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable)
: isLight
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable)
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable);
// When baseDir is set (editor preview), override img to load local files via IPC
const components = baseDir
? {
...baseComponents,
img: ({ src, alt }: { src?: string; alt?: string }) => {
if (src && isRelativeUrl(src)) {
return <LocalImage src={src} alt={alt} baseDir={baseDir} />;
}
return <img src={src} alt={alt || ''} className="my-2 max-w-full rounded" />;
},
}
: baseComponents;
// Only re-render if THIS item has search matches
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => {
const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false;
return {
searchQuery: hasMatch ? s.searchQuery : '',
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
currentSearchIndex: hasMatch ? s.currentSearchIndex : -1,
};
})
);
// Guard: very large markdown can freeze the renderer (remark/rehype + highlighting).
// For large content, default to a lightweight raw preview with manual expansion.
if (isTooLarge || showRaw) {
const shown = content.slice(0, Math.min(rawLimit, content.length));
const isTruncated = shown.length < content.length;
return (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
@ -1152,12 +999,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
}
}
>
{/* Copy button overlay (when no label header) */}
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{/* Optional header - matches CodeBlockViewer style */}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
@ -1170,31 +1015,184 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
{copyable && (
<>
<span className="flex-1" />
<CopyButton text={content} inline />
</>
)}
<span className="ml-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Raw
</span>
<span className="flex-1" />
<button
type="button"
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
{copyable && <CopyButton text={content} inline />}
</div>
)}
{/* Markdown content with scroll */}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<div className="min-w-0 break-words p-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
unwrapDisallowed
{!label && (
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
<span>Raw preview</span>
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
{content}
</ReactMarkdown>
Render markdown
</button>
</div>
)}
{isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to
keep the UI responsive.
</div>
)}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<pre
className="min-w-0 whitespace-pre-wrap break-words p-4 font-mono text-xs leading-relaxed"
style={{ color: PROSE_BODY }}
>
{shown}
</pre>
{isTruncated && (
<div className="flex items-center justify-between gap-2 px-4 pb-4 text-xs">
<span style={{ color: COLOR_TEXT_MUTED }}>
Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit((v) => Math.min(content.length, v * 2))}
>
Show more
</button>
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
Show all
</button>
</div>
</div>
)}
</div>
</div>
);
}
);
// Create search context (fresh each render so counter starts at 0)
const effectiveQuery = (searchQueryOverride ?? searchQuery).trim();
const effectiveMatches = searchQueryOverride ? [] : searchMatches;
const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex;
const searchCtx =
effectiveQuery && itemId
? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex)
: null;
// Local search (Claude logs): use bright highlight for all matches (no "current result" concept).
if (searchCtx && searchQueryOverride) {
searchCtx.forceAllActive = true;
}
// Create markdown components with optional search highlighting
// When search is active, create fresh each render (match counter is stateful and must start at 0)
// useMemo would cache stale closures when parent re-renders without search deps changing
const baseComponents = searchCtx
? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable)
: isLight
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable)
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable);
// When baseDir is set (editor preview), override img to load local files via IPC
const components = baseDir
? {
...baseComponents,
img: ({ src, alt }: { src?: string; alt?: string }) => {
if (src && isRelativeUrl(src)) {
return <LocalImage src={src} alt={alt} baseDir={baseDir} />;
}
return <img src={src} alt={alt || ''} className="my-2 max-w-full rounded" />;
},
}
: baseComponents;
return (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
style={
bare
? undefined
: {
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}
}
>
{/* Copy button overlay (when no label header) */}
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{/* Optional header - matches CodeBlockViewer style */}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: CODE_HEADER_BG,
borderBottom: `1px solid ${CODE_BORDER}`,
}}
>
<FileText className="size-4 shrink-0" style={{ color: COLOR_TEXT_MUTED }} />
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
{copyable && (
<>
<span className="flex-1" />
<CopyButton text={content} inline />
</>
)}
</div>
)}
{/* Markdown content with scroll */}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<div className="min-w-0 break-words p-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
unwrapDisallowed
>
{content}
</ReactMarkdown>
</div>
</div>
</div>
);
});

View file

@ -25,12 +25,12 @@ import {
import { useShallow } from 'zustand/react/shallow';
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
const LaunchTeamDialog = lazy(() =>
import('../team/dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';

File diff suppressed because it is too large Load diff

View file

@ -155,243 +155,239 @@ const SessionRuntimeBadge = ({
);
};
export const SessionItem = memo(
({
session,
isActive,
isPinned,
isHidden,
multiSelectActive,
isSelected,
}: Readonly<SessionItemProps>): React.JSX.Element => {
const {
openTab,
activeProjectId,
selectSession,
paneCount,
splitPane,
togglePinSession,
toggleHideSession,
toggleSidebarSessionSelection,
} = useStore(
useShallow((s) => ({
openTab: s.openTab,
activeProjectId: s.activeProjectId,
selectSession: s.selectSession,
paneCount: s.paneLayout.panes.length,
splitPane: s.splitPane,
togglePinSession: s.togglePinSession,
toggleHideSession: s.toggleHideSession,
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
}))
export const SessionItem = memo(function SessionItem({
session,
isActive,
isPinned,
isHidden,
multiSelectActive,
isSelected,
}: Readonly<SessionItemProps>): React.JSX.Element {
const {
openTab,
activeProjectId,
selectSession,
paneCount,
splitPane,
togglePinSession,
toggleHideSession,
toggleSidebarSessionSelection,
} = useStore(
useShallow((s) => ({
openTab: s.openTab,
activeProjectId: s.activeProjectId,
selectSession: s.selectSession,
paneCount: s.paneLayout.panes.length,
splitPane: s.splitPane,
togglePinSession: s.togglePinSession,
toggleHideSession: s.toggleHideSession,
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
}))
);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const handleClick = (event: React.MouseEvent): void => {
if (!activeProjectId) return;
// In multi-select mode, clicks toggle selection
if (multiSelectActive) {
toggleSidebarSessionSelection(session.id);
return;
}
// Cmd/Ctrl+click: open in new tab; plain click: replace current tab
const forceNewTab = event.ctrlKey || event.metaKey;
openTab(
{
type: 'session',
sessionId: session.id,
projectId: activeProjectId,
label: formatSessionLabel(session.firstMessage),
},
forceNewTab ? { forceNewTab } : { replaceActiveTab: true }
);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
selectSession(session.id);
};
const handleClick = (event: React.MouseEvent): void => {
if (!activeProjectId) return;
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
}, []);
// In multi-select mode, clicks toggle selection
if (multiSelectActive) {
toggleSidebarSessionSelection(session.id);
return;
}
const sessionLabel = formatSessionLabel(session.firstMessage);
// Cmd/Ctrl+click: open in new tab; plain click: replace current tab
const forceNewTab = event.ctrlKey || event.metaKey;
openTab(
{
type: 'session',
sessionId: session.id,
projectId: activeProjectId,
label: formatSessionLabel(session.firstMessage),
},
forceNewTab ? { forceNewTab } : { replaceActiveTab: true }
);
selectSession(session.id);
};
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const sessionLabel = formatSessionLabel(session.firstMessage);
const handleOpenInCurrentPane = useCallback(() => {
if (!activeProjectId) return;
openTab(
{
type: 'session',
sessionId: session.id,
projectId: activeProjectId,
label: sessionLabel,
},
{ replaceActiveTab: true }
);
selectSession(session.id);
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel]);
const handleOpenInNewTab = useCallback(() => {
if (!activeProjectId) return;
openTab(
{
type: 'session',
sessionId: session.id,
projectId: activeProjectId,
label: sessionLabel,
},
{ forceNewTab: true }
);
selectSession(session.id);
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel]);
const handleSplitRightAndOpen = useCallback(() => {
if (!activeProjectId) return;
// First open the tab in the focused pane
openTab({
const handleOpenInCurrentPane = useCallback(() => {
if (!activeProjectId) return;
openTab(
{
type: 'session',
sessionId: session.id,
projectId: activeProjectId,
label: sessionLabel,
});
selectSession(session.id);
// Then split it to the right
const state = useStore.getState();
const focusedPaneId = state.paneLayout.focusedPaneId;
const activeTabId = state.activeTabId;
if (activeTabId) {
splitPane(focusedPaneId, activeTabId, 'right');
}
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]);
// Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll
return (
<>
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`flex h-[54px] w-full flex-col justify-center overflow-hidden border-b px-2 py-1.5 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
style={{
borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
...(isHidden ? { opacity: 0.5 } : {}),
}}
>
{(() => {
const parsed = parseSessionTitle(session.firstMessage);
const isTeam = parsed.kind !== 'regular';
return (
<>
{/* First line: title + ongoing indicator + pin/hidden icons */}
<div className="flex items-center gap-1.5">
{multiSelectActive && (
<input
type="checkbox"
checked={isSelected ?? false}
onChange={() => toggleSidebarSessionSelection(session.id)}
onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-blue-500"
/>
)}
{session.isOngoing && <OngoingIndicator />}
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
{isTeam ? (
<span
className="flex items-center gap-1.5 truncate text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
<Users className="size-3 shrink-0 text-blue-400" />
<span className="truncate">{parsed.displayText}</span>
</span>
) : (
<span
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{parsed.displayText}
</span>
)}
</div>
{/* Second line: metadata */}
<div
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{isTeam && parsed.projectName && (
<>
<span className="truncate">{parsed.projectName}</span>
<span style={{ opacity: 0.5 }}>·</span>
</>
)}
{isTeam && (
<>
<span className="flex shrink-0 items-center gap-0.5">
{parsed.kind === 'team-resume' ? (
<RotateCw className="size-2.5" />
) : (
<Play className="size-2.5" />
)}
{parsed.kind === 'team-resume' ? 'resume' : 'new'}
</span>
<span style={{ opacity: 0.5 }}>·</span>
</>
)}
<span className="flex shrink-0 items-center gap-0.5">
<MessageSquare className="size-2.5" />
{session.messageCount}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">
{formatShortTime(new Date(session.createdAt))}
</span>
{session.model && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<SessionRuntimeBadge model={session.model} />
</>
)}
{session.contextConsumption != null && session.contextConsumption > 0 && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<ConsumptionBadge
contextConsumption={session.contextConsumption}
phaseBreakdown={session.phaseBreakdown}
/>
</>
)}
</div>
</>
);
})()}
</button>
{contextMenu &&
activeProjectId &&
createPortal(
<SessionContextMenu
x={contextMenu.x}
y={contextMenu.y}
sessionId={session.id}
projectId={activeProjectId}
sessionLabel={sessionLabel}
paneCount={paneCount}
isPinned={isPinned ?? false}
isHidden={isHidden ?? false}
onClose={() => setContextMenu(null)}
onOpenInCurrentPane={handleOpenInCurrentPane}
onOpenInNewTab={handleOpenInNewTab}
onSplitRightAndOpen={handleSplitRightAndOpen}
onTogglePin={() => void togglePinSession(session.id)}
onToggleHide={() => void toggleHideSession(session.id)}
/>,
document.body
)}
</>
},
{ replaceActiveTab: true }
);
}
);
selectSession(session.id);
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel]);
const handleOpenInNewTab = useCallback(() => {
if (!activeProjectId) return;
openTab(
{
type: 'session',
sessionId: session.id,
projectId: activeProjectId,
label: sessionLabel,
},
{ forceNewTab: true }
);
selectSession(session.id);
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel]);
const handleSplitRightAndOpen = useCallback(() => {
if (!activeProjectId) return;
// First open the tab in the focused pane
openTab({
type: 'session',
sessionId: session.id,
projectId: activeProjectId,
label: sessionLabel,
});
selectSession(session.id);
// Then split it to the right
const state = useStore.getState();
const focusedPaneId = state.paneLayout.focusedPaneId;
const activeTabId = state.activeTabId;
if (activeTabId) {
splitPane(focusedPaneId, activeTabId, 'right');
}
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]);
// Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll
return (
<>
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`flex h-[54px] w-full flex-col justify-center overflow-hidden border-b px-2 py-1.5 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
style={{
borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
...(isHidden ? { opacity: 0.5 } : {}),
}}
>
{(() => {
const parsed = parseSessionTitle(session.firstMessage);
const isTeam = parsed.kind !== 'regular';
return (
<>
{/* First line: title + ongoing indicator + pin/hidden icons */}
<div className="flex items-center gap-1.5">
{multiSelectActive && (
<input
type="checkbox"
checked={isSelected ?? false}
onChange={() => toggleSidebarSessionSelection(session.id)}
onClick={(e) => e.stopPropagation()}
className="size-3.5 shrink-0 accent-blue-500"
/>
)}
{session.isOngoing && <OngoingIndicator />}
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
{isTeam ? (
<span
className="flex items-center gap-1.5 truncate text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
<Users className="size-3 shrink-0 text-blue-400" />
<span className="truncate">{parsed.displayText}</span>
</span>
) : (
<span
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{parsed.displayText}
</span>
)}
</div>
{/* Second line: metadata */}
<div
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{isTeam && parsed.projectName && (
<>
<span className="truncate">{parsed.projectName}</span>
<span style={{ opacity: 0.5 }}>·</span>
</>
)}
{isTeam && (
<>
<span className="flex shrink-0 items-center gap-0.5">
{parsed.kind === 'team-resume' ? (
<RotateCw className="size-2.5" />
) : (
<Play className="size-2.5" />
)}
{parsed.kind === 'team-resume' ? 'resume' : 'new'}
</span>
<span style={{ opacity: 0.5 }}>·</span>
</>
)}
<span className="flex shrink-0 items-center gap-0.5">
<MessageSquare className="size-2.5" />
{session.messageCount}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
{session.model && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<SessionRuntimeBadge model={session.model} />
</>
)}
{session.contextConsumption != null && session.contextConsumption > 0 && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<ConsumptionBadge
contextConsumption={session.contextConsumption}
phaseBreakdown={session.phaseBreakdown}
/>
</>
)}
</div>
</>
);
})()}
</button>
{contextMenu &&
activeProjectId &&
createPortal(
<SessionContextMenu
x={contextMenu.x}
y={contextMenu.y}
sessionId={session.id}
projectId={activeProjectId}
sessionLabel={sessionLabel}
paneCount={paneCount}
isPinned={isPinned ?? false}
isHidden={isHidden ?? false}
onClose={() => setContextMenu(null)}
onOpenInCurrentPane={handleOpenInCurrentPane}
onOpenInNewTab={handleOpenInNewTab}
onSplitRightAndOpen={handleSplitRightAndOpen}
onTogglePin={() => void togglePinSession(session.id)}
onToggleHide={() => void toggleHideSession(session.id)}
/>,
document.body
)}
</>
);
});

View file

@ -69,220 +69,218 @@ interface SidebarTaskItemProps {
getDisplaySubject?: (task: GlobalTask) => string | undefined;
}
export const SidebarTaskItem = memo(
({
task,
hideTeamName,
showTeamName,
renamingKey,
onRenameComplete,
onRenameCancel,
getDisplaySubject,
}: SidebarTaskItemProps): React.JSX.Element => {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const { isLight } = useTheme();
export const SidebarTaskItem = memo(function SidebarTaskItem({
task,
hideTeamName,
showTeamName,
renamingKey,
onRenameComplete,
onRenameCancel,
getDisplaySubject,
}: SidebarTaskItemProps): React.JSX.Element {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const { isLight } = useTheme();
const isRenaming = renamingKey === `${task.teamName}:${task.id}`;
const displaySubject = getDisplaySubject?.(task) ?? task.subject;
const [editValue, setEditValue] = useState(displaySubject);
const inputRef = useRef<HTMLInputElement>(null);
// Focus input when rename starts
useEffect(() => {
if (!isRenaming) return;
const raf = requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
return () => cancelAnimationFrame(raf);
}, [isRenaming]);
const isRenaming = renamingKey === `${task.teamName}:${task.id}`;
const displaySubject = getDisplaySubject?.(task) ?? task.subject;
const [editValue, setEditValue] = useState(displaySubject);
const inputRef = useRef<HTMLInputElement>(null);
// Focus input when rename starts
useEffect(() => {
if (!isRenaming) return;
const raf = requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
return () => cancelAnimationFrame(raf);
}, [isRenaming]);
// Reset edit value when renaming starts
useEffect(() => {
if (isRenaming) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setEditValue(displaySubject);
}
}, [isRenaming, displaySubject]);
// Reset edit value when renaming starts
useEffect(() => {
if (isRenaming) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setEditValue(displaySubject);
}
}, [isRenaming, displaySubject]);
const reviewColumn = getTaskKanbanColumn(task);
const cfg =
reviewColumn === 'approved'
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
: reviewColumn === 'review'
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
: (statusConfig[task.status] ?? statusConfig.pending);
const StatusIcon = cfg.icon;
const updatedLabel = formatUpdatedLabel(task);
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
const reviewColumn = getTaskKanbanColumn(task);
const cfg =
reviewColumn === 'approved'
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
: reviewColumn === 'review'
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
: (statusConfig[task.status] ?? statusConfig.pending);
const StatusIcon = cfg.icon;
const updatedLabel = formatUpdatedLabel(task);
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
const ownerColorSet = useMemo(() => {
if (!teamMembers || !task.owner) return null;
const colorMap = buildMemberColorMap(teamMembers);
const colorName = colorMap.get(task.owner);
return colorName ? getTeamColorSet(colorName) : null;
}, [teamMembers, task.owner]);
const ownerColorSet = useMemo(() => {
if (!teamMembers || !task.owner) return null;
const colorMap = buildMemberColorMap(teamMembers);
const colorName = colorMap.get(task.owner);
return colorName ? getTeamColorSet(colorName) : null;
}, [teamMembers, task.owner]);
const ownerTextColor = useMemo(() => {
if (!ownerColorSet) return undefined;
return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text;
}, [ownerColorSet, isLight]);
const ownerTextColor = useMemo(() => {
if (!ownerColorSet) return undefined;
return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text;
}, [ownerColorSet, isLight]);
const projectLabel = useMemo(() => {
if (!task.projectPath?.trim()) return null;
return projectLabelFromPath(task.projectPath);
}, [task.projectPath]);
const projectLabel = useMemo(() => {
if (!task.projectPath?.trim()) return null;
return projectLabelFromPath(task.projectPath);
}, [task.projectPath]);
const projectColorSet = useMemo(
() => (projectLabel ? projectColor(projectLabel, isLight) : null),
[projectLabel, isLight]
);
const projectColorSet = useMemo(
() => (projectLabel ? projectColor(projectLabel, isLight) : null),
[projectLabel, isLight]
);
const teamColor = useMemo(
() => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null),
[showTeamName, task.teamDisplayName, isLight]
);
const teamColor = useMemo(
() => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null),
[showTeamName, task.teamDisplayName, isLight]
);
const showTeamRow = showTeamName && !hideTeamName;
const unreadBackgroundClass =
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
const showTeamRow = showTeamName && !hideTeamName;
const unreadBackgroundClass =
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
return (
<button
type="button"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }}
onClick={() => {
if (!isRenaming) {
openGlobalTaskDetail(task.teamName, task.id);
}
}}
>
{/* Row 1: status + subject */}
<div className="w-full overflow-hidden">
{isRenaming ? (
<div className="flex items-start gap-1.5">
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
} else if (e.key === 'Escape') {
e.preventDefault();
onRenameCancel?.();
}
}}
onBlur={() => {
return (
<button
type="button"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }}
onClick={() => {
if (!isRenaming) {
openGlobalTaskDetail(task.teamName, task.id);
}
}}
>
{/* Row 1: status + subject */}
<div className="w-full overflow-hidden">
{isRenaming ? (
<div className="flex items-start gap-1.5">
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
}}
className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none"
style={{ color: 'var(--color-text-muted)' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<StatusIcon className={`mr-1.5 inline-block size-3 align-[-1px] ${cfg.color}`} />
{unreadCount > 0 &&
(unreadCount === 1 ? (
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" />
) : (
<span className="mr-1 inline-flex size-3.5 items-center justify-center rounded-full bg-blue-500 align-middle text-[8px] font-bold leading-none text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
))}
{displaySubject}
{task.reviewState === 'needsFix' && (
<span
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
{REVIEW_STATE_DISPLAY.needsFix.label}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{displaySubject}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Row 2: project + owner (when no team row) + date */}
<div
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{task.teamDeleted && <Trash2 className="size-2.5 shrink-0 text-zinc-500" />}
{projectLabel && (
<span
className="shrink-0"
style={projectColorSet ? { color: projectColorSet.text } : undefined}
>
{projectLabel}
</span>
)}
{!showTeamRow && (
<>
{projectLabel && <span className="opacity-100 dark:opacity-40">·</span>}
} else if (e.key === 'Escape') {
e.preventDefault();
onRenameCancel?.();
}
}}
onBlur={() => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== task.subject) {
onRenameComplete?.(task.teamName, task.id, trimmed);
} else {
onRenameCancel?.();
}
}}
className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none"
style={{ color: 'var(--color-text-muted)' }}
onClick={(e) => e.stopPropagation()}
/>
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span
className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined}
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{task.owner ?? 'unassigned'}
<StatusIcon className={`mr-1.5 inline-block size-3 align-[-1px] ${cfg.color}`} />
{unreadCount > 0 &&
(unreadCount === 1 ? (
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" />
) : (
<span className="mr-1 inline-flex size-3.5 items-center justify-center rounded-full bg-blue-500 align-middle text-[8px] font-bold leading-none text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
))}
{displaySubject}
{task.reviewState === 'needsFix' && (
<span
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
{REVIEW_STATE_DISPLAY.needsFix.label}
</span>
)}
</span>
</>
)}
{dateLabel && (
<span
className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-100 dark:opacity-70' : ''}`}
>
{dateLabel}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{displaySubject}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Row 3: Team: name · owner */}
{showTeamRow && (
<div
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
{/* Row 2: project + owner (when no team row) + date */}
<div
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
{task.teamDeleted && <Trash2 className="size-2.5 shrink-0 text-zinc-500" />}
{projectLabel && (
<span
className="shrink-0"
style={projectColorSet ? { color: projectColorSet.text } : undefined}
>
<span className="shrink-0 opacity-100 dark:opacity-50">Team:</span>
<span className="shrink-0" style={teamColor ? { color: teamColor.text } : undefined}>
{task.teamDisplayName}
</span>
<span className="opacity-100 dark:opacity-40">·</span>
{projectLabel}
</span>
)}
{!showTeamRow && (
<>
{projectLabel && <span className="opacity-100 dark:opacity-40">·</span>}
<span
className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined}
>
{task.owner ?? 'unassigned'}
</span>
</div>
</>
)}
</button>
);
}
);
{dateLabel && (
<span
className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-100 dark:opacity-70' : ''}`}
>
{dateLabel}
</span>
)}
</div>
{/* Row 3: Team: name · owner */}
{showTeamRow && (
<div
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<span className="shrink-0 opacity-100 dark:opacity-50">Team:</span>
<span className="shrink-0" style={teamColor ? { color: teamColor.text } : undefined}>
{task.teamDisplayName}
</span>
<span className="opacity-100 dark:opacity-40">·</span>
<span
className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined}
>
{task.owner ?? 'unassigned'}
</span>
</div>
)}
</button>
);
});

File diff suppressed because it is too large Load diff

View file

@ -45,7 +45,6 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import {
@ -54,13 +53,7 @@ import {
teamMatchesProjectSelection,
} from './teamProjectSelection';
const CreateTeamDialog = lazy(() =>
import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog }))
);
const LaunchTeamDialog = lazy(() =>
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover';
import type { TeamStatus } from '@renderer/utils/teamListStatus';
import type {
@ -72,6 +65,13 @@ import type {
TeamSummaryMember,
} from '@shared/types';
const CreateTeamDialog = lazy(() =>
import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog }))
);
const LaunchTeamDialog = lazy(() =>
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
function generateUniqueName(sourceName: string, existingNames: string[]): string {
const base = sourceName.replace(/-\d+$/, '');
const existing = new Set(existingNames);
@ -238,7 +238,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
}
};
export const TeamListView = memo((): React.JSX.Element => {
export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const { isLight } = useTheme();
const electronMode = isElectronMode();
const [showCreateDialog, setShowCreateDialog] = useState(false);

View file

@ -311,123 +311,119 @@ const SortableKanbanTaskCard = ({
);
};
export const KanbanBoard = memo(
({
tasks,
teamName,
kanbanState,
filter,
sort,
sessions,
leadSessionId,
members,
onFilterChange,
onSortChange,
onRequestReview,
onApprove,
onRequestChanges,
onMoveBackToDone,
onStartTask,
onCompleteTask,
onCancelTask,
onScrollToTask,
onTaskClick,
onViewChanges,
onColumnOrderChange,
toolbarLeft,
onAddTask,
onDeleteTask,
deletedTaskCount,
onOpenTrash,
}: KanbanBoardProps): React.JSX.Element => {
const boardRef = useRef<HTMLDivElement>(null);
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
const hasReviewers = kanbanState.reviewers.length > 0;
const enableTaskSorting =
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
export const KanbanBoard = memo(function KanbanBoard({
tasks,
teamName,
kanbanState,
filter,
sort,
sessions,
leadSessionId,
members,
onFilterChange,
onSortChange,
onRequestReview,
onApprove,
onRequestChanges,
onMoveBackToDone,
onStartTask,
onCompleteTask,
onCancelTask,
onScrollToTask,
onTaskClick,
onViewChanges,
onColumnOrderChange,
toolbarLeft,
onAddTask,
onDeleteTask,
deletedTaskCount,
onOpenTrash,
}: KanbanBoardProps): React.JSX.Element {
const boardRef = useRef<HTMLDivElement>(null);
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
const hasReviewers = kanbanState.reviewers.length > 0;
const enableTaskSorting =
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
const stableTaskMapRef = useRef<{
signatures: string[];
map: Map<string, TeamTask>;
} | null>(null);
const taskMap = useMemo(() => {
const signatures = tasks.map(
(task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}`
);
const previous = stableTaskMapRef.current;
if (
previous?.signatures.length === signatures.length &&
previous.signatures.every((signature, index) => signature === signatures[index])
) {
return previous.map;
}
const next = new Map(tasks.map((task) => [task.id, task]));
stableTaskMapRef.current = { signatures, map: next };
return next;
}, [tasks]);
const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]);
const grouped = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>(
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
);
for (const task of tasks) {
const column = getTaskColumn(task, kanbanState);
if (!column) {
continue;
}
result.get(column)?.push(task);
}
return result;
}, [tasks, kanbanState]);
const groupedOrdered = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>();
for (const column of COLUMNS) {
const columnTasks = grouped.get(column.id) ?? [];
const order = kanbanState.columnOrder?.[column.id];
result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order));
}
return result;
}, [grouped, kanbanState.columnOrder, sort.field]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
const stableTaskMapRef = useRef<{
signatures: string[];
map: Map<string, TeamTask>;
} | null>(null);
const taskMap = useMemo(() => {
const signatures = tasks.map(
(task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}`
);
const previous = stableTaskMapRef.current;
if (
previous?.signatures.length === signatures.length &&
previous.signatures.every((signature, index) => signature === signatures[index])
) {
return previous.map;
}
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!onColumnOrderChange || !over || active.id === over.id) {
return;
}
const activeData = active.data.current;
if (activeData?.type !== 'kanban-task') {
return;
}
const columnId = activeData.columnId as KanbanColumnId;
const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? [];
const oldIndex = orderedIds.indexOf(active.id as string);
const newIndex = orderedIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
return;
}
const newOrder = arrayMove(orderedIds, oldIndex, newIndex);
onColumnOrderChange(columnId, newOrder);
},
[onColumnOrderChange, groupedOrdered]
const next = new Map(tasks.map((task) => [task.id, task]));
stableTaskMapRef.current = { signatures, map: next };
return next;
}, [tasks]);
const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]);
const grouped = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>(
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
);
for (const task of tasks) {
const column = getTaskColumn(task, kanbanState);
if (!column) {
continue;
}
result.get(column)?.push(task);
}
return result;
}, [tasks, kanbanState]);
const renderCards = (
columnId: KanbanColumnId,
columnTasks: TeamTask[],
compact?: boolean
): React.JSX.Element => {
const groupedOrdered = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>();
for (const column of COLUMNS) {
const columnTasks = grouped.get(column.id) ?? [];
const order = kanbanState.columnOrder?.[column.id];
result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order));
}
return result;
}, [grouped, kanbanState.columnOrder, sort.field]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!onColumnOrderChange || !over || active.id === over.id) {
return;
}
const activeData = active.data.current;
if (activeData?.type !== 'kanban-task') {
return;
}
const columnId = activeData.columnId as KanbanColumnId;
const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? [];
const oldIndex = orderedIds.indexOf(active.id as string);
const newIndex = orderedIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
return;
}
const newOrder = arrayMove(orderedIds, oldIndex, newIndex);
onColumnOrderChange(columnId, newOrder);
},
[onColumnOrderChange, groupedOrdered]
);
const renderCards = useCallback(
(columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean): React.JSX.Element => {
const addHandler =
onAddTask && columnId === 'todo'
? () => onAddTask(false)
@ -517,248 +513,266 @@ export const KanbanBoard = memo(
{addButton}
</>
);
};
},
[
enableTaskSorting,
hasReviewers,
kanbanState,
memberColorMap,
onAddTask,
onApprove,
onCancelTask,
onCompleteTask,
onDeleteTask,
onMoveBackToDone,
onRequestChanges,
onRequestReview,
onScrollToTask,
onStartTask,
onTaskClick,
onViewChanges,
taskMap,
teamName,
]
);
const visibleColumns = useMemo(
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
[filter.columns]
);
const primaryVisibleColumnId = visibleColumns[0]?.id ?? null;
const visibleColumns = useMemo(
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
[filter.columns]
);
const primaryVisibleColumnId = visibleColumns[0]?.id ?? null;
const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]);
const { widths: columnWidths, getHandleProps } = useResizableColumns({
storageKey: teamName,
columnIds: resizableColumnIds,
});
const columnModeSearchWidth =
primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256;
const toolbarLeftWidth =
viewMode === 'grid'
? (gridPrimaryColumnWidth ?? columnModeSearchWidth)
: columnModeSearchWidth;
const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]);
const { widths: columnWidths, getHandleProps } = useResizableColumns({
storageKey: teamName,
columnIds: resizableColumnIds,
});
const columnModeSearchWidth =
primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256;
const toolbarLeftWidth =
viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth;
const clearScheduledScrollRestore = useCallback(() => {
for (const timeoutId of scrollRestoreTimeoutsRef.current) {
window.clearTimeout(timeoutId);
const clearScheduledScrollRestore = useCallback(() => {
for (const timeoutId of scrollRestoreTimeoutsRef.current) {
window.clearTimeout(timeoutId);
}
scrollRestoreTimeoutsRef.current = [];
}, []);
useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]);
const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => {
let current = startNode?.parentElement ?? null;
while (current) {
const { overflowY } = window.getComputedStyle(current);
if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) {
return current;
}
scrollRestoreTimeoutsRef.current = [];
}, []);
current = current.parentElement;
}
return null;
}, []);
useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]);
const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => {
let current = startNode?.parentElement ?? null;
while (current) {
const { overflowY } = window.getComputedStyle(current);
if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) {
return current;
}
current = current.parentElement;
const scheduleScrollRestore = useCallback(
(nextViewMode: KanbanViewMode, skeletonDelayMs: number) => {
const container = findScrollContainer(boardRef.current);
if (!container) {
return;
}
return null;
}, []);
const scheduleScrollRestore = useCallback(
(nextViewMode: KanbanViewMode, skeletonDelayMs: number) => {
const container = findScrollContainer(boardRef.current);
if (!container) {
return;
}
const savedScrollTop = container.scrollTop;
clearScheduledScrollRestore();
const savedScrollTop = container.scrollTop;
clearScheduledScrollRestore();
const restore = (): void => {
container.scrollTop = savedScrollTop;
};
const restore = (): void => {
container.scrollTop = savedScrollTop;
const delays =
nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120];
scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay));
},
[clearScheduledScrollRestore, findScrollContainer]
);
const switchViewMode = useCallback(
(nextViewMode: KanbanViewMode) => {
const nextSkeletonDelayMs =
nextViewMode === 'grid' && viewMode === 'columns'
? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH
: SKELETON_HIDE_DELAY_MS;
setGridSkeletonDelayMs(nextSkeletonDelayMs);
scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs);
setViewMode(nextViewMode);
},
[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 delays =
nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120];
scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay));
},
[clearScheduledScrollRestore, findScrollContainer]
);
const switchViewMode = useCallback(
(nextViewMode: KanbanViewMode) => {
const nextSkeletonDelayMs =
nextViewMode === 'grid' && viewMode === 'columns'
? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH
: SKELETON_HIDE_DELAY_MS;
setGridSkeletonDelayMs(nextSkeletonDelayMs);
scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs);
setViewMode(nextViewMode);
},
[scheduleScrollRestore, viewMode]
);
const boardContent = (
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
<div
className={cn(
'flex min-w-0 max-w-full items-center gap-2',
viewMode === 'columns' ? 'mb-0' : 'mb-2',
toolbarLeft == null && 'justify-end'
)}
>
{toolbarLeft != null && (
<div className="min-w-0 max-w-full" style={{ width: toolbarLeftWidth }}>
{toolbarLeft}
</div>
)}
<div className="ml-auto flex shrink-0 items-center gap-2">
<div className="inline-flex items-center rounded-md border border-[var(--color-border)]">
<KanbanFilterPopover
filter={filter}
sessions={sessions}
leadSessionId={leadSessionId}
members={members}
onFilterChange={onFilterChange}
/>
<div className="h-4 w-px bg-[var(--color-border)]" />
<KanbanSortPopover sort={sort} onSortChange={onSortChange} />
</div>
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[var(--color-text-muted)]"
onClick={onOpenTrash}
>
<Trash2 size={14} />
<span className="ml-1 text-xs">{deletedTaskCount}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Trash</TooltipContent>
</Tooltip>
) : null}
<div className="inline-flex rounded-md border border-[var(--color-border)]">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-r-none px-2',
viewMode === 'grid'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => switchViewMode('grid')}
aria-label="Grid view"
>
<LayoutGrid size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Grid view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
viewMode === 'columns'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => switchViewMode('columns')}
aria-label="Columns view"
>
<Columns3 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Columns view</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{viewMode === 'grid' ? (
<KanbanGridLayout
allColumnIds={COLUMNS.map((column) => column.id)}
primaryColumnId={primaryVisibleColumnId}
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
skeletonDelayMs={gridSkeletonDelayMs}
columns={visibleColumns.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return {
id: column.id,
title: column.title,
count: columnTasks.length,
icon: accent.icon,
headerBg: accent.headerBg,
bodyBg: accent.bodyBg,
content: renderCards(column.id, columnTasks),
showAddButton: columnSupportsAddButton(column.id, onAddTask),
skeletonCards: columnTasks.map((task) => ({
key: task.id,
height: estimateGridSkeletonCardHeight(
task,
column.id,
kanbanState,
hasReviewers
),
})),
};
})}
/>
) : (
<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="flex min-w-max items-start pr-1">
{visibleColumns.map((column, index) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
const width = columnWidths.get(column.id) ?? 256;
const handleProps = getHandleProps(column.id);
return (
<div key={column.id} className="flex shrink-0">
<div style={{ width }}>
<KanbanColumn
title={column.title}
count={columnTasks.length}
icon={accent.icon}
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
bodyClassName="max-h-none overflow-visible"
>
{renderCards(column.id, columnTasks, true)}
</KanbanColumn>
</div>
{index < visibleColumns.length - 1 ? (
<div
className="group relative mx-0.5 flex items-center justify-center"
onPointerDown={handleProps.onPointerDown}
style={handleProps.style}
aria-label={handleProps['aria-label']}
>
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
</div>
) : null}
</div>
);
})}
</div>
const boardContent = (
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
<div
className={cn(
'flex min-w-0 max-w-full items-center gap-2',
viewMode === 'columns' ? 'mb-0' : 'mb-2',
toolbarLeft == null && 'justify-end'
)}
>
{toolbarLeft != null && (
<div className="min-w-0 max-w-full" style={{ width: toolbarLeftWidth }}>
{toolbarLeft}
</div>
)}
<div className="ml-auto flex shrink-0 items-center gap-2">
<div className="inline-flex items-center rounded-md border border-[var(--color-border)]">
<KanbanFilterPopover
filter={filter}
sessions={sessions}
leadSessionId={leadSessionId}
members={members}
onFilterChange={onFilterChange}
/>
<div className="h-4 w-px bg-[var(--color-border)]" />
<KanbanSortPopover sort={sort} onSortChange={onSortChange} />
</div>
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[var(--color-text-muted)]"
onClick={onOpenTrash}
>
<Trash2 size={14} />
<span className="ml-1 text-xs">{deletedTaskCount}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Trash</TooltipContent>
</Tooltip>
) : null}
<div className="inline-flex rounded-md border border-[var(--color-border)]">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-r-none px-2',
viewMode === 'grid'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => switchViewMode('grid')}
aria-label="Grid view"
>
<LayoutGrid size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Grid view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
viewMode === 'columns'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => switchViewMode('columns')}
aria-label="Columns view"
>
<Columns3 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Columns view</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{viewMode === 'grid' ? (
<KanbanGridLayout
allColumnIds={COLUMNS.map((column) => column.id)}
primaryColumnId={primaryVisibleColumnId}
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
skeletonDelayMs={gridSkeletonDelayMs}
columns={gridColumns}
/>
) : (
<div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden px-1 pb-6 pr-4 pt-2">
<div className="flex min-w-max items-start pr-1">
{visibleColumns.map((column, index) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
const width = columnWidths.get(column.id) ?? 256;
const handleProps = getHandleProps(column.id);
return (
<div key={column.id} className="flex shrink-0">
<div style={{ width }}>
<KanbanColumn
title={column.title}
count={columnTasks.length}
icon={accent.icon}
headerBg={accent.headerBg}
bodyBg={accent.bodyBg}
bodyClassName="max-h-none overflow-visible"
>
{renderCards(column.id, columnTasks, true)}
</KanbanColumn>
</div>
{index < visibleColumns.length - 1 ? (
<div
className="group relative mx-0.5 flex items-center justify-center"
onPointerDown={handleProps.onPointerDown}
style={handleProps.style}
aria-label={handleProps['aria-label']}
>
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
</div>
) : null}
</div>
);
})}
</div>
</div>
)}
</div>
);
if (enableTaskSorting) {
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{boardContent}
</DndContext>
);
if (enableTaskSorting) {
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{boardContent}
</DndContext>
);
}
return boardContent;
}
);
return boardContent;
});

File diff suppressed because it is too large Load diff

View file

@ -19,13 +19,13 @@ import {
import { useShallow } from 'zustand/react/shallow';
import { ScheduleEmptyState } from './ScheduleEmptyState';
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
import { ScheduleRunRow } from './ScheduleRunRow';
import { ScheduleStatusBadge } from './ScheduleStatusBadge';
const LaunchTeamDialog = lazy(() =>
import('../dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
import { ScheduleRunRow } from './ScheduleRunRow';
import { ScheduleStatusBadge } from './ScheduleStatusBadge';
import type { Schedule, ScheduleRun } from '@shared/types';

View file

@ -14,7 +14,7 @@ interface TaskRowProps {
task: TeamTaskWithKanban;
}
export const TaskRow = memo(({ task }: TaskRowProps): React.JSX.Element => {
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const kanbanColumn = getTaskKanbanColumn(task);