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:
parent
49006ee589
commit
053caed8b6
12 changed files with 4537 additions and 4501 deletions
|
|
@ -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,353 +423,75 @@ 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 = React.memo(
|
export const DisplayItemList = React.memo(function DisplayItemList({
|
||||||
({
|
items,
|
||||||
items,
|
onItemClick,
|
||||||
onItemClick,
|
expandedItemIds,
|
||||||
expandedItemIds,
|
aiGroupId,
|
||||||
aiGroupId,
|
order = 'chronological',
|
||||||
order = 'chronological',
|
searchQueryOverride,
|
||||||
searchQueryOverride,
|
highlightToolUseId,
|
||||||
highlightToolUseId,
|
highlightColor,
|
||||||
highlightColor,
|
notificationColorMap,
|
||||||
notificationColorMap,
|
registerToolRef,
|
||||||
registerToolRef,
|
previewMaxLength,
|
||||||
previewMaxLength,
|
timestampFormat,
|
||||||
timestampFormat,
|
showItemMetaTooltip = false,
|
||||||
showItemMetaTooltip = false,
|
}: Readonly<DisplayItemListProps>): React.JSX.Element {
|
||||||
}: Readonly<DisplayItemListProps>): React.JSX.Element => {
|
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
|
||||||
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
|
|
||||||
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) {
|
|
||||||
return (
|
|
||||||
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
|
|
||||||
No items to display
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
|
||||||
className={
|
No items to display
|
||||||
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>
|
</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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -946,200 +946,47 @@ export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = Rea
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
|
export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(function MarkdownViewer({
|
||||||
({
|
content,
|
||||||
content,
|
maxHeight = 'max-h-96',
|
||||||
maxHeight = 'max-h-96',
|
className = '',
|
||||||
className = '',
|
label,
|
||||||
label,
|
itemId,
|
||||||
itemId,
|
searchQueryOverride,
|
||||||
searchQueryOverride,
|
copyable = false,
|
||||||
copyable = false,
|
bare = false,
|
||||||
bare = false,
|
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();
|
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
|
||||||
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
|
providedTeamColorByName,
|
||||||
providedTeamColorByName,
|
providedOnTeamClick
|
||||||
providedOnTeamClick
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
||||||
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
||||||
|
|
||||||
// Only re-render if THIS item has search matches
|
// Only re-render if THIS item has search matches
|
||||||
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
||||||
useShallow((s) => {
|
useShallow((s) => {
|
||||||
const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false;
|
const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false;
|
||||||
return {
|
return {
|
||||||
searchQuery: hasMatch ? s.searchQuery : '',
|
searchQuery: hasMatch ? s.searchQuery : '',
|
||||||
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
|
searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES,
|
||||||
currentSearchIndex: hasMatch ? s.currentSearchIndex : -1,
|
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;
|
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
|
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 && (
|
{copyable && !label && (
|
||||||
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
|
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Optional header - matches CodeBlockViewer style */}
|
|
||||||
{label && (
|
{label && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-3 py-2"
|
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 }}>
|
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{copyable && (
|
<span className="ml-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
<>
|
Raw
|
||||||
<span className="flex-1" />
|
</span>
|
||||||
<CopyButton text={content} inline />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Markdown content with scroll */}
|
{!label && (
|
||||||
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
|
<div
|
||||||
<div className="min-w-0 break-words p-2">
|
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||||
<ReactMarkdown
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
remarkPlugins={[remarkGfm]}
|
>
|
||||||
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
|
<span>Raw preview</span>
|
||||||
components={components}
|
<button
|
||||||
urlTransform={allowCustomProtocols}
|
type="button"
|
||||||
allowElement={isAllowedElement}
|
className="underline"
|
||||||
unwrapDisallowed
|
style={{ color: PROSE_LINK }}
|
||||||
|
onClick={() => setShowRaw(false)}
|
||||||
|
disabled={isTooLarge}
|
||||||
|
title={
|
||||||
|
isTooLarge
|
||||||
|
? 'Large content is shown as raw to prevent UI freeze'
|
||||||
|
: 'Render markdown'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{content}
|
Render markdown
|
||||||
</ReactMarkdown>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,12 @@ import {
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
||||||
|
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
||||||
|
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
||||||
|
|
||||||
const LaunchTeamDialog = lazy(() =>
|
const LaunchTeamDialog = lazy(() =>
|
||||||
import('../team/dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
|
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';
|
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -155,243 +155,239 @@ const SessionRuntimeBadge = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SessionItem = memo(
|
export const SessionItem = memo(function SessionItem({
|
||||||
({
|
session,
|
||||||
session,
|
isActive,
|
||||||
isActive,
|
isPinned,
|
||||||
isPinned,
|
isHidden,
|
||||||
isHidden,
|
multiSelectActive,
|
||||||
multiSelectActive,
|
isSelected,
|
||||||
isSelected,
|
}: Readonly<SessionItemProps>): React.JSX.Element {
|
||||||
}: Readonly<SessionItemProps>): React.JSX.Element => {
|
const {
|
||||||
const {
|
openTab,
|
||||||
openTab,
|
activeProjectId,
|
||||||
activeProjectId,
|
selectSession,
|
||||||
selectSession,
|
paneCount,
|
||||||
paneCount,
|
splitPane,
|
||||||
splitPane,
|
togglePinSession,
|
||||||
togglePinSession,
|
toggleHideSession,
|
||||||
toggleHideSession,
|
toggleSidebarSessionSelection,
|
||||||
toggleSidebarSessionSelection,
|
} = useStore(
|
||||||
} = useStore(
|
useShallow((s) => ({
|
||||||
useShallow((s) => ({
|
openTab: s.openTab,
|
||||||
openTab: s.openTab,
|
activeProjectId: s.activeProjectId,
|
||||||
activeProjectId: s.activeProjectId,
|
selectSession: s.selectSession,
|
||||||
selectSession: s.selectSession,
|
paneCount: s.paneLayout.panes.length,
|
||||||
paneCount: s.paneLayout.panes.length,
|
splitPane: s.splitPane,
|
||||||
splitPane: s.splitPane,
|
togglePinSession: s.togglePinSession,
|
||||||
togglePinSession: s.togglePinSession,
|
toggleHideSession: s.toggleHideSession,
|
||||||
toggleHideSession: s.toggleHideSession,
|
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
|
||||||
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 => {
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||||
if (!activeProjectId) return;
|
e.preventDefault();
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// In multi-select mode, clicks toggle selection
|
const sessionLabel = formatSessionLabel(session.firstMessage);
|
||||||
if (multiSelectActive) {
|
|
||||||
toggleSidebarSessionSelection(session.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cmd/Ctrl+click: open in new tab; plain click: replace current tab
|
const handleOpenInCurrentPane = useCallback(() => {
|
||||||
const forceNewTab = event.ctrlKey || event.metaKey;
|
if (!activeProjectId) return;
|
||||||
|
openTab(
|
||||||
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({
|
|
||||||
type: 'session',
|
type: 'session',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
projectId: activeProjectId,
|
projectId: activeProjectId,
|
||||||
label: sessionLabel,
|
label: sessionLabel,
|
||||||
});
|
},
|
||||||
selectSession(session.id);
|
{ replaceActiveTab: true }
|
||||||
// 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
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -69,220 +69,218 @@ interface SidebarTaskItemProps {
|
||||||
getDisplaySubject?: (task: GlobalTask) => string | undefined;
|
getDisplaySubject?: (task: GlobalTask) => string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidebarTaskItem = memo(
|
export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||||
({
|
task,
|
||||||
task,
|
hideTeamName,
|
||||||
hideTeamName,
|
showTeamName,
|
||||||
showTeamName,
|
renamingKey,
|
||||||
renamingKey,
|
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);
|
const { isLight } = useTheme();
|
||||||
const { isLight } = useTheme();
|
|
||||||
|
|
||||||
const isRenaming = renamingKey === `${task.teamName}:${task.id}`;
|
const isRenaming = renamingKey === `${task.teamName}:${task.id}`;
|
||||||
const displaySubject = getDisplaySubject?.(task) ?? task.subject;
|
const displaySubject = getDisplaySubject?.(task) ?? task.subject;
|
||||||
const [editValue, setEditValue] = useState(displaySubject);
|
const [editValue, setEditValue] = useState(displaySubject);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
// Focus input when rename starts
|
// Focus input when rename starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRenaming) return;
|
if (!isRenaming) return;
|
||||||
const raf = requestAnimationFrame(() => {
|
const raf = requestAnimationFrame(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
inputRef.current?.select();
|
inputRef.current?.select();
|
||||||
});
|
});
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [isRenaming]);
|
}, [isRenaming]);
|
||||||
|
|
||||||
// Reset edit value when renaming starts
|
// Reset edit value when renaming starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRenaming) {
|
if (isRenaming) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setEditValue(displaySubject);
|
setEditValue(displaySubject);
|
||||||
}
|
}
|
||||||
}, [isRenaming, displaySubject]);
|
}, [isRenaming, displaySubject]);
|
||||||
|
|
||||||
const reviewColumn = getTaskKanbanColumn(task);
|
const reviewColumn = getTaskKanbanColumn(task);
|
||||||
const cfg =
|
const cfg =
|
||||||
reviewColumn === 'approved'
|
reviewColumn === 'approved'
|
||||||
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
||||||
: reviewColumn === 'review'
|
: reviewColumn === 'review'
|
||||||
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
||||||
: (statusConfig[task.status] ?? statusConfig.pending);
|
: (statusConfig[task.status] ?? statusConfig.pending);
|
||||||
const StatusIcon = cfg.icon;
|
const StatusIcon = cfg.icon;
|
||||||
const updatedLabel = formatUpdatedLabel(task);
|
const updatedLabel = formatUpdatedLabel(task);
|
||||||
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
|
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
|
||||||
|
|
||||||
const ownerColorSet = useMemo(() => {
|
const ownerColorSet = useMemo(() => {
|
||||||
if (!teamMembers || !task.owner) return null;
|
if (!teamMembers || !task.owner) return null;
|
||||||
const colorMap = buildMemberColorMap(teamMembers);
|
const colorMap = buildMemberColorMap(teamMembers);
|
||||||
const colorName = colorMap.get(task.owner);
|
const colorName = colorMap.get(task.owner);
|
||||||
return colorName ? getTeamColorSet(colorName) : null;
|
return colorName ? getTeamColorSet(colorName) : null;
|
||||||
}, [teamMembers, task.owner]);
|
}, [teamMembers, task.owner]);
|
||||||
|
|
||||||
const ownerTextColor = useMemo(() => {
|
const ownerTextColor = useMemo(() => {
|
||||||
if (!ownerColorSet) return undefined;
|
if (!ownerColorSet) return undefined;
|
||||||
return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text;
|
return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text;
|
||||||
}, [ownerColorSet, isLight]);
|
}, [ownerColorSet, isLight]);
|
||||||
|
|
||||||
const projectLabel = useMemo(() => {
|
const projectLabel = useMemo(() => {
|
||||||
if (!task.projectPath?.trim()) return null;
|
if (!task.projectPath?.trim()) return null;
|
||||||
return projectLabelFromPath(task.projectPath);
|
return projectLabelFromPath(task.projectPath);
|
||||||
}, [task.projectPath]);
|
}, [task.projectPath]);
|
||||||
|
|
||||||
const projectColorSet = useMemo(
|
const projectColorSet = useMemo(
|
||||||
() => (projectLabel ? projectColor(projectLabel, isLight) : null),
|
() => (projectLabel ? projectColor(projectLabel, isLight) : null),
|
||||||
[projectLabel, isLight]
|
[projectLabel, isLight]
|
||||||
);
|
);
|
||||||
|
|
||||||
const teamColor = useMemo(
|
const teamColor = useMemo(
|
||||||
() => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null),
|
() => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null),
|
||||||
[showTeamName, task.teamDisplayName, isLight]
|
[showTeamName, task.teamDisplayName, isLight]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showTeamRow = showTeamName && !hideTeamName;
|
const showTeamRow = showTeamName && !hideTeamName;
|
||||||
const unreadBackgroundClass =
|
const unreadBackgroundClass =
|
||||||
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
|
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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' : ''}`}
|
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)' }}
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isRenaming) {
|
if (!isRenaming) {
|
||||||
openGlobalTaskDetail(task.teamName, task.id);
|
openGlobalTaskDetail(task.teamName, task.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Row 1: status + subject */}
|
{/* Row 1: status + subject */}
|
||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
<div className="flex items-start gap-1.5">
|
<div className="flex items-start gap-1.5">
|
||||||
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
|
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
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={() => {
|
|
||||||
const trimmed = editValue.trim();
|
const trimmed = editValue.trim();
|
||||||
if (trimmed && trimmed !== task.subject) {
|
if (trimmed && trimmed !== task.subject) {
|
||||||
onRenameComplete?.(task.teamName, task.id, trimmed);
|
onRenameComplete?.(task.teamName, task.id, trimmed);
|
||||||
} else {
|
} else {
|
||||||
onRenameCancel?.();
|
onRenameCancel?.();
|
||||||
}
|
}
|
||||||
}}
|
} else if (e.key === 'Escape') {
|
||||||
className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none"
|
e.preventDefault();
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
onRenameCancel?.();
|
||||||
onClick={(e) => e.stopPropagation()}
|
}
|
||||||
/>
|
}}
|
||||||
</div>
|
onBlur={() => {
|
||||||
) : (
|
const trimmed = editValue.trim();
|
||||||
<Tooltip>
|
if (trimmed && trimmed !== task.subject) {
|
||||||
<TooltipTrigger asChild>
|
onRenameComplete?.(task.teamName, task.id, trimmed);
|
||||||
<span
|
} else {
|
||||||
className="line-clamp-2 text-[13px] font-medium leading-tight"
|
onRenameCancel?.();
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
}
|
||||||
>
|
}}
|
||||||
<StatusIcon className={`mr-1.5 inline-block size-3 align-[-1px] ${cfg.color}`} />
|
className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none"
|
||||||
{unreadCount > 0 &&
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
(unreadCount === 1 ? (
|
onClick={(e) => e.stopPropagation()}
|
||||||
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" />
|
/>
|
||||||
) : (
|
</div>
|
||||||
<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}
|
<Tooltip>
|
||||||
</span>
|
<TooltipTrigger asChild>
|
||||||
))}
|
|
||||||
{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>}
|
|
||||||
<span
|
<span
|
||||||
className="shrink-0 opacity-100 dark:opacity-60"
|
className="line-clamp-2 text-[13px] font-medium leading-tight"
|
||||||
style={ownerTextColor ? { color: ownerTextColor } : undefined}
|
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>
|
</span>
|
||||||
</>
|
</TooltipTrigger>
|
||||||
)}
|
<TooltipContent side="right" sideOffset={6}>
|
||||||
{dateLabel && (
|
{displaySubject}
|
||||||
<span
|
</TooltipContent>
|
||||||
className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-100 dark:opacity-70' : ''}`}
|
</Tooltip>
|
||||||
>
|
)}
|
||||||
{dateLabel}
|
</div>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 3: Team: name · owner */}
|
{/* Row 2: project + owner (when no team row) + date */}
|
||||||
{showTeamRow && (
|
<div
|
||||||
<div
|
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
|
||||||
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
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>
|
{projectLabel}
|
||||||
<span className="shrink-0" style={teamColor ? { color: teamColor.text } : undefined}>
|
</span>
|
||||||
{task.teamDisplayName}
|
)}
|
||||||
</span>
|
{!showTeamRow && (
|
||||||
<span className="opacity-100 dark:opacity-40">·</span>
|
<>
|
||||||
|
{projectLabel && <span className="opacity-100 dark:opacity-40">·</span>}
|
||||||
<span
|
<span
|
||||||
className="shrink-0 opacity-100 dark:opacity-60"
|
className="shrink-0 opacity-100 dark:opacity-60"
|
||||||
style={ownerTextColor ? { color: ownerTextColor } : undefined}
|
style={ownerTextColor ? { color: ownerTextColor } : undefined}
|
||||||
>
|
>
|
||||||
{task.owner ?? 'unassigned'}
|
{task.owner ?? 'unassigned'}
|
||||||
</span>
|
</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
|
|
@ -45,7 +45,6 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
|
||||||
import { TeamEmptyState } from './TeamEmptyState';
|
import { TeamEmptyState } from './TeamEmptyState';
|
||||||
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
||||||
import {
|
import {
|
||||||
|
|
@ -54,13 +53,7 @@ import {
|
||||||
teamMatchesProjectSelection,
|
teamMatchesProjectSelection,
|
||||||
} from './teamProjectSelection';
|
} from './teamProjectSelection';
|
||||||
|
|
||||||
const CreateTeamDialog = lazy(() =>
|
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
||||||
import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog }))
|
|
||||||
);
|
|
||||||
const LaunchTeamDialog = lazy(() =>
|
|
||||||
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
|
|
||||||
);
|
|
||||||
|
|
||||||
import type { TeamListFilterState } from './TeamListFilterPopover';
|
import type { TeamListFilterState } from './TeamListFilterPopover';
|
||||||
import type { TeamStatus } from '@renderer/utils/teamListStatus';
|
import type { TeamStatus } from '@renderer/utils/teamListStatus';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -72,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);
|
||||||
|
|
@ -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 { isLight } = useTheme();
|
||||||
const electronMode = isElectronMode();
|
const electronMode = isElectronMode();
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -311,123 +311,119 @@ const SortableKanbanTaskCard = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KanbanBoard = memo(
|
export const KanbanBoard = memo(function KanbanBoard({
|
||||||
({
|
tasks,
|
||||||
tasks,
|
teamName,
|
||||||
teamName,
|
kanbanState,
|
||||||
kanbanState,
|
filter,
|
||||||
filter,
|
sort,
|
||||||
sort,
|
sessions,
|
||||||
sessions,
|
leadSessionId,
|
||||||
leadSessionId,
|
members,
|
||||||
members,
|
onFilterChange,
|
||||||
onFilterChange,
|
onSortChange,
|
||||||
onSortChange,
|
onRequestReview,
|
||||||
onRequestReview,
|
onApprove,
|
||||||
onApprove,
|
onRequestChanges,
|
||||||
onRequestChanges,
|
onMoveBackToDone,
|
||||||
onMoveBackToDone,
|
onStartTask,
|
||||||
onStartTask,
|
onCompleteTask,
|
||||||
onCompleteTask,
|
onCancelTask,
|
||||||
onCancelTask,
|
onScrollToTask,
|
||||||
onScrollToTask,
|
onTaskClick,
|
||||||
onTaskClick,
|
onViewChanges,
|
||||||
onViewChanges,
|
onColumnOrderChange,
|
||||||
onColumnOrderChange,
|
toolbarLeft,
|
||||||
toolbarLeft,
|
onAddTask,
|
||||||
onAddTask,
|
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');
|
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
|
||||||
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
|
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
|
||||||
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
|
const hasReviewers = kanbanState.reviewers.length > 0;
|
||||||
const hasReviewers = kanbanState.reviewers.length > 0;
|
const enableTaskSorting =
|
||||||
const enableTaskSorting =
|
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
|
||||||
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
|
|
||||||
|
|
||||||
const stableTaskMapRef = useRef<{
|
const stableTaskMapRef = useRef<{
|
||||||
signatures: string[];
|
signatures: string[];
|
||||||
map: Map<string, TeamTask>;
|
map: Map<string, TeamTask>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const taskMap = useMemo(() => {
|
const taskMap = useMemo(() => {
|
||||||
const signatures = tasks.map(
|
const signatures = tasks.map(
|
||||||
(task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}`
|
(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 previous = stableTaskMapRef.current;
|
||||||
|
if (
|
||||||
|
previous?.signatures.length === signatures.length &&
|
||||||
|
previous.signatures.every((signature, index) => signature === signatures[index])
|
||||||
|
) {
|
||||||
|
return previous.map;
|
||||||
|
}
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const next = new Map(tasks.map((task) => [task.id, task]));
|
||||||
(event: DragEndEvent) => {
|
stableTaskMapRef.current = { signatures, map: next };
|
||||||
const { active, over } = event;
|
return next;
|
||||||
if (!onColumnOrderChange || !over || active.id === over.id) {
|
}, [tasks]);
|
||||||
return;
|
const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
}
|
const grouped = useMemo(() => {
|
||||||
const activeData = active.data.current;
|
const result = new Map<KanbanColumnId, TeamTask[]>(
|
||||||
if (activeData?.type !== 'kanban-task') {
|
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
|
||||||
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]
|
|
||||||
);
|
);
|
||||||
|
for (const task of tasks) {
|
||||||
|
const column = getTaskColumn(task, kanbanState);
|
||||||
|
if (!column) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.get(column)?.push(task);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [tasks, kanbanState]);
|
||||||
|
|
||||||
const renderCards = (
|
const groupedOrdered = useMemo(() => {
|
||||||
columnId: KanbanColumnId,
|
const result = new Map<KanbanColumnId, TeamTask[]>();
|
||||||
columnTasks: TeamTask[],
|
for (const column of COLUMNS) {
|
||||||
compact?: boolean
|
const columnTasks = grouped.get(column.id) ?? [];
|
||||||
): React.JSX.Element => {
|
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 =
|
const addHandler =
|
||||||
onAddTask && columnId === 'todo'
|
onAddTask && columnId === 'todo'
|
||||||
? () => onAddTask(false)
|
? () => onAddTask(false)
|
||||||
|
|
@ -517,248 +513,266 @@ export const KanbanBoard = memo(
|
||||||
{addButton}
|
{addButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[
|
||||||
|
enableTaskSorting,
|
||||||
|
hasReviewers,
|
||||||
|
kanbanState,
|
||||||
|
memberColorMap,
|
||||||
|
onAddTask,
|
||||||
|
onApprove,
|
||||||
|
onCancelTask,
|
||||||
|
onCompleteTask,
|
||||||
|
onDeleteTask,
|
||||||
|
onMoveBackToDone,
|
||||||
|
onRequestChanges,
|
||||||
|
onRequestReview,
|
||||||
|
onScrollToTask,
|
||||||
|
onStartTask,
|
||||||
|
onTaskClick,
|
||||||
|
onViewChanges,
|
||||||
|
taskMap,
|
||||||
|
teamName,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
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),
|
||||||
[filter.columns]
|
[filter.columns]
|
||||||
);
|
);
|
||||||
const primaryVisibleColumnId = visibleColumns[0]?.id ?? null;
|
const primaryVisibleColumnId = visibleColumns[0]?.id ?? null;
|
||||||
|
|
||||||
const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]);
|
const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]);
|
||||||
const { widths: columnWidths, getHandleProps } = useResizableColumns({
|
const { widths: columnWidths, getHandleProps } = useResizableColumns({
|
||||||
storageKey: teamName,
|
storageKey: teamName,
|
||||||
columnIds: resizableColumnIds,
|
columnIds: resizableColumnIds,
|
||||||
});
|
});
|
||||||
const columnModeSearchWidth =
|
const columnModeSearchWidth =
|
||||||
primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256;
|
primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256;
|
||||||
const toolbarLeftWidth =
|
const toolbarLeftWidth =
|
||||||
viewMode === 'grid'
|
viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth;
|
||||||
? (gridPrimaryColumnWidth ?? columnModeSearchWidth)
|
|
||||||
: columnModeSearchWidth;
|
|
||||||
|
|
||||||
const clearScheduledScrollRestore = useCallback(() => {
|
const clearScheduledScrollRestore = useCallback(() => {
|
||||||
for (const timeoutId of scrollRestoreTimeoutsRef.current) {
|
for (const timeoutId of scrollRestoreTimeoutsRef.current) {
|
||||||
window.clearTimeout(timeoutId);
|
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 scheduleScrollRestore = useCallback(
|
||||||
|
(nextViewMode: KanbanViewMode, skeletonDelayMs: number) => {
|
||||||
const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => {
|
const container = findScrollContainer(boardRef.current);
|
||||||
let current = startNode?.parentElement ?? null;
|
if (!container) {
|
||||||
while (current) {
|
return;
|
||||||
const { overflowY } = window.getComputedStyle(current);
|
|
||||||
if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
current = current.parentElement;
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scheduleScrollRestore = useCallback(
|
const savedScrollTop = container.scrollTop;
|
||||||
(nextViewMode: KanbanViewMode, skeletonDelayMs: number) => {
|
clearScheduledScrollRestore();
|
||||||
const container = findScrollContainer(boardRef.current);
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedScrollTop = container.scrollTop;
|
const restore = (): void => {
|
||||||
clearScheduledScrollRestore();
|
container.scrollTop = savedScrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
const restore = (): void => {
|
const delays =
|
||||||
container.scrollTop = savedScrollTop;
|
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 =
|
const boardContent = (
|
||||||
nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120];
|
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
|
||||||
|
<div
|
||||||
scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay));
|
className={cn(
|
||||||
},
|
'flex min-w-0 max-w-full items-center gap-2',
|
||||||
[clearScheduledScrollRestore, findScrollContainer]
|
viewMode === 'columns' ? 'mb-0' : 'mb-2',
|
||||||
);
|
toolbarLeft == null && 'justify-end'
|
||||||
|
)}
|
||||||
const switchViewMode = useCallback(
|
>
|
||||||
(nextViewMode: KanbanViewMode) => {
|
{toolbarLeft != null && (
|
||||||
const nextSkeletonDelayMs =
|
<div className="min-w-0 max-w-full" style={{ width: toolbarLeftWidth }}>
|
||||||
nextViewMode === 'grid' && viewMode === 'columns'
|
{toolbarLeft}
|
||||||
? 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>
|
|
||||||
</div>
|
</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>
|
</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
|
|
@ -19,13 +19,13 @@ import {
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
import { ScheduleEmptyState } from './ScheduleEmptyState';
|
||||||
|
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
|
||||||
|
import { ScheduleRunRow } from './ScheduleRunRow';
|
||||||
|
import { ScheduleStatusBadge } from './ScheduleStatusBadge';
|
||||||
|
|
||||||
const LaunchTeamDialog = lazy(() =>
|
const LaunchTeamDialog = lazy(() =>
|
||||||
import('../dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
|
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';
|
import type { Schedule, ScheduleRun } from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface TaskRowProps {
|
||||||
task: TeamTaskWithKanban;
|
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 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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue