From 053caed8b6141190abaffcc9fcd0e742869b707d Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 3 May 2026 08:57:59 +0500 Subject: [PATCH] fix(perf): resolve all PR #93 review blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/chat/DisplayItemList.tsx | 754 ++-- .../chat/viewers/MarkdownViewer.tsx | 422 +- .../components/schedules/SchedulesView.tsx | 4 +- .../components/sidebar/GlobalTaskList.tsx | 1244 +++--- .../components/sidebar/SessionItem.tsx | 456 +- .../components/sidebar/SidebarTaskItem.tsx | 376 +- .../components/team/TeamDetailView.tsx | 3898 ++++++++--------- src/renderer/components/team/TeamListView.tsx | 18 +- .../components/team/kanban/KanbanBoard.tsx | 696 +-- .../components/team/members/MemberCard.tsx | 1162 +++-- .../team/schedule/ScheduleSection.tsx | 6 +- .../components/team/tasks/TaskRow.tsx | 2 +- 12 files changed, 4537 insertions(+), 4501 deletions(-) diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index cd2c5754..8ae4e8c9 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { CODE_BG, @@ -65,9 +65,6 @@ function buildItemMetaTooltip( return parts.length > 0 ? parts.join(' • ') : undefined; } -/** - * Truncates text to a maximum length and adds ellipsis if needed. - */ function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; @@ -75,6 +72,345 @@ function truncateText(text: string, maxLength: number): string { return text.substring(0, maxLength) + '...'; } +function getItemKey(item: AIGroupDisplayItem, index: number): string { + switch (item.type) { + case 'thinking': + return `thinking-${index}`; + case 'output': + return `output-${index}`; + case 'tool': + return `tool-${item.tool.id}-${index}`; + case 'subagent': + return `subagent-${item.subagent.id}-${index}`; + case 'slash': + return `slash-${item.slash.name}-${index}`; + case 'teammate_message': + return `teammate-${item.teammateMessage.id}-${index}`; + case 'subagent_input': + return `input-${index}`; + case 'compact_boundary': + return `compact-${index}`; + default: + return `unknown-${index}`; + } +} + +// ============================================================================= +// Per-item row — memoized to prevent re-renders from parent state changes +// ============================================================================= + +interface DisplayItemRowProps { + item: AIGroupDisplayItem; + index: number; + itemKey: string; + isExpanded: boolean; + isDimmed: boolean; + hasReplyLink: boolean; + onItemClick: (key: string) => void; + onReplyHover: (toolId: string | null) => void; + aiGroupId: string; + searchQueryOverride?: string; + highlightToolUseId?: string; + highlightColor?: TriggerColor; + notificationColorMap?: Map; + 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 = ( + + ); + 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 = ( + + ); + break; + } + + case 'tool': { + element = ( + 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 = ( + + ); + break; + } + + case 'slash': { + element = ( + + ); + break; + } + + case 'teammate_message': { + element = ( + + ); + break; + } + + case 'subagent_input': { + const inputContent = item.content; + const inputTokenCount = item.tokenCount; + element = ( + } + 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} + > + + + ); + break; + } + + case 'compact_boundary': { + const compactContent = item.content; + element = ( +
+ + {isExpanded && compactContent && ( +
+
+ +
+
+ )} +
+ ); + break; + } + + default: + return null; + } + + return ( +
+ {element} +
+ ); +}); + +// ============================================================================= +// Main component +// ============================================================================= + /** * Renders a flat list of AIGroupDisplayItem[] into the appropriate components. * @@ -87,353 +423,75 @@ function truncateText(text: string, maxLength: number): string { * * The list is completely flat with no nested toggles or hierarchies. */ -export const DisplayItemList = React.memo( - ({ - items, - onItemClick, - expandedItemIds, - aiGroupId, - order = 'chronological', - searchQueryOverride, - highlightToolUseId, - highlightColor, - notificationColorMap, - registerToolRef, - previewMaxLength, - timestampFormat, - showItemMetaTooltip = false, - }: Readonly): React.JSX.Element => { - // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair - const [replyLinkToolId, setReplyLinkToolId] = useState(null); +export const DisplayItemList = React.memo(function DisplayItemList({ + items, + onItemClick, + expandedItemIds, + aiGroupId, + order = 'chronological', + searchQueryOverride, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, +}: Readonly): React.JSX.Element { + const [replyLinkToolId, setReplyLinkToolId] = useState(null); - const handleReplyHover = useCallback((toolId: string | null) => { - setReplyLinkToolId(toolId); - }, []); - - /** Check if an item is part of the currently highlighted reply link */ - const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { - if (!replyLinkToolId) return false; - if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; - if ( - item.type === 'teammate_message' && - item.teammateMessage.replyToToolId === replyLinkToolId - ) - return true; - return false; - }; - - if (!items || items.length === 0) { - return ( -
- No items to display -
- ); - } + const handleReplyHover = useCallback((toolId: string | null) => { + setReplyLinkToolId(toolId); + }, []); + if (!items || items.length === 0) { return ( -
- {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 = ( - 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 = ( - 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 = ( - 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 = ( - 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 = ( - 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 = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - onReplyHover={handleReplyHover} - /> - ); - break; - } - - case 'subagent_input': { - itemKey = `input-${index}`; - const inputContent = item.content; - const inputTokenCount = item.tokenCount; - element = ( - } - 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)} - > - - - ); - break; - } - - case 'compact_boundary': { - itemKey = `compact-${index}`; - const compactContent = item.content; - const compactExpanded = expandedItemIds.has(itemKey); - element = ( -
- - {compactExpanded && compactContent && ( -
-
- -
-
- )} -
- ); - break; - } - - default: - return null; - } - - // Apply reply-link spotlight: dim items not in the highlighted pair - const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); - return ( -
- {element} -
- ); - })} +
+ No items to display
); } -); + + return ( +
+ {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 ( + + ); + })} +
+ ); +}); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 2c7d3922..d5412610 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -946,200 +946,47 @@ export const CompactMarkdownPreview: React.FC = Rea } ); -export const MarkdownViewer: React.FC = React.memo( - ({ - content, - maxHeight = 'max-h-96', - className = '', - label, - itemId, - searchQueryOverride, - copyable = false, - bare = false, - baseDir, - teamColorByName: providedTeamColorByName, - onTeamClick: providedOnTeamClick, - }) => { - const [showRaw, setShowRaw] = React.useState(false); - const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); - const { isLight } = useTheme(); - const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( - providedTeamColorByName, - providedOnTeamClick - ); +export const MarkdownViewer: React.FC = React.memo(function MarkdownViewer({ + content, + maxHeight = 'max-h-96', + className = '', + label, + itemId, + searchQueryOverride, + copyable = false, + bare = false, + baseDir, + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, +}) { + const [showRaw, setShowRaw] = React.useState(false); + const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); + const { isLight } = useTheme(); + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); - const isTooLarge = content.length > MAX_MARKDOWN_CHARS; - const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; + const isTooLarge = content.length > MAX_MARKDOWN_CHARS; + const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; - // Only re-render if THIS item has search matches - const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => { - const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; - return { - searchQuery: hasMatch ? s.searchQuery : '', - searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, - currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, - }; - }) - ); - - // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). - // For large content, default to a lightweight raw preview with manual expansion. - if (isTooLarge || showRaw) { - const shown = content.slice(0, Math.min(rawLimit, content.length)); - const isTruncated = shown.length < content.length; - return ( -
- {copyable && !label && ( - - )} - - {label && ( -
- - - {label} - - - Raw - - - - {copyable && } -
- )} - - {!label && ( -
- Raw preview - -
- )} - - {isTooLarge && ( -
- Content is very large ({content.length.toLocaleString()} chars). Showing raw preview - to keep the UI responsive. -
- )} - -
-
-              {shown}
-            
- {isTruncated && ( -
- - Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars - -
- - -
-
- )} -
-
- ); - } - - // 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 ; - } - return {alt; - }, - } - : baseComponents; + // Only re-render if THIS item has search matches + const { searchQuery, searchMatches, currentSearchIndex } = useStore( + useShallow((s) => { + const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) + ); + // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). + // For large content, default to a lightweight raw preview with manual expansion. + if (isTooLarge || showRaw) { + const shown = content.slice(0, Math.min(rawLimit, content.length)); + const isTruncated = shown.length < content.length; return (
= React.memo( } } > - {/* Copy button overlay (when no label header) */} {copyable && !label && ( )} - {/* Optional header - matches CodeBlockViewer style */} {label && (
= React.memo( {label} - {copyable && ( - <> - - - - )} + + Raw + + + + {copyable && }
)} - {/* Markdown content with scroll */} -
-
- + Raw preview +
+ )} + + {isTooLarge && ( +
+ Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to + keep the UI responsive. +
+ )} + +
+
+            {shown}
+          
+ {isTruncated && ( +
+ + Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars + +
+ + +
+
+ )}
); } -); + + // 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 ; + } + return {alt; + }, + } + : baseComponents; + + return ( +
+ {/* Copy button overlay (when no label header) */} + {copyable && !label && ( + + )} + + {/* Optional header - matches CodeBlockViewer style */} + {label && ( +
+ + + {label} + + {copyable && ( + <> + + + + )} +
+ )} + + {/* Markdown content with scroll */} +
+
+ + {content} + +
+
+
+ ); +}); diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx index 3535cc28..96d72051 100644 --- a/src/renderer/components/schedules/SchedulesView.tsx +++ b/src/renderer/components/schedules/SchedulesView.tsx @@ -25,12 +25,12 @@ import { import { useShallow } from 'zustand/react/shallow'; import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog'; +import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; +import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; const LaunchTeamDialog = lazy(() => import('../team/dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) ); -import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; -import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types'; diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index d4a48051..f236f190 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -173,116 +173,118 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized); } -export const GlobalTaskList = memo( - ({ - hideHeader = false, - filters: externalFilters, - onFiltersChange: externalOnFiltersChange, - filtersPopoverOpen: externalFiltersPopoverOpen, - onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, - }: GlobalTaskListProps = {}): React.JSX.Element => { - const { - globalTasks, - globalTasksLoading, - globalTasksInitialized, - fetchAllTasks, - softDeleteTask, - projects, - viewMode, - repositoryGroups, - teams, - } = useStore( - useShallow((s) => ({ - globalTasks: s.globalTasks, - globalTasksLoading: s.globalTasksLoading, - globalTasksInitialized: s.globalTasksInitialized, - fetchAllTasks: s.fetchAllTasks, - softDeleteTask: s.softDeleteTask, - projects: s.projects, - viewMode: s.viewMode, - repositoryGroups: s.repositoryGroups, - teams: s.teams, - })) - ); +export const GlobalTaskList = memo(function GlobalTaskList({ + hideHeader = false, + filters: externalFilters, + onFiltersChange: externalOnFiltersChange, + filtersPopoverOpen: externalFiltersPopoverOpen, + onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, +}: GlobalTaskListProps = {}): React.JSX.Element { + const { + globalTasks, + globalTasksLoading, + globalTasksInitialized, + fetchAllTasks, + softDeleteTask, + projects, + viewMode, + repositoryGroups, + teams, + } = useStore( + useShallow((s) => ({ + globalTasks: s.globalTasks, + globalTasksLoading: s.globalTasksLoading, + globalTasksInitialized: s.globalTasksInitialized, + fetchAllTasks: s.fetchAllTasks, + softDeleteTask: s.softDeleteTask, + projects: s.projects, + viewMode: s.viewMode, + repositoryGroups: s.repositoryGroups, + teams: s.teams, + })) + ); - const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); - const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); - const filters = externalFilters ?? internalFilters; - const setFilters = externalOnFiltersChange ?? setInternalFilters; - const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; - const setFiltersPopoverOpen = - externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; - const [searchQuery, setSearchQuery] = useState(''); - const [groupingMode, setGroupingModeState] = useState(loadGroupingMode); - const [sortMode, setSortModeState] = useState(loadSortMode); - const [sortPopoverOpen, setSortPopoverOpen] = useState(false); - const [showArchived, setShowArchived] = useState(false); - const [renamingTaskKey, setRenamingTaskKey] = useState(null); - const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< - Record - >({}); - const searchInputRef = useRef(null); - const hasFetchedRef = useRef(false); - const readState = useReadStateSnapshot(); - const taskLocalState = useTaskLocalState(); + const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); + const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); + const filters = externalFilters ?? internalFilters; + const setFilters = externalOnFiltersChange ?? setInternalFilters; + const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; + const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; + const [searchQuery, setSearchQuery] = useState(''); + const [groupingMode, setGroupingModeState] = useState(loadGroupingMode); + const [sortMode, setSortModeState] = useState(loadSortMode); + const [sortPopoverOpen, setSortPopoverOpen] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [renamingTaskKey, setRenamingTaskKey] = useState(null); + const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< + Record + >({}); + const searchInputRef = useRef(null); + const hasFetchedRef = useRef(false); + const readState = useReadStateSnapshot(); + const taskLocalState = useTaskLocalState(); - // --- New-task animation tracking (same pattern as ChatHistory) --- - const knownTaskIdsRef = useRef>(new Set()); - const isInitialTaskLoadRef = useRef(true); + // --- New-task animation tracking (same pattern as ChatHistory) --- + const knownTaskIdsRef = useRef>(new Set()); + const isInitialTaskLoadRef = useRef(true); - const newTaskIds = useMemo(() => { - if (!globalTasksInitialized || globalTasks.length === 0) { - return new Set(); - } + const newTaskIds = useMemo(() => { + if (!globalTasksInitialized || globalTasks.length === 0) { + return new Set(); + } - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - if (isInitialTaskLoadRef.current) { - isInitialTaskLoadRef.current = false; - for (const t of globalTasks) { - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); - } - return new Set(); - } - - const newIds = new Set(); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + if (isInitialTaskLoadRef.current) { + isInitialTaskLoadRef.current = false; for (const t of globalTasks) { - const key = `${t.teamName}:${t.id}`; // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - if (!knownTaskIdsRef.current.has(key)) { - newIds.add(key); - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(key); - } + knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); } - return newIds; - }, [globalTasks, globalTasksInitialized]); + return new Set(); + } - const isNewTask = useCallback( - (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), - [newTaskIds] - ); + const newIds = new Set(); + for (const t of globalTasks) { + const key = `${t.teamName}:${t.id}`; + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + if (!knownTaskIdsRef.current.has(key)) { + newIds.add(key); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + knownTaskIdsRef.current.add(key); + } + } + return newIds; + }, [globalTasks, globalTasksInitialized]); - const setGroupingMode = (mode: TaskGroupingMode): void => { - setGroupingModeState(mode); - saveGroupingMode(mode); - }; + const isNewTask = useCallback( + (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), + [newTaskIds] + ); - const setSortMode = (mode: TaskSortMode): void => { - setSortModeState(mode); - saveSortMode(mode); - }; + const setGroupingMode = (mode: TaskGroupingMode): void => { + setGroupingModeState(mode); + saveGroupingMode(mode); + }; - const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => { + const setSortMode = (mode: TaskSortMode): void => { + setSortModeState(mode); + saveSortMode(mode); + }; + + const handleRenameComplete = useCallback( + (teamName: string, taskId: string, newSubject: string): void => { taskLocalState.renameTask(teamName, taskId, newSubject); setRenamingTaskKey(null); - }; + }, + [taskLocalState] + ); - const handleRenameCancel = (): void => { - setRenamingTaskKey(null); - }; + const handleRenameCancel = useCallback((): void => { + setRenamingTaskKey(null); + }, []); - const handleDeleteTask = async (teamName: string, taskId: string): Promise => { + const handleDeleteTask = useCallback( + async (teamName: string, taskId: string): Promise => { const confirmed = await confirm({ title: 'Delete task', message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, @@ -303,559 +305,555 @@ export const GlobalTaskList = memo( }); } } - }; + }, + [fetchAllTasks, softDeleteTask] + ); - // Fetch tasks on mount — loading guard in the store action prevents - // duplicate IPC calls when the centralized init chain is already fetching. - useEffect(() => { - if (!hasFetchedRef.current && !globalTasksLoading) { - hasFetchedRef.current = true; - void fetchAllTasks(); - } - }, [fetchAllTasks, globalTasksLoading]); + // Fetch tasks on mount — loading guard in the store action prevents + // duplicate IPC calls when the centralized init chain is already fetching. + useEffect(() => { + if (!hasFetchedRef.current && !globalTasksLoading) { + hasFetchedRef.current = true; + void fetchAllTasks(); + } + }, [fetchAllTasks, globalTasksLoading]); - // Build project combobox options from available projects/repos - const projectFilterOptions = useMemo((): ComboboxOption[] => { - const items = - viewMode === 'grouped' - ? repositoryGroups - .filter((r) => r.totalSessions > 0) - .map((r) => ({ - value: r.worktrees[0]?.path ?? r.id, - label: r.name, - path: r.worktrees[0]?.path, - })) - : projects - .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) - .map((p) => ({ - value: p.path, - label: p.name, - path: p.path, - })); + // Build project combobox options from available projects/repos + const projectFilterOptions = useMemo((): ComboboxOption[] => { + const items = + viewMode === 'grouped' + ? repositoryGroups + .filter((r) => r.totalSessions > 0) + .map((r) => ({ + value: r.worktrees[0]?.path ?? r.id, + label: r.name, + path: r.worktrees[0]?.path, + })) + : projects + .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) + .map((p) => ({ + value: p.path, + label: p.name, + path: p.path, + })); - return items.map((item) => ({ - value: item.value, - label: item.label, - description: item.path, - })); - }, [viewMode, repositoryGroups, projects]); + return items.map((item) => ({ + value: item.value, + label: item.label, + description: item.path, + })); + }, [viewMode, repositoryGroups, projects]); - // Resolve project filter from filters state - const selectedProjectPath = filters.projectPath; - const hasArchivedTasks = useMemo( - () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), - [globalTasks, taskLocalState] - ); - const effectiveShowArchived = showArchived && hasArchivedTasks; + // Resolve project filter from filters state + const selectedProjectPath = filters.projectPath; + const hasArchivedTasks = useMemo( + () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), + [globalTasks, taskLocalState] + ); + const effectiveShowArchived = showArchived && hasArchivedTasks; - const filtered = useMemo(() => { - let result = globalTasks; - result = applyProjectFilter(result, selectedProjectPath); - result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); - if (filters.teamName) { - result = result.filter((t) => t.teamName === filters.teamName); - } - if (filters.readFilter === 'unread') { - result = result.filter( - (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 - ); - } else if (filters.readFilter === 'read') { - result = result.filter( - (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) === 0 - ); - } - result = applySearch(result, searchQuery); - // Archive filtering - if (effectiveShowArchived) { - result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); - } else { - result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); - } - return result; - }, [ - globalTasks, - selectedProjectPath, - filters.statusIds, - filters.teamName, - filters.readFilter, - searchQuery, - readState, - effectiveShowArchived, - taskLocalState, - ]); + const filtered = useMemo(() => { + let result = globalTasks; + result = applyProjectFilter(result, selectedProjectPath); + result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); + if (filters.teamName) { + result = result.filter((t) => t.teamName === filters.teamName); + } + if (filters.readFilter === 'unread') { + result = result.filter( + (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 + ); + } else if (filters.readFilter === 'read') { + result = result.filter( + (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) === 0 + ); + } + result = applySearch(result, searchQuery); + // Archive filtering + if (effectiveShowArchived) { + result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); + } else { + result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); + } + return result; + }, [ + globalTasks, + selectedProjectPath, + filters.statusIds, + filters.teamName, + filters.readFilter, + searchQuery, + readState, + effectiveShowArchived, + taskLocalState, + ]); - // Split into pinned and normal (non-pinned) tasks - const pinnedTasks = useMemo( - () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] - ); - const normalTasks = useMemo( - () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] - ); + // Split into pinned and normal (non-pinned) tasks + const pinnedTasks = useMemo( + () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), + [filtered, taskLocalState] + ); + const normalTasks = useMemo( + () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), + [filtered, taskLocalState] + ); - const sortedFlat = useMemo( - () => applySortMode(normalTasks, sortMode, readState), - [normalTasks, sortMode, readState] - ); - const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); - const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); - const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); + const sortedFlat = useMemo( + () => applySortMode(normalTasks, sortMode, readState), + [normalTasks, sortMode, readState] + ); + const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); + const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); + const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); - // Collapsed group keys for each grouping mode - const projectGroupKeys = useMemo( - () => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey), - [projectGroups] - ); - const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); - const projectGroupVisibility = useMemo( - () => - projectGroups.map((group) => ({ - projectKey: group.projectKey, - taskCount: group.tasks.length, - })), - [projectGroups] - ); - const projectVisibleCountByKey = useMemo( - () => - syncProjectGroupVisibleCountByKey( - projectRequestedVisibleCountByKey, - projectGroupVisibility - ), - [projectRequestedVisibleCountByKey, projectGroupVisibility] - ); + // Collapsed group keys for each grouping mode + const projectGroupKeys = useMemo( + () => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey), + [projectGroups] + ); + const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); + const projectGroupVisibility = useMemo( + () => + projectGroups.map((group) => ({ + projectKey: group.projectKey, + taskCount: group.tasks.length, + })), + [projectGroups] + ); + const projectVisibleCountByKey = useMemo( + () => + syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility), + [projectRequestedVisibleCountByKey, projectGroupVisibility] + ); - const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); - const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); + const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); + const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); - const hasContent = - pinnedTasks.length > 0 || - (groupingMode === 'none' - ? sortedFlat.length > 0 - : groupingMode === 'time' - ? categories.length > 0 - : projectGroups.some((g) => g.tasks.length > 0)); + const hasContent = + pinnedTasks.length > 0 || + (groupingMode === 'none' + ? sortedFlat.length > 0 + : groupingMode === 'time' + ? categories.length > 0 + : projectGroups.some((g) => g.tasks.length > 0)); - const noProjectGroupColor = useMemo( - () => ({ - border: 'var(--color-border)', - glow: 'transparent', - icon: 'var(--color-text-muted)', - text: 'var(--color-text-secondary)', - }), - [] - ); + const noProjectGroupColor = useMemo( + () => ({ + border: 'var(--color-border)', + glow: 'transparent', + icon: 'var(--color-text-muted)', + text: 'var(--color-text-secondary)', + }), + [] + ); - return ( -
- {!hideHeader && ( -
- Tasks -
- )} - - {/* Search bar */} + return ( +
+ {!hideHeader && (
- - setSearchQuery(e.target.value)} - className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" - /> - {searchQuery && ( + Tasks +
+ )} + + {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" + /> + {searchQuery && ( + + )} + + - )} - - - - - -
- {SORT_OPTIONS.map((opt) => ( - - ))} -
-
-
- ({ teamName: t.teamName, displayName: t.displayName }))} - projectOptions={projectFilterOptions} - filters={filters} - onFiltersChange={setFilters} - onApply={() => {}} - /> -
- - {/* Pinned tasks section */} - {pinnedTasks.length > 0 && !effectiveShowArchived && ( -
-
- - Pinned -
- {sortTasksByFreshness(pinnedTasks).map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} -
- )} - - {/* Grouping mode — compact text toggle */} -
- Group by: -
- {(['none', 'project', 'time'] as const).map((mode) => { - const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; - return ( + + +
+ {SORT_OPTIONS.map((opt) => ( + ))} +
+
+ + ({ teamName: t.teamName, displayName: t.displayName }))} + projectOptions={projectFilterOptions} + filters={filters} + onFiltersChange={setFilters} + onApply={() => {}} + /> +
+ + {/* Pinned tasks section */} + {pinnedTasks.length > 0 && !effectiveShowArchived && ( +
+
+ + Pinned +
+ {sortTasksByFreshness(pinnedTasks).map((task) => ( + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + + + ))} +
+ )} + + {/* Grouping mode — compact text toggle */} +
+ Group by: +
+ {(['none', 'project', 'time'] as const).map((mode) => { + const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; + return ( + + ); + })} +
+ {/* Archive toggle — only visible when archived tasks exist */} + {hasArchivedTasks && ( +
+ + + - ); - })} + + + {effectiveShowArchived ? 'Hide archived' : 'Show archived'} + +
- {/* Archive toggle — only visible when archived tasks exist */} - {hasArchivedTasks && ( -
- - - - - - {effectiveShowArchived ? 'Hide archived' : 'Show archived'} - - -
- )} -
- - {/* Content */} -
- {globalTasksLoading && !globalTasksInitialized && ( -
- {[1, 2, 3].map((i) => ( -
- ))} -
- )} - - {globalTasksInitialized && !hasContent && ( -
- - - {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'} - -
- )} - - {groupingMode === 'none' && - sortedFlat.map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} - - {groupingMode === 'project' && - projectGroups.map((group) => { - if (group.tasks.length === 0) return null; - const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); - const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; - const groupColor = isNoProjectGroup - ? noProjectGroupColor - : projectColor(group.projectLabel); - const visibleCount = getProjectGroupVisibleCount( - projectVisibleCountByKey[group.projectKey], - group.tasks.length - ); - const visibleTasks = group.tasks.slice(0, visibleCount); - const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); - const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); - let lastTeam: string | null = null; - return ( -
- - {!isGroupCollapsed && - visibleTasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) - } - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - - taskLocalState.getRenamedSubject(t.teamName, t.id) - } - /> - - -
- ); - })} - {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( -
- {showMoreVisible && ( - - )} - {showLessVisible && ( - - )} -
- )} -
- ); - })} - - {groupingMode === 'time' && - categories.map((category) => { - const tasks = grouped[category]; - const isGroupCollapsed = timeCollapsed.isCollapsed(category); - let lastTeam: string | null = null; - - return ( -
- - - {!isGroupCollapsed && - tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) - } - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - - taskLocalState.getRenamedSubject(t.teamName, t.id) - } - /> - - -
- ); - })} -
- ); - })} -
+ )}
- ); - } -); + + {/* Content */} +
+ {globalTasksLoading && !globalTasksInitialized && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {globalTasksInitialized && !hasContent && ( +
+ + + {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'} + +
+ )} + + {groupingMode === 'none' && + sortedFlat.map((task) => ( + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + + + ))} + + {groupingMode === 'project' && + projectGroups.map((group) => { + if (group.tasks.length === 0) return null; + const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); + const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; + const groupColor = isNoProjectGroup + ? noProjectGroupColor + : projectColor(group.projectLabel); + const visibleCount = getProjectGroupVisibleCount( + projectVisibleCountByKey[group.projectKey], + group.tasks.length + ); + const visibleTasks = group.tasks.slice(0, visibleCount); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); + let lastTeam: string | null = null; + return ( +
+ + {!isGroupCollapsed && + visibleTasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) + } + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + + +
+ ); + })} + {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( +
+ {showMoreVisible && ( + + )} + {showLessVisible && ( + + )} +
+ )} +
+ ); + })} + + {groupingMode === 'time' && + categories.map((category) => { + const tasks = grouped[category]; + const isGroupCollapsed = timeCollapsed.isCollapsed(category); + let lastTeam: string | null = null; + + return ( +
+ + + {!isGroupCollapsed && + tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) + } + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + + +
+ ); + })} +
+ ); + })} +
+
+ ); +}); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index dbf534c4..f9726a75 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -155,243 +155,239 @@ const SessionRuntimeBadge = ({ ); }; -export const SessionItem = memo( - ({ - session, - isActive, - isPinned, - isHidden, - multiSelectActive, - isSelected, - }: Readonly): React.JSX.Element => { - const { - openTab, - activeProjectId, - selectSession, - paneCount, - splitPane, - togglePinSession, - toggleHideSession, - toggleSidebarSessionSelection, - } = useStore( - useShallow((s) => ({ - openTab: s.openTab, - activeProjectId: s.activeProjectId, - selectSession: s.selectSession, - paneCount: s.paneLayout.panes.length, - splitPane: s.splitPane, - togglePinSession: s.togglePinSession, - toggleHideSession: s.toggleHideSession, - toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, - })) +export const SessionItem = memo(function SessionItem({ + session, + isActive, + isPinned, + isHidden, + multiSelectActive, + isSelected, +}: Readonly): React.JSX.Element { + const { + openTab, + activeProjectId, + selectSession, + paneCount, + splitPane, + togglePinSession, + toggleHideSession, + toggleSidebarSessionSelection, + } = useStore( + useShallow((s) => ({ + openTab: s.openTab, + activeProjectId: s.activeProjectId, + selectSession: s.selectSession, + paneCount: s.paneLayout.panes.length, + splitPane: s.splitPane, + togglePinSession: s.togglePinSession, + toggleHideSession: s.toggleHideSession, + toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, + })) + ); + + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + + const handleClick = (event: React.MouseEvent): void => { + if (!activeProjectId) return; + + // In multi-select mode, clicks toggle selection + if (multiSelectActive) { + toggleSidebarSessionSelection(session.id); + return; + } + + // Cmd/Ctrl+click: open in new tab; plain click: replace current tab + const forceNewTab = event.ctrlKey || event.metaKey; + + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: formatSessionLabel(session.firstMessage), + }, + forceNewTab ? { forceNewTab } : { replaceActiveTab: true } ); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + selectSession(session.id); + }; - const handleClick = (event: React.MouseEvent): void => { - if (!activeProjectId) return; + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); - // In multi-select mode, clicks toggle selection - if (multiSelectActive) { - toggleSidebarSessionSelection(session.id); - return; - } + const sessionLabel = formatSessionLabel(session.firstMessage); - // Cmd/Ctrl+click: open in new tab; plain click: replace current tab - const forceNewTab = event.ctrlKey || event.metaKey; - - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: formatSessionLabel(session.firstMessage), - }, - forceNewTab ? { forceNewTab } : { replaceActiveTab: true } - ); - - selectSession(session.id); - }; - - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }, []); - - const sessionLabel = formatSessionLabel(session.firstMessage); - - const handleOpenInCurrentPane = useCallback(() => { - if (!activeProjectId) return; - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }, - { replaceActiveTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); - - const handleOpenInNewTab = useCallback(() => { - if (!activeProjectId) return; - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }, - { forceNewTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); - - const handleSplitRightAndOpen = useCallback(() => { - if (!activeProjectId) return; - // First open the tab in the focused pane - openTab({ + const handleOpenInCurrentPane = useCallback(() => { + if (!activeProjectId) return; + openTab( + { type: 'session', sessionId: session.id, projectId: activeProjectId, label: sessionLabel, - }); - selectSession(session.id); - // Then split it to the right - const state = useStore.getState(); - const focusedPaneId = state.paneLayout.focusedPaneId; - const activeTabId = state.activeTabId; - if (activeTabId) { - splitPane(focusedPaneId, activeTabId, 'right'); - } - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - - // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll - return ( - <> - - - {contextMenu && - activeProjectId && - createPortal( - setContextMenu(null)} - onOpenInCurrentPane={handleOpenInCurrentPane} - onOpenInNewTab={handleOpenInNewTab} - onSplitRightAndOpen={handleSplitRightAndOpen} - onTogglePin={() => void togglePinSession(session.id)} - onToggleHide={() => void toggleHideSession(session.id)} - />, - document.body - )} - + }, + { replaceActiveTab: true } ); - } -); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleOpenInNewTab = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { forceNewTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleSplitRightAndOpen = useCallback(() => { + if (!activeProjectId) return; + // First open the tab in the focused pane + openTab({ + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }); + selectSession(session.id); + // Then split it to the right + const state = useStore.getState(); + const focusedPaneId = state.paneLayout.focusedPaneId; + const activeTabId = state.activeTabId; + if (activeTabId) { + splitPane(focusedPaneId, activeTabId, 'right'); + } + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); + + // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll + return ( + <> + + + {contextMenu && + activeProjectId && + createPortal( + setContextMenu(null)} + onOpenInCurrentPane={handleOpenInCurrentPane} + onOpenInNewTab={handleOpenInNewTab} + onSplitRightAndOpen={handleSplitRightAndOpen} + onTogglePin={() => void togglePinSession(session.id)} + onToggleHide={() => void toggleHideSession(session.id)} + />, + document.body + )} + + ); +}); diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 5469b20f..ef6d959b 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -69,220 +69,218 @@ interface SidebarTaskItemProps { getDisplaySubject?: (task: GlobalTask) => string | undefined; } -export const SidebarTaskItem = memo( - ({ - task, - hideTeamName, - showTeamName, - renamingKey, - onRenameComplete, - onRenameCancel, - getDisplaySubject, - }: SidebarTaskItemProps): React.JSX.Element => { - const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); - const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); - const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); - const { isLight } = useTheme(); +export const SidebarTaskItem = memo(function SidebarTaskItem({ + task, + hideTeamName, + showTeamName, + renamingKey, + onRenameComplete, + onRenameCancel, + getDisplaySubject, +}: SidebarTaskItemProps): React.JSX.Element { + const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); + const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); + const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); + const { isLight } = useTheme(); - const isRenaming = renamingKey === `${task.teamName}:${task.id}`; - const displaySubject = getDisplaySubject?.(task) ?? task.subject; - const [editValue, setEditValue] = useState(displaySubject); - const inputRef = useRef(null); - // Focus input when rename starts - useEffect(() => { - if (!isRenaming) return; - const raf = requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); - return () => cancelAnimationFrame(raf); - }, [isRenaming]); + const isRenaming = renamingKey === `${task.teamName}:${task.id}`; + const displaySubject = getDisplaySubject?.(task) ?? task.subject; + const [editValue, setEditValue] = useState(displaySubject); + const inputRef = useRef(null); + // Focus input when rename starts + useEffect(() => { + if (!isRenaming) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); + }, [isRenaming]); - // Reset edit value when renaming starts - useEffect(() => { - if (isRenaming) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change - setEditValue(displaySubject); - } - }, [isRenaming, displaySubject]); + // Reset edit value when renaming starts + useEffect(() => { + if (isRenaming) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change + setEditValue(displaySubject); + } + }, [isRenaming, displaySubject]); - const reviewColumn = getTaskKanbanColumn(task); - const cfg = - reviewColumn === 'approved' - ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) - : reviewColumn === 'review' - ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) - : (statusConfig[task.status] ?? statusConfig.pending); - const StatusIcon = cfg.icon; - const updatedLabel = formatUpdatedLabel(task); - const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); + const reviewColumn = getTaskKanbanColumn(task); + const cfg = + reviewColumn === 'approved' + ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) + : reviewColumn === 'review' + ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) + : (statusConfig[task.status] ?? statusConfig.pending); + const StatusIcon = cfg.icon; + const updatedLabel = formatUpdatedLabel(task); + const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); - const ownerColorSet = useMemo(() => { - if (!teamMembers || !task.owner) return null; - const colorMap = buildMemberColorMap(teamMembers); - const colorName = colorMap.get(task.owner); - return colorName ? getTeamColorSet(colorName) : null; - }, [teamMembers, task.owner]); + const ownerColorSet = useMemo(() => { + if (!teamMembers || !task.owner) return null; + const colorMap = buildMemberColorMap(teamMembers); + const colorName = colorMap.get(task.owner); + return colorName ? getTeamColorSet(colorName) : null; + }, [teamMembers, task.owner]); - const ownerTextColor = useMemo(() => { - if (!ownerColorSet) return undefined; - return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; - }, [ownerColorSet, isLight]); + const ownerTextColor = useMemo(() => { + if (!ownerColorSet) return undefined; + return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; + }, [ownerColorSet, isLight]); - const projectLabel = useMemo(() => { - if (!task.projectPath?.trim()) return null; - return projectLabelFromPath(task.projectPath); - }, [task.projectPath]); + const projectLabel = useMemo(() => { + if (!task.projectPath?.trim()) return null; + return projectLabelFromPath(task.projectPath); + }, [task.projectPath]); - const projectColorSet = useMemo( - () => (projectLabel ? projectColor(projectLabel, isLight) : null), - [projectLabel, isLight] - ); + const projectColorSet = useMemo( + () => (projectLabel ? projectColor(projectLabel, isLight) : null), + [projectLabel, isLight] + ); - const teamColor = useMemo( - () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), - [showTeamName, task.teamDisplayName, isLight] - ); + const teamColor = useMemo( + () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), + [showTeamName, task.teamDisplayName, isLight] + ); - const showTeamRow = showTeamName && !hideTeamName; - const unreadBackgroundClass = - unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; + const showTeamRow = showTeamName && !hideTeamName; + const unreadBackgroundClass = + unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; - return ( -
+ + + {displaySubject} + + + )} +
- {/* Row 3: Team: name · owner */} - {showTeamRow && ( -
+ {task.teamDeleted && } + {projectLabel && ( + - Team: - - {task.teamDisplayName} - - · + {projectLabel} + + )} + {!showTeamRow && ( + <> + {projectLabel && ·} {task.owner ?? 'unassigned'} -
+ )} - - ); - } -); + {dateLabel && ( + + {dateLabel} + + )} +
+ + {/* Row 3: Team: name · owner */} + {showTeamRow && ( +
+ Team: + + {task.teamDisplayName} + + · + + {task.owner ?? 'unassigned'} + +
+ )} + + ); +}); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index fdd68f1c..011bb0df 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -79,7 +79,6 @@ import { useShallow } from 'zustand/react/shallow'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; -import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import { ReviewDialog } from './dialogs/ReviewDialog'; import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow'; import { KanbanBoard } from './kanban/KanbanBoard'; @@ -90,6 +89,7 @@ import { MemberDetailDialog } from './members/MemberDetailDialog'; import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; +import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { ComponentProps, CSSProperties } from 'react'; @@ -958,1235 +958,1223 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( ); }); -export const TeamDetailView = memo( - ({ teamName, isPaneFocused = false }: TeamDetailViewProps): React.JSX.Element => { - const { isLight } = useTheme(); - const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); - const [selectedMember, setSelectedMember] = useState(null); - const [selectedMemberView, setSelectedMemberView] = useState<{ - initialTab?: MemberDetailTab; - initialActivityFilter?: MemberActivityFilter; - } | null>(null); - const [pendingRepliesByMember, setPendingRepliesByMember] = useState>( - () => getTeamPendingRepliesState(teamName) +export const TeamDetailView = memo(function TeamDetailView({ + teamName, + isPaneFocused = false, +}: TeamDetailViewProps): React.JSX.Element { + const { isLight } = useTheme(); + const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [selectedMember, setSelectedMember] = useState(null); + const [selectedMemberView, setSelectedMemberView] = useState<{ + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } | null>(null); + const [pendingRepliesByMember, setPendingRepliesByMember] = useState>(() => + getTeamPendingRepliesState(teamName) + ); + const [createTaskDialog, setCreateTaskDialog] = useState({ + open: false, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + }); + const [creatingTask, setCreatingTask] = useState(false); + const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); + const [addingMemberLoading, setAddingMemberLoading] = useState(false); + const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); + const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [launchDialogState, setLaunchDialogState] = useState<{ + open: boolean; + mode: TeamLaunchDialogMode; + }>({ + open: false, + mode: 'launch', + }); + const [editorOpen, setEditorOpen] = useState(false); + const [graphOpen, setGraphOpen] = useState(false); + const contentRef = useRef(null); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); + const provisioningBannerRef = useRef(null); + const wasProvisioningRef = useRef(false); + const handleOpenGraphTab = useCallback(() => { + const state = useStore.getState(); + const displayName = state.teamByName[teamName]?.displayName ?? teamName; + state.openTab({ + type: 'graph', + label: `${displayName} Graph`, + teamName, + }); + }, [teamName]); + const visualizeButtonStyle = useMemo( + () => + isLight + ? { + background: + 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', + borderColor: 'rgba(59,130,246,0.30)', + color: '#0f172a', + boxShadow: '0 10px 24px rgba(59,130,246,0.12)', + } + : { + background: + 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', + borderColor: 'rgba(56,189,248,0.34)', + color: 'rgba(236,253,255,0.96)', + boxShadow: '0 12px 28px rgba(8,145,178,0.22)', + }, + [isLight] + ); + + // Set inert on background content when editor/graph overlay is open (a11y focus trap) + useEffect(() => { + const el = contentRef.current; + if (!el) return; + if (editorOpen || graphOpen) { + el.setAttribute('inert', ''); + } else { + el.removeAttribute('inert'); + } + }, [editorOpen, graphOpen]); + + // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.teamName === teamName) { + handleOpenGraphTab(); + } + }; + window.addEventListener('toggle-team-graph', handler); + return () => window.removeEventListener('toggle-team-graph', handler); + }, [handleOpenGraphTab, teamName]); + + // Listen for graph tab actions (open task, send message) + useEffect(() => { + const onOpenTask = (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + if (task) setSelectedTask(task); + }; + const onSendMsg = (e: Event) => { + const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }; + const onOpenProfile = (e: Event) => { + const { + teamName: tn, + memberName, + initialTab, + initialActivityFilter, + } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const member = members.find((m: { name: string }) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab, + initialActivityFilter, + }); + } + }; + const onCreateTask = (e: Event) => { + const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + openCreateTaskDialog('', '', owner ?? ''); + }; + window.addEventListener('graph:open-task', onOpenTask); + window.addEventListener('graph:send-message', onSendMsg); + window.addEventListener('graph:open-profile', onOpenProfile); + window.addEventListener('graph:create-task', onCreateTask); + + // Task action events from graph + const taskAction = (handler: (taskId: string) => void) => (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !taskId) return; + handler(taskId); + }; + const onStartTask = taskAction((taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } + } catch { + /* best-effort */ + } + } + } catch { + /* error via store */ + } + })(); + }); + const onCompleteTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onApproveTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + /* */ + } + })(); + }); + const onRequestReviewTask = taskAction((taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + /* */ + } + })(); + }); + const onRequestChangesTask = taskAction((taskId) => { + setRequestChangesTaskId(taskId); + }); + const onCancelTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'pending'); + } catch { + /* */ + } + })(); + }); + const onMoveBackToDoneTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); + + window.addEventListener('graph:start-task', onStartTask); + window.addEventListener('graph:complete-task', onCompleteTask); + window.addEventListener('graph:approve-task', onApproveTask); + window.addEventListener('graph:request-review', onRequestReviewTask); + window.addEventListener('graph:request-changes', onRequestChangesTask); + window.addEventListener('graph:cancel-task', onCancelTask); + window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.addEventListener('graph:delete-task', onDeleteTaskGraph); + return () => { + window.removeEventListener('graph:open-task', onOpenTask); + window.removeEventListener('graph:send-message', onSendMsg); + window.removeEventListener('graph:open-profile', onOpenProfile); + window.removeEventListener('graph:create-task', onCreateTask); + window.removeEventListener('graph:start-task', onStartTask); + window.removeEventListener('graph:complete-task', onCompleteTask); + window.removeEventListener('graph:approve-task', onApproveTask); + window.removeEventListener('graph:request-review', onRequestReviewTask); + window.removeEventListener('graph:request-changes', onRequestChangesTask); + window.removeEventListener('graph:cancel-task', onCancelTask); + window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.removeEventListener('graph:delete-task', onDeleteTaskGraph); + }; + }); + + const [sendDialogOpen, setSendDialogOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [stoppingTeam, setStoppingTeam] = useState(false); + const [trashOpen, setTrashOpen] = useState(false); + const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); + const [sendDialogDefaultText, setSendDialogDefaultText] = useState(undefined); + const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( + undefined + ); + const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( + undefined + ); + const [reviewDialogState, setReviewDialogState] = useState<{ + open: boolean; + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; + initialFilePath?: string; + taskChangeRequestOptions?: TaskChangeRequestOptions; + }>({ open: false, mode: 'task' }); + + // Active teams for conflict warning in LaunchTeamDialog + const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< + { teamName: string; displayName: string; projectPath: string }[] + >([]); + const launchDialogOpen = launchDialogState.open; + + // Session loading and filtering state + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionsError, setSessionsError] = useState(null); + const [kanbanFilter, setKanbanFilter] = useState({ + sessionId: null, + selectedOwners: new Set(), + columns: new Set(), + }); + const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); + + const { + data, + members, + loading, + error, + projects, + repositoryGroups, + initTabUIState, + selectTeam, + updateKanban, + updateKanbanColumnOrder, + updateTaskStatus, + updateTaskOwner, + sendTeamMessage, + requestReview, + createTeamTask, + startTaskByUser, + deleteTeam, + openTeamsTab, + closeTab, + sendingMessage, + sendMessageError, + sendMessageWarning, + sendMessageDebugDetails, + lastSendMessageResult, + reviewActionError, + addMember, + restartMember, + skipMemberForLaunch, + removeMember, + updateMemberRole, + launchTeam, + provisioningError, + clearProvisioningError, + isTeamProvisioning, + refreshTeamData, + refreshTeamMessagesHead, + refreshMemberActivityMeta, + syncTeamPendingReplyRefresh, + kanbanFilterQuery, + clearKanbanFilter, + softDeleteTask, + restoreTask, + fetchDeletedTasks, + deletedTasks, + launchParams, + messagesPanelMode, + messagesPanelWidth, + sidebarLogsHeight, + setMessagesPanelMode, + setMessagesPanelWidth, + setSidebarLogsHeight, + selectReviewFile, + pendingReviewRequest, + setPendingReviewRequest, + } = useStore( + useShallow((s) => ({ + projects: s.projects, + repositoryGroups: s.repositoryGroups, + initTabUIState: s.initTabUIState, + selectTeam: s.selectTeam, + updateKanban: s.updateKanban, + updateKanbanColumnOrder: s.updateKanbanColumnOrder, + updateTaskStatus: s.updateTaskStatus, + updateTaskOwner: s.updateTaskOwner, + sendTeamMessage: s.sendTeamMessage, + requestReview: s.requestReview, + createTeamTask: s.createTeamTask, + startTaskByUser: s.startTaskByUser, + deleteTeam: s.deleteTeam, + openTeamsTab: s.openTeamsTab, + closeTab: s.closeTab, + sendingMessage: s.sendingMessage, + sendMessageError: s.sendMessageError, + sendMessageWarning: s.sendMessageWarning, + sendMessageDebugDetails: s.sendMessageDebugDetails, + lastSendMessageResult: s.lastSendMessageResult, + reviewActionError: s.reviewActionError, + addMember: s.addMember, + restartMember: s.restartMember, + skipMemberForLaunch: s.skipMemberForLaunch, + removeMember: s.removeMember, + updateMemberRole: s.updateMemberRole, + launchTeam: s.launchTeam, + provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, + clearProvisioningError: s.clearProvisioningError, + isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, + data: s.selectedTeamName === teamName ? s.selectedTeamData : null, + members: selectResolvedMembersForTeamName(s, teamName), + loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, + error: s.selectedTeamName === teamName ? s.selectedTeamError : null, + refreshTeamData: s.refreshTeamData, + refreshTeamMessagesHead: s.refreshTeamMessagesHead, + refreshMemberActivityMeta: s.refreshMemberActivityMeta, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, + kanbanFilterQuery: s.kanbanFilterQuery, + clearKanbanFilter: s.clearKanbanFilter, + softDeleteTask: s.softDeleteTask, + restoreTask: s.restoreTask, + fetchDeletedTasks: s.fetchDeletedTasks, + deletedTasks: s.deletedTasks, + launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + sidebarLogsHeight: s.sidebarLogsHeight, + setMessagesPanelMode: s.setMessagesPanelMode, + setMessagesPanelWidth: s.setMessagesPanelWidth, + setSidebarLogsHeight: s.setSidebarLogsHeight, + selectReviewFile: s.selectReviewFile, + pendingReviewRequest: s.pendingReviewRequest, + setPendingReviewRequest: s.setPendingReviewRequest, + })) + ); + + const tabId = useTabIdOptional(); + const activeTabId = useStore((s) => s.activeTabId); + const isThisTabActive = tabId ? activeTabId === tabId : false; + const wasInteractiveRef = useRef(false); + + // Messages panel resize + const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = + useResizablePanel({ + width: messagesPanelWidth, + onWidthChange: setMessagesPanelWidth, + minWidth: 280, + maxWidth: 600, + side: 'left', + }); + const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({ + height: sidebarLogsHeight, + onHeightChange: setSidebarLogsHeight, + minHeight: 120, + maxHeight: 520, + side: 'top', + }); + + const changeMessagesPanelMode = useCallback( + (mode: TeamMessagesPanelMode) => { + setMessagesPanelMode(mode); + }, + [setMessagesPanelMode] + ); + + useEffect(() => { + if (tabId) { + initTabUIState(tabId); + } + }, [tabId, initTabUIState]); + + useEffect(() => { + setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); + }, [teamName]); + + useEffect(() => { + setTeamPendingRepliesState(teamName, pendingRepliesByMember); + }, [pendingRepliesByMember, teamName]); + + useEffect(() => { + const wasProvisioning = wasProvisioningRef.current; + wasProvisioningRef.current = isTeamProvisioning; + if (!wasProvisioning && isTeamProvisioning) { + provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [isTeamProvisioning]); + + const [kanbanSearch, setKanbanSearch] = useState(''); + + // Open editor overlay when a file reveal is requested (e.g. from chip click) + const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); + useEffect(() => { + if (pendingRevealFile && data?.config.projectPath) { + setEditorOpen(true); + } + }, [pendingRevealFile, data?.config.projectPath]); + + useEffect(() => { + if (!teamName) { + return; + } + void selectTeam(teamName); + void fetchDeletedTasks(teamName); + }, [teamName, selectTeam, fetchDeletedTasks]); + + // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. + // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins + // and other tabs get stuck with mismatched data (permanent skeleton). + // Re-trigger selectTeam when this tab becomes active and store data is stale. + const storedTeamName = data?.teamName; + useEffect(() => { + if (!isThisTabActive || !teamName || loading) return; + if (storedTeamName != null && storedTeamName !== teamName) { + void selectTeam(teamName); + } + }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); + + useEffect(() => { + const isInteractive = isThisTabActive && isPaneFocused; + const justBecameInteractive = isInteractive && !wasInteractiveRef.current; + wasInteractiveRef.current = isInteractive; + if (!justBecameInteractive || !teamName) { + return; + } + + void (async () => { + try { + const headResult = await refreshTeamMessagesHead(teamName); + if (headResult.feedChanged) { + await refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort refresh on tab focus. + } + })(); + }, [ + isPaneFocused, + isThisTabActive, + refreshMemberActivityMeta, + refreshTeamMessagesHead, + teamName, + ]); + + // Fetch active teams when launch dialog opens (for conflict warning) + useEffect(() => { + if (!launchDialogOpen) return; + let cancelled = false; + const teamsSnapshot = useStore.getState().teams; + void (async () => { + try { + const aliveList = await api.teams.aliveList(); + if (cancelled) return; + const aliveSet = new Set(aliveList); + const refs = teamsSnapshot + .filter((t) => aliveSet.has(t.teamName) && t.projectPath) + .map((t) => ({ + teamName: t.teamName, + displayName: t.displayName, + projectPath: t.projectPath!, + })); + setActiveTeamsForLaunch(refs); + } catch { + // best-effort + } + })(); + return () => { + cancelled = true; + }; + }, [launchDialogOpen]); + + useEffect(() => { + if (kanbanFilterQuery) { + setKanbanSearch(kanbanFilterQuery); + clearKanbanFilter(); + } + }, [kanbanFilterQuery, clearKanbanFilter]); + + // Load sessions for the team's project + const projectId = useMemo( + () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), + [projects, repositoryGroups, data?.config.projectPath] + ); + + const leadSessionId = data?.config.leadSessionId ?? null; + const pendingReplyRefreshSourceId = useId(); + const sessionHistoryKey = useMemo( + () => (data?.config.sessionHistory ?? []).join('|'), + [data?.config.sessionHistory] + ); + + // Keep team message state fresh while we are explicitly waiting for a reply. + // This stays enabled even for hidden mounted tabs, because the waiting state + // is renderer-local and should keep its lightweight polling until resolved. + useEffect(() => { + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + pendingReplyRefreshSourceId, + Boolean(data?.isAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS ); - const [createTaskDialog, setCreateTaskDialog] = useState({ + + return () => { + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); + }; + }, [ + data?.isAlive, + pendingRepliesByMember, + pendingReplyRefreshSourceId, + syncTeamPendingReplyRefresh, + teamName, + ]); + + useEffect(() => { + if (!projectId) return; + + let cancelled = false; + setSessionsLoading(true); + setSessionsError(null); + + void (async () => { + try { + const result = await api.getSessions(projectId); + if (!cancelled) { + setSessions(result); + } + } catch (e) { + if (!cancelled) { + setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); + } + } finally { + if (!cancelled) { + setSessionsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [projectId]); + + // Live git branch tracking for the lead project and member worktrees + const teamProjectPath = data?.config.projectPath?.trim() ?? null; + const leadProjectPath = useMemo(() => { + const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); + return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; + }, [members, teamProjectPath]); + const branchSyncPaths = useMemo(() => { + const uniquePaths = new Map(); + const addPath = (candidate: string | null | undefined): void => { + const trimmed = candidate?.trim(); + if (!trimmed) return; + const key = normalizePath(trimmed); + if (!key || uniquePaths.has(key)) return; + uniquePaths.set(key, trimmed); + }; + + addPath(leadProjectPath); + for (const member of members) { + addPath(member.cwd); + } + + return Array.from(uniquePaths.values()); + }, [members, leadProjectPath]); + useBranchSync(branchSyncPaths, { live: true }); + const trackedBranches = useStore( + useShallow((s) => + Object.fromEntries( + branchSyncPaths.map((projectPath) => { + const normalizedPath = normalizePath(projectPath); + return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; + }) + ) + ) + ); + const leadBranch = leadProjectPath + ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) + : null; + const membersWithLiveBranches = useMemo(() => { + if (!data) return []; + + return members.map((member) => { + const memberPath = member.cwd?.trim(); + const nextGitBranch = + memberPath && !isLeadMember(member) && leadBranch !== null + ? (() => { + const branch = trackedBranches[normalizePath(memberPath)] ?? null; + return branch && branch !== leadBranch ? branch : undefined; + })() + : undefined; + + if (member.gitBranch === nextGitBranch) { + return member; + } + + const nextMember: ResolvedTeamMember = { ...member }; + if (nextGitBranch) { + nextMember.gitBranch = nextGitBranch; + } else { + delete nextMember.gitBranch; + } + return nextMember; + }); + }, [leadBranch, members, trackedBranches]); + const resolvedMemberColorMap = useMemo( + () => buildMemberColorMap(membersWithLiveBranches), + [membersWithLiveBranches] + ); + + // Filter sessions to team-only using sessionHistory + leadSessionId + const teamSessionIds = useMemo(() => { + const sessionIds = new Set(); + if (data?.config.leadSessionId) { + sessionIds.add(data.config.leadSessionId); + } + if (data?.config.sessionHistory) { + for (const id of data.config.sessionHistory) { + sessionIds.add(id); + } + } + return sessionIds; + }, [data?.config.leadSessionId, data?.config.sessionHistory]); + + const teamSessions = useMemo(() => { + // If no session IDs known (backward compat), show all sessions + if (teamSessionIds.size === 0) return sessions; + return sessions.filter((s) => teamSessionIds.has(s.id)); + }, [sessions, teamSessionIds]); + + // Auto-reset session filter if the selected session is no longer in teamSessions + useEffect(() => { + if ( + kanbanFilter.sessionId !== null && + !teamSessions.some((s) => s.id === kanbanFilter.sessionId) + ) { + setKanbanFilter((prev) => ({ ...prev, sessionId: null })); + } + }, [kanbanFilter.sessionId, teamSessions]); + + // Compute time-window for session filtering + const timeWindow = useMemo(() => { + if (kanbanFilter.sessionId === null) return null; + + const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); + const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); + if (idx === -1) return null; + + const start = sorted[idx].createdAt; + const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; + return { start, end }; + }, [kanbanFilter.sessionId, teamSessions]); + + // Filter tasks by time-window and owner + const filteredTasks = useMemo(() => { + if (!data) return []; + let result = data.tasks; + + // Session time-window filter + if (timeWindow) { + result = result.filter((t) => { + if (!t.createdAt) return true; // legacy tasks always included + const ts = new Date(t.createdAt).getTime(); + return ts >= timeWindow.start && ts < timeWindow.end; + }); + } + + // Owner filter + if (kanbanFilter.selectedOwners.size > 0) { + result = result.filter((t) => + t.owner + ? kanbanFilter.selectedOwners.has(t.owner) + : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) + ); + } + + return result; + }, [data, timeWindow, kanbanFilter.selectedOwners]); + + const activeMembers = useStableActiveMembers(membersWithLiveBranches); + + const kanbanDisplayTasks = useMemo(() => { + const query = kanbanSearch.trim(); + if (!query) return filteredTasks; + return filterKanbanTasks(filteredTasks, query); + }, [filteredTasks, kanbanSearch]); + + const activeTeammateCount = useMemo( + () => activeMembers.filter((m) => !isLeadMember(m)).length, + [activeMembers] + ); + const leadProviderId = useMemo(() => { + const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; + if (activeLeadProviderId) return activeLeadProviderId; + const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; + if (configuredLeadProviderId) return configuredLeadProviderId; + return launchParams?.providerId; + }, [activeMembers, data?.config.members, launchParams?.providerId]); + const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); + + const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); + const taskMapRef = useRef(taskMap); + taskMapRef.current = taskMap; + + const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); + + const openCreateTaskDialog = useCallback( + (subject = '', description = '', owner = '', startImmediately?: boolean): void => { + setCreateTaskDialog({ + open: true, + defaultSubject: subject, + defaultDescription: description, + defaultOwner: owner, + defaultStartImmediately: startImmediately, + }); + }, + [] + ); + + const closeCreateTaskDialog = useCallback((): void => { + setCreateTaskDialog({ open: false, defaultSubject: '', defaultDescription: '', defaultOwner: '', + defaultStartImmediately: undefined, }); - const [creatingTask, setCreatingTask] = useState(false); - const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); - const [addingMemberLoading, setAddingMemberLoading] = useState(false); - const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); - const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [launchDialogState, setLaunchDialogState] = useState<{ - open: boolean; - mode: TeamLaunchDialogMode; - }>({ - open: false, - mode: 'launch', - }); - const [editorOpen, setEditorOpen] = useState(false); - const [graphOpen, setGraphOpen] = useState(false); - const contentRef = useRef(null); - const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( - null - ); - const provisioningBannerRef = useRef(null); - const wasProvisioningRef = useRef(false); - const handleOpenGraphTab = useCallback(() => { - const state = useStore.getState(); - const displayName = state.teamByName[teamName]?.displayName ?? teamName; - state.openTab({ - type: 'graph', - label: `${displayName} Graph`, + }, []); + + const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { + openCreateTaskDialog(subject, description); + }, []); + + const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }, []); + + const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { + setLaunchDialogState({ open: true, mode }); + }, []); + + const closeLaunchDialog = useCallback(() => { + setLaunchDialogState((prev) => ({ ...prev, open: false })); + }, []); + + const handleRestartTeam = useCallback(() => { + openLaunchDialog('relaunch'); + }, [openLaunchDialog]); + + const handleLaunchDialogSubmit = useCallback( + async (request: TeamLaunchRequest): Promise => { + await launchTeam(request); + }, + [launchTeam] + ); + + const handleRelaunchDialogSubmit = useCallback( + async ( + request: TeamLaunchRequest, + nextMembers: TeamCreateRequest['members'] + ): Promise => { + await executeTeamRelaunch({ teamName, + isTeamAlive: data?.isAlive === true, + request, + members: nextMembers, + stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), + replaceMembers: (nextTeamName, nextRequest) => + api.teams.replaceMembers(nextTeamName, nextRequest), + launchTeam, }); - }, [teamName]); - const visualizeButtonStyle = useMemo( - () => - isLight - ? { - background: - 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', - borderColor: 'rgba(59,130,246,0.30)', - color: '#0f172a', - boxShadow: '0 10px 24px rgba(59,130,246,0.12)', - } - : { - background: - 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', - borderColor: 'rgba(56,189,248,0.34)', - color: 'rgba(236,253,255,0.96)', - boxShadow: '0 12px 28px rgba(8,145,178,0.22)', - }, - [isLight] - ); + }, + [data?.isAlive, launchTeam, teamName] + ); - // Set inert on background content when editor/graph overlay is open (a11y focus trap) - useEffect(() => { - const el = contentRef.current; - if (!el) return; - if (editorOpen || graphOpen) { - el.setAttribute('inert', ''); - } else { - el.removeAttribute('inert'); - } - }, [editorOpen, graphOpen]); + const handleChangeLeadRuntime = useCallback(() => { + setEditDialogOpen(false); + openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); + }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); - // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (detail?.teamName === teamName) { - handleOpenGraphTab(); - } - }; - window.addEventListener('toggle-team-graph', handler); - return () => window.removeEventListener('toggle-team-graph', handler); - }, [handleOpenGraphTab, teamName]); + const handleRestartMember = useCallback( + async (memberName: string): Promise => { + await restartMember(teamName, memberName); + }, + [restartMember, teamName] + ); - // Listen for graph tab actions (open task, send message) - useEffect(() => { - const onOpenTask = (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - if (task) setSelectedTask(task); - }; - const onSendMsg = (e: Event) => { - const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); + const handleSkipMemberForLaunch = useCallback( + async (memberName: string): Promise => { + await skipMemberForLaunch(teamName, memberName); + }, + [skipMemberForLaunch, teamName] + ); + + const handleSelectMember = useCallback((member: ResolvedTeamMember) => { + setSelectedMember(member); + setSelectedMemberView(null); + }, []); + + const closeSelectedMemberDialog = useCallback(() => { + setSelectedMember(null); + setSelectedMemberView(null); + }, []); + + const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { + setSendDialogRecipient(member.name); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }, []); + + const handleAssignTaskToMember = useCallback( + (member: ResolvedTeamMember) => { + openCreateTaskDialog('', '', member.name); + }, + [openCreateTaskDialog] + ); + + const handleOpenTaskById = useCallback((taskId: string) => { + const task = taskMapRef.current.get(taskId); + if (task) { + setSelectedTask(task); + } + }, []); + + const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { + setSelectedTask(task); + }, []); + + const handleTaskIdClick = useCallback( + (taskId: string) => { + const task = + taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }, + [taskMap, data?.tasks] + ); + + const handleEditorAction = useCallback( + (action: EditorSelectionAction) => { + const chip = createChipFromSelection(action, []) ?? undefined; + if (action.type === 'sendMessage') { + setSendDialogDefaultText(chip ? undefined : action.formattedContext); + setSendDialogDefaultChip(chip); + setSendDialogRecipient(undefined); + setReplyQuote(undefined); setSendDialogOpen(true); - }; - const onOpenProfile = (e: Event) => { - const { - teamName: tn, - memberName, - initialTab, - initialActivityFilter, - } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const member = members.find((m: { name: string }) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab, - initialActivityFilter, + } else if (action.type === 'createTask') { + if (chip) { + setCreateTaskDialog({ + open: true, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + defaultStartImmediately: undefined, + defaultChip: chip, }); - } - }; - const onCreateTask = (e: Event) => { - const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - openCreateTaskDialog('', '', owner ?? ''); - }; - window.addEventListener('graph:open-task', onOpenTask); - window.addEventListener('graph:send-message', onSendMsg); - window.addEventListener('graph:open-profile', onOpenProfile); - window.addEventListener('graph:create-task', onCreateTask); - - // Task action events from graph - const taskAction = (handler: (taskId: string) => void) => (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !taskId) return; - handler(taskId); - }; - const onStartTask = taskAction((taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } - } catch { - /* best-effort */ - } - } - } catch { - /* error via store */ - } - })(); - }); - const onCompleteTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onApproveTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - } catch { - /* */ - } - })(); - }); - const onRequestReviewTask = taskAction((taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - /* */ - } - })(); - }); - const onRequestChangesTask = taskAction((taskId) => { - setRequestChangesTaskId(taskId); - }); - const onCancelTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'pending'); - } catch { - /* */ - } - })(); - }); - const onMoveBackToDoneTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); - - window.addEventListener('graph:start-task', onStartTask); - window.addEventListener('graph:complete-task', onCompleteTask); - window.addEventListener('graph:approve-task', onApproveTask); - window.addEventListener('graph:request-review', onRequestReviewTask); - window.addEventListener('graph:request-changes', onRequestChangesTask); - window.addEventListener('graph:cancel-task', onCancelTask); - window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.addEventListener('graph:delete-task', onDeleteTaskGraph); - return () => { - window.removeEventListener('graph:open-task', onOpenTask); - window.removeEventListener('graph:send-message', onSendMsg); - window.removeEventListener('graph:open-profile', onOpenProfile); - window.removeEventListener('graph:create-task', onCreateTask); - window.removeEventListener('graph:start-task', onStartTask); - window.removeEventListener('graph:complete-task', onCompleteTask); - window.removeEventListener('graph:approve-task', onApproveTask); - window.removeEventListener('graph:request-review', onRequestReviewTask); - window.removeEventListener('graph:request-changes', onRequestChangesTask); - window.removeEventListener('graph:cancel-task', onCancelTask); - window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.removeEventListener('graph:delete-task', onDeleteTaskGraph); - }; - }); - - const [sendDialogOpen, setSendDialogOpen] = useState(false); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [stoppingTeam, setStoppingTeam] = useState(false); - const [trashOpen, setTrashOpen] = useState(false); - const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); - const [sendDialogDefaultText, setSendDialogDefaultText] = useState( - undefined - ); - const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( - undefined - ); - const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( - undefined - ); - const [reviewDialogState, setReviewDialogState] = useState<{ - open: boolean; - mode: 'agent' | 'task'; - memberName?: string; - taskId?: string; - initialFilePath?: string; - taskChangeRequestOptions?: TaskChangeRequestOptions; - }>({ open: false, mode: 'task' }); - - // Active teams for conflict warning in LaunchTeamDialog - const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< - { teamName: string; displayName: string; projectPath: string }[] - >([]); - const launchDialogOpen = launchDialogState.open; - - // Session loading and filtering state - const [sessions, setSessions] = useState([]); - const [sessionsLoading, setSessionsLoading] = useState(false); - const [sessionsError, setSessionsError] = useState(null); - const [kanbanFilter, setKanbanFilter] = useState({ - sessionId: null, - selectedOwners: new Set(), - columns: new Set(), - }); - const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); - - const { - data, - members, - loading, - error, - projects, - repositoryGroups, - initTabUIState, - selectTeam, - updateKanban, - updateKanbanColumnOrder, - updateTaskStatus, - updateTaskOwner, - sendTeamMessage, - requestReview, - createTeamTask, - startTaskByUser, - deleteTeam, - openTeamsTab, - closeTab, - sendingMessage, - sendMessageError, - sendMessageWarning, - sendMessageDebugDetails, - lastSendMessageResult, - reviewActionError, - addMember, - restartMember, - skipMemberForLaunch, - removeMember, - updateMemberRole, - launchTeam, - provisioningError, - clearProvisioningError, - isTeamProvisioning, - refreshTeamData, - refreshTeamMessagesHead, - refreshMemberActivityMeta, - syncTeamPendingReplyRefresh, - kanbanFilterQuery, - clearKanbanFilter, - softDeleteTask, - restoreTask, - fetchDeletedTasks, - deletedTasks, - launchParams, - messagesPanelMode, - messagesPanelWidth, - sidebarLogsHeight, - setMessagesPanelMode, - setMessagesPanelWidth, - setSidebarLogsHeight, - selectReviewFile, - pendingReviewRequest, - setPendingReviewRequest, - } = useStore( - useShallow((s) => ({ - projects: s.projects, - repositoryGroups: s.repositoryGroups, - initTabUIState: s.initTabUIState, - selectTeam: s.selectTeam, - updateKanban: s.updateKanban, - updateKanbanColumnOrder: s.updateKanbanColumnOrder, - updateTaskStatus: s.updateTaskStatus, - updateTaskOwner: s.updateTaskOwner, - sendTeamMessage: s.sendTeamMessage, - requestReview: s.requestReview, - createTeamTask: s.createTeamTask, - startTaskByUser: s.startTaskByUser, - deleteTeam: s.deleteTeam, - openTeamsTab: s.openTeamsTab, - closeTab: s.closeTab, - sendingMessage: s.sendingMessage, - sendMessageError: s.sendMessageError, - sendMessageWarning: s.sendMessageWarning, - sendMessageDebugDetails: s.sendMessageDebugDetails, - lastSendMessageResult: s.lastSendMessageResult, - reviewActionError: s.reviewActionError, - addMember: s.addMember, - restartMember: s.restartMember, - skipMemberForLaunch: s.skipMemberForLaunch, - removeMember: s.removeMember, - updateMemberRole: s.updateMemberRole, - launchTeam: s.launchTeam, - provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, - clearProvisioningError: s.clearProvisioningError, - isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, - data: s.selectedTeamName === teamName ? s.selectedTeamData : null, - members: selectResolvedMembersForTeamName(s, teamName), - loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, - error: s.selectedTeamName === teamName ? s.selectedTeamError : null, - refreshTeamData: s.refreshTeamData, - refreshTeamMessagesHead: s.refreshTeamMessagesHead, - refreshMemberActivityMeta: s.refreshMemberActivityMeta, - syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, - kanbanFilterQuery: s.kanbanFilterQuery, - clearKanbanFilter: s.clearKanbanFilter, - softDeleteTask: s.softDeleteTask, - restoreTask: s.restoreTask, - fetchDeletedTasks: s.fetchDeletedTasks, - deletedTasks: s.deletedTasks, - launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, - messagesPanelMode: s.messagesPanelMode, - messagesPanelWidth: s.messagesPanelWidth, - sidebarLogsHeight: s.sidebarLogsHeight, - setMessagesPanelMode: s.setMessagesPanelMode, - setMessagesPanelWidth: s.setMessagesPanelWidth, - setSidebarLogsHeight: s.setSidebarLogsHeight, - selectReviewFile: s.selectReviewFile, - pendingReviewRequest: s.pendingReviewRequest, - setPendingReviewRequest: s.setPendingReviewRequest, - })) - ); - - const tabId = useTabIdOptional(); - const activeTabId = useStore((s) => s.activeTabId); - const isThisTabActive = tabId ? activeTabId === tabId : false; - const wasInteractiveRef = useRef(false); - - // Messages panel resize - const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = - useResizablePanel({ - width: messagesPanelWidth, - onWidthChange: setMessagesPanelWidth, - minWidth: 280, - maxWidth: 600, - side: 'left', - }); - const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = - useResizablePanel({ - height: sidebarLogsHeight, - onHeightChange: setSidebarLogsHeight, - minHeight: 120, - maxHeight: 520, - side: 'top', - }); - - const changeMessagesPanelMode = useCallback( - (mode: TeamMessagesPanelMode) => { - setMessagesPanelMode(mode); - }, - [setMessagesPanelMode] - ); - - useEffect(() => { - if (tabId) { - initTabUIState(tabId); - } - }, [tabId, initTabUIState]); - - useEffect(() => { - setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); - }, [teamName]); - - useEffect(() => { - setTeamPendingRepliesState(teamName, pendingRepliesByMember); - }, [pendingRepliesByMember, teamName]); - - useEffect(() => { - const wasProvisioning = wasProvisioningRef.current; - wasProvisioningRef.current = isTeamProvisioning; - if (!wasProvisioning && isTeamProvisioning) { - provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [isTeamProvisioning]); - - const [kanbanSearch, setKanbanSearch] = useState(''); - - // Open editor overlay when a file reveal is requested (e.g. from chip click) - const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); - useEffect(() => { - if (pendingRevealFile && data?.config.projectPath) { - setEditorOpen(true); - } - }, [pendingRevealFile, data?.config.projectPath]); - - useEffect(() => { - if (!teamName) { - return; - } - void selectTeam(teamName); - void fetchDeletedTasks(teamName); - }, [teamName, selectTeam, fetchDeletedTasks]); - - // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. - // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins - // and other tabs get stuck with mismatched data (permanent skeleton). - // Re-trigger selectTeam when this tab becomes active and store data is stale. - const storedTeamName = data?.teamName; - useEffect(() => { - if (!isThisTabActive || !teamName || loading) return; - if (storedTeamName != null && storedTeamName !== teamName) { - void selectTeam(teamName); - } - }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); - - useEffect(() => { - const isInteractive = isThisTabActive && isPaneFocused; - const justBecameInteractive = isInteractive && !wasInteractiveRef.current; - wasInteractiveRef.current = isInteractive; - if (!justBecameInteractive || !teamName) { - return; - } - - void (async () => { - try { - const headResult = await refreshTeamMessagesHead(teamName); - if (headResult.feedChanged) { - await refreshMemberActivityMeta(teamName); - } - } catch { - // Best-effort refresh on tab focus. - } - })(); - }, [ - isPaneFocused, - isThisTabActive, - refreshMemberActivityMeta, - refreshTeamMessagesHead, - teamName, - ]); - - // Fetch active teams when launch dialog opens (for conflict warning) - useEffect(() => { - if (!launchDialogOpen) return; - let cancelled = false; - const teamsSnapshot = useStore.getState().teams; - void (async () => { - try { - const aliveList = await api.teams.aliveList(); - if (cancelled) return; - const aliveSet = new Set(aliveList); - const refs = teamsSnapshot - .filter((t) => aliveSet.has(t.teamName) && t.projectPath) - .map((t) => ({ - teamName: t.teamName, - displayName: t.displayName, - projectPath: t.projectPath!, - })); - setActiveTeamsForLaunch(refs); - } catch { - // best-effort - } - })(); - return () => { - cancelled = true; - }; - }, [launchDialogOpen]); - - useEffect(() => { - if (kanbanFilterQuery) { - setKanbanSearch(kanbanFilterQuery); - clearKanbanFilter(); - } - }, [kanbanFilterQuery, clearKanbanFilter]); - - // Load sessions for the team's project - const projectId = useMemo( - () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), - [projects, repositoryGroups, data?.config.projectPath] - ); - - const leadSessionId = data?.config.leadSessionId ?? null; - const pendingReplyRefreshSourceId = useId(); - const sessionHistoryKey = useMemo( - () => (data?.config.sessionHistory ?? []).join('|'), - [data?.config.sessionHistory] - ); - - // Keep team message state fresh while we are explicitly waiting for a reply. - // This stays enabled even for hidden mounted tabs, because the waiting state - // is renderer-local and should keep its lightweight polling until resolved. - useEffect(() => { - const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; - syncTeamPendingReplyRefresh( - teamName, - pendingReplyRefreshSourceId, - Boolean(data?.isAlive) && hasPendingReplies, - TEAM_PENDING_REPLY_REFRESH_DELAY_MS - ); - - return () => { - syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); - }; - }, [ - data?.isAlive, - pendingRepliesByMember, - pendingReplyRefreshSourceId, - syncTeamPendingReplyRefresh, - teamName, - ]); - - useEffect(() => { - if (!projectId) return; - - let cancelled = false; - setSessionsLoading(true); - setSessionsError(null); - - void (async () => { - try { - const result = await api.getSessions(projectId); - if (!cancelled) { - setSessions(result); - } - } catch (e) { - if (!cancelled) { - setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); - } - } finally { - if (!cancelled) { - setSessionsLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [projectId]); - - // Live git branch tracking for the lead project and member worktrees - const teamProjectPath = data?.config.projectPath?.trim() ?? null; - const leadProjectPath = useMemo(() => { - const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); - return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; - }, [members, teamProjectPath]); - const branchSyncPaths = useMemo(() => { - const uniquePaths = new Map(); - const addPath = (candidate: string | null | undefined): void => { - const trimmed = candidate?.trim(); - if (!trimmed) return; - const key = normalizePath(trimmed); - if (!key || uniquePaths.has(key)) return; - uniquePaths.set(key, trimmed); - }; - - addPath(leadProjectPath); - for (const member of members) { - addPath(member.cwd); - } - - return Array.from(uniquePaths.values()); - }, [members, leadProjectPath]); - useBranchSync(branchSyncPaths, { live: true }); - const trackedBranches = useStore( - useShallow((s) => - Object.fromEntries( - branchSyncPaths.map((projectPath) => { - const normalizedPath = normalizePath(projectPath); - return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; - }) - ) - ) - ); - const leadBranch = leadProjectPath - ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) - : null; - const membersWithLiveBranches = useMemo(() => { - if (!data) return []; - - return members.map((member) => { - const memberPath = member.cwd?.trim(); - const nextGitBranch = - memberPath && !isLeadMember(member) && leadBranch !== null - ? (() => { - const branch = trackedBranches[normalizePath(memberPath)] ?? null; - return branch && branch !== leadBranch ? branch : undefined; - })() - : undefined; - - if (member.gitBranch === nextGitBranch) { - return member; - } - - const nextMember: ResolvedTeamMember = { ...member }; - if (nextGitBranch) { - nextMember.gitBranch = nextGitBranch; } else { - delete nextMember.gitBranch; - } - return nextMember; - }); - }, [leadBranch, members, trackedBranches]); - const resolvedMemberColorMap = useMemo( - () => buildMemberColorMap(membersWithLiveBranches), - [membersWithLiveBranches] - ); - - // Filter sessions to team-only using sessionHistory + leadSessionId - const teamSessionIds = useMemo(() => { - const sessionIds = new Set(); - if (data?.config.leadSessionId) { - sessionIds.add(data.config.leadSessionId); - } - if (data?.config.sessionHistory) { - for (const id of data.config.sessionHistory) { - sessionIds.add(id); + openCreateTaskDialog('', action.formattedContext); } } - return sessionIds; - }, [data?.config.leadSessionId, data?.config.sessionHistory]); + }, - const teamSessions = useMemo(() => { - // If no session IDs known (backward compat), show all sessions - if (teamSessionIds.size === 0) return sessions; - return sessions.filter((s) => teamSessionIds.has(s.id)); - }, [sessions, teamSessionIds]); + [] + ); - // Auto-reset session filter if the selected session is no longer in teamSessions - useEffect(() => { - if ( - kanbanFilter.sessionId !== null && - !teamSessions.some((s) => s.id === kanbanFilter.sessionId) - ) { - setKanbanFilter((prev) => ({ ...prev, sessionId: null })); - } - }, [kanbanFilter.sessionId, teamSessions]); + const handleStopTeam = useCallback(async (): Promise => { + setStoppingTeam(true); + try { + await api.teams.stop(teamName); + // Backend sends 'disconnected' progress which triggers store refresh, + // but refresh here too as a safety net (e.g. if progress event is missed). + await refreshTeamData(teamName); + } catch (err) { + console.error('Failed to stop team:', err); + } finally { + setStoppingTeam(false); + } + }, [teamName, refreshTeamData]); - // Compute time-window for session filtering - const timeWindow = useMemo(() => { - if (kanbanFilter.sessionId === null) return null; + // Pick up pending review request from GlobalTaskDetailDialog + useEffect(() => { + if (!pendingReviewRequest) return; + setReviewDialogState({ + open: true, + mode: 'task', + taskId: pendingReviewRequest.taskId, + initialFilePath: pendingReviewRequest.filePath, + taskChangeRequestOptions: pendingReviewRequest.requestOptions, + }); + if (pendingReviewRequest.filePath) { + selectReviewFile(pendingReviewRequest.filePath); + } + setPendingReviewRequest(null); + }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); - const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); - const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); - if (idx === -1) return null; - - const start = sorted[idx].createdAt; - const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; - return { start, end }; - }, [kanbanFilter.sessionId, teamSessions]); - - // Filter tasks by time-window and owner - const filteredTasks = useMemo(() => { - if (!data) return []; - let result = data.tasks; - - // Session time-window filter - if (timeWindow) { - result = result.filter((t) => { - if (!t.createdAt) return true; // legacy tasks always included - const ts = new Date(t.createdAt).getTime(); - return ts >= timeWindow.start && ts < timeWindow.end; - }); - } - - // Owner filter - if (kanbanFilter.selectedOwners.size > 0) { - result = result.filter((t) => - t.owner - ? kanbanFilter.selectedOwners.has(t.owner) - : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) - ); - } - - return result; - }, [data, timeWindow, kanbanFilter.selectedOwners]); - - const activeMembers = useStableActiveMembers(membersWithLiveBranches); - - const kanbanDisplayTasks = useMemo(() => { - const query = kanbanSearch.trim(); - if (!query) return filteredTasks; - return filterKanbanTasks(filteredTasks, query); - }, [filteredTasks, kanbanSearch]); - - const activeTeammateCount = useMemo( - () => activeMembers.filter((m) => !isLeadMember(m)).length, - [activeMembers] - ); - const leadProviderId = useMemo(() => { - const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; - if (activeLeadProviderId) return activeLeadProviderId; - const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; - if (configuredLeadProviderId) return configuredLeadProviderId; - return launchParams?.providerId; - }, [activeMembers, data?.config.members, launchParams?.providerId]); - const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); - - const taskMap = useMemo( - () => new Map((data?.tasks ?? []).map((t) => [t.id, t])), - [data?.tasks] - ); - const taskMapRef = useRef(taskMap); - taskMapRef.current = taskMap; - - const memberTaskCounts = useMemo( - () => buildTaskCountsByOwner(data?.tasks ?? []), - [data?.tasks] - ); - - const openCreateTaskDialog = useCallback( - (subject = '', description = '', owner = '', startImmediately?: boolean): void => { - setCreateTaskDialog({ - open: true, - defaultSubject: subject, - defaultDescription: description, - defaultOwner: owner, - defaultStartImmediately: startImmediately, - }); - }, - [] - ); - - const closeCreateTaskDialog = useCallback((): void => { - setCreateTaskDialog({ - open: false, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - defaultStartImmediately: undefined, - }); - }, []); - - const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { - openCreateTaskDialog(subject, description); - }, []); - - const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }, []); - - const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { - setLaunchDialogState({ open: true, mode }); - }, []); - - const closeLaunchDialog = useCallback(() => { - setLaunchDialogState((prev) => ({ ...prev, open: false })); - }, []); - - const handleRestartTeam = useCallback(() => { - openLaunchDialog('relaunch'); - }, [openLaunchDialog]); - - const handleLaunchDialogSubmit = useCallback( - async (request: TeamLaunchRequest): Promise => { - await launchTeam(request); - }, - [launchTeam] - ); - - const handleRelaunchDialogSubmit = useCallback( - async ( - request: TeamLaunchRequest, - nextMembers: TeamCreateRequest['members'] - ): Promise => { - await executeTeamRelaunch({ - teamName, - isTeamAlive: data?.isAlive === true, - request, - members: nextMembers, - stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), - replaceMembers: (nextTeamName, nextRequest) => - api.teams.replaceMembers(nextTeamName, nextRequest), - launchTeam, - }); - }, - [data?.isAlive, launchTeam, teamName] - ); - - const handleChangeLeadRuntime = useCallback(() => { - setEditDialogOpen(false); - openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); - }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); - - const handleRestartMember = useCallback( - async (memberName: string): Promise => { - await restartMember(teamName, memberName); - }, - [restartMember, teamName] - ); - - const handleSkipMemberForLaunch = useCallback( - async (memberName: string): Promise => { - await skipMemberForLaunch(teamName, memberName); - }, - [skipMemberForLaunch, teamName] - ); - - const handleSelectMember = useCallback((member: ResolvedTeamMember) => { + // Pick up pending member profile request from MemberHoverCard + const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); + useEffect(() => { + if (!pendingMemberProfile || !data) return; + const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); + if (member) { setSelectedMember(member); setSelectedMemberView(null); - }, []); + } + useStore.getState().closeMemberProfile(); + }, [pendingMemberProfile, membersWithLiveBranches]); - const closeSelectedMemberDialog = useCallback(() => { - setSelectedMember(null); - setSelectedMemberView(null); - }, []); - - const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { - setSendDialogRecipient(member.name); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }, []); - - const handleAssignTaskToMember = useCallback( - (member: ResolvedTeamMember) => { - openCreateTaskDialog('', '', member.name); - }, - [openCreateTaskDialog] - ); - - const handleOpenTaskById = useCallback((taskId: string) => { - const task = taskMapRef.current.get(taskId); - if (task) { - setSelectedTask(task); - } - }, []); - - const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { - setSelectedTask(task); - }, []); - - const handleTaskIdClick = useCallback( - (taskId: string) => { - const task = - taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); - }, - [taskMap, data?.tasks] - ); - - const handleEditorAction = useCallback( - (action: EditorSelectionAction) => { - const chip = createChipFromSelection(action, []) ?? undefined; - if (action.type === 'sendMessage') { - setSendDialogDefaultText(chip ? undefined : action.formattedContext); - setSendDialogDefaultChip(chip); - setSendDialogRecipient(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - } else if (action.type === 'createTask') { - if (chip) { - setCreateTaskDialog({ - open: true, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - defaultStartImmediately: undefined, - defaultChip: chip, - }); - } else { - openCreateTaskDialog('', action.formattedContext); + const handleDeleteTask = useCallback( + (taskId: string) => { + void (async () => { + const confirmed = await confirm({ + title: 'Delete task', + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (confirmed) { + try { + await softDeleteTask(teamName, taskId); + } catch { + // error via store } } - }, + })(); + }, + [teamName, softDeleteTask] + ); - [] - ); - - const handleStopTeam = useCallback(async (): Promise => { - setStoppingTeam(true); - try { - await api.teams.stop(teamName); - // Backend sends 'disconnected' progress which triggers store refresh, - // but refresh here too as a safety net (e.g. if progress event is missed). - await refreshTeamData(teamName); - } catch (err) { - console.error('Failed to stop team:', err); - } finally { - setStoppingTeam(false); - } - }, [teamName, refreshTeamData]); - - // Pick up pending review request from GlobalTaskDetailDialog - useEffect(() => { - if (!pendingReviewRequest) return; + const handleViewChanges = useCallback( + (taskId: string) => { + const task = taskMap.get(taskId); setReviewDialogState({ open: true, mode: 'task', - taskId: pendingReviewRequest.taskId, - initialFilePath: pendingReviewRequest.filePath, - taskChangeRequestOptions: pendingReviewRequest.requestOptions, + taskId, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, }); - if (pendingReviewRequest.filePath) { - selectReviewFile(pendingReviewRequest.filePath); + }, + [taskMap] + ); + + const handleViewChangesForFile = useCallback( + (taskId: string, filePath?: string) => { + const task = taskMap.get(taskId); + setReviewDialogState({ + open: true, + mode: 'task', + taskId, + initialFilePath: filePath, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + }); + if (filePath) { + selectReviewFile(filePath); } - setPendingReviewRequest(null); - }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); + }, + [selectReviewFile, taskMap] + ); - // Pick up pending member profile request from MemberHoverCard - const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); - useEffect(() => { - if (!pendingMemberProfile || !data) return; - const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); - if (member) { - setSelectedMember(member); - setSelectedMemberView(null); + const handleDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(true); + }, []); + + const confirmDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(false); + void (async () => { + try { + await deleteTeam(teamName); + if (tabId) closeTab(tabId); + openTeamsTab(); + } catch { + // error is shown via store } - useStore.getState().closeMemberProfile(); - }, [pendingMemberProfile, membersWithLiveBranches]); + })(); + }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); - const handleDeleteTask = useCallback( - (taskId: string) => { - void (async () => { - const confirmed = await confirm({ - title: 'Delete task', - message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, - confirmLabel: 'Delete', - cancelLabel: 'Cancel', - variant: 'danger', - }); - if (confirmed) { - try { - await softDeleteTask(teamName, taskId); - } catch { - // error via store - } - } - })(); - }, - [teamName, softDeleteTask] - ); - - const handleViewChanges = useCallback( - (taskId: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + const handleCreateTask = ( + subject: string, + description: string, + owner?: string, + blockedBy?: string[], + related?: string[], + prompt?: string, + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] + ): void => { + setCreatingTask(true); + void (async () => { + try { + await createTeamTask(teamName, { + subject, + description: description || undefined, + owner, + blockedBy, + related, + prompt, + descriptionTaskRefs, + promptTaskRefs, + startImmediately, }); - }, - [taskMap] - ); - const handleViewChangesForFile = useCallback( - (taskId: string, filePath?: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - initialFilePath: filePath, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, - }); - if (filePath) { - selectReviewFile(filePath); - } - }, - [selectReviewFile, taskMap] - ); - - const handleDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(true); - }, []); - - const confirmDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(false); - void (async () => { - try { - await deleteTeam(teamName); - if (tabId) closeTab(tabId); - openTeamsTab(); - } catch { - // error is shown via store - } - })(); - }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); - - const handleCreateTask = ( - subject: string, - description: string, - owner?: string, - blockedBy?: string[], - related?: string[], - prompt?: string, - startImmediately?: boolean, - descriptionTaskRefs?: TaskRef[], - promptTaskRefs?: TaskRef[] - ): void => { - setCreatingTask(true); - void (async () => { - try { - await createTeamTask(teamName, { - subject, - description: description || undefined, - owner, - blockedBy, - related, - prompt, - descriptionTaskRefs, - promptTaskRefs, - startImmediately, - }); - - if ( - prompt && - owner && - data?.isAlive && - !isTeamProvisioning && - startImmediately !== false - ) { - const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; - try { - await api.teams.processSend(teamName, msg); - } catch { - // best-effort - } + if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) { + const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; + try { + await api.teams.processSend(teamName, msg); + } catch { + // best-effort } - - closeCreateTaskDialog(); - } catch { - // error shown via store - } finally { - setCreatingTask(false); } - })(); - }; - const sharedMessagesPanelProps = useMemo( - () => ({ - teamName, - onPositionChange: changeMessagesPanelMode, - mountPoint: messagesPanelMountPoint, - members: activeMembers, - tasks: data?.tasks ?? [], - isTeamAlive: data?.isAlive, - timeWindow, - teamSessionIds, - currentLeadSessionId: data?.config.leadSessionId, - pendingRepliesByMember, - onPendingReplyChange: setPendingRepliesByMember, - onMemberClick: handleSelectMember, - onTaskClick: handleOpenTask, - onCreateTaskFromMessage: handleCreateTaskFromMessage, - onReplyToMessage: handleReplyToMessage, - onRestartTeam: handleRestartTeam, - onTaskIdClick: handleTaskIdClick, - inlineScrollContainerRef: contentRef, - }), - [ - activeMembers, - data?.config.leadSessionId, - data?.isAlive, - data?.tasks, - handleCreateTaskFromMessage, - handleOpenTask, - handleReplyToMessage, - handleRestartTeam, - handleSelectMember, - handleTaskIdClick, - messagesPanelMountPoint, - pendingRepliesByMember, - teamName, - teamSessionIds, - timeWindow, - changeMessagesPanelMode, - ] + closeCreateTaskDialog(); + } catch { + // error shown via store + } finally { + setCreatingTask(false); + } + })(); + }; + + const sharedMessagesPanelProps = useMemo( + () => ({ + teamName, + onPositionChange: changeMessagesPanelMode, + mountPoint: messagesPanelMountPoint, + members: activeMembers, + tasks: data?.tasks ?? [], + isTeamAlive: data?.isAlive, + timeWindow, + teamSessionIds, + currentLeadSessionId: data?.config.leadSessionId, + pendingRepliesByMember, + onPendingReplyChange: setPendingRepliesByMember, + onMemberClick: handleSelectMember, + onTaskClick: handleOpenTask, + onCreateTaskFromMessage: handleCreateTaskFromMessage, + onReplyToMessage: handleReplyToMessage, + onRestartTeam: handleRestartTeam, + onTaskIdClick: handleTaskIdClick, + inlineScrollContainerRef: contentRef, + }), + [ + activeMembers, + data?.config.leadSessionId, + data?.isAlive, + data?.tasks, + handleCreateTaskFromMessage, + handleOpenTask, + handleReplyToMessage, + handleRestartTeam, + handleSelectMember, + handleTaskIdClick, + messagesPanelMountPoint, + pendingRepliesByMember, + teamName, + teamSessionIds, + timeWindow, + changeMessagesPanelMode, + ] + ); + + if (!teamName) { + return ( +
+ Invalid team tab +
); + } - if (!teamName) { + const spawnStatusWatcher = ( + + ); + const teamAgentRuntimeWatcher = ( + + ); + const leadContextWatcher = shouldShowLeadContextUi ? ( + + ) : null; + + const renderBody = (): React.JSX.Element => { + if ((loading && !data) || (data && data.teamName !== teamName)) { return ( -
- Invalid team tab +
+
+
+ +
+
+
+
+
+
); } - const spawnStatusWatcher = ( - - ); - const teamAgentRuntimeWatcher = ( - - ); - const leadContextWatcher = shouldShowLeadContextUi ? ( - - ) : null; + if (error === 'TEAM_DRAFT') { + const draftTeamSummary = useStore.getState().teamByName[teamName]; + const draftDisplayName = draftTeamSummary?.displayName || teamName; + const draftMemberCount = draftTeamSummary?.memberCount ?? 0; - const renderBody = (): React.JSX.Element => { - if ((loading && !data) || (data && data.teamName !== teamName)) { - return ( -
-
+ return ( + <> +
-
-
-
-
-
-
- ); - } - - if (error === 'TEAM_DRAFT') { - const draftTeamSummary = useStore.getState().teamByName[teamName]; - const draftDisplayName = draftTeamSummary?.displayName || teamName; - const draftMemberCount = draftTeamSummary?.memberCount ?? 0; - - return ( - <> -
-
- -
-
-
-

Team not launched yet

-

- This is a draft team - {draftDisplayName} has been configured - with {draftMemberCount} member - {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. - Click Launch to select a model and start the team. -

-
- - -
+
+
+

Team not launched yet

+

+ This is a draft team - {draftDisplayName} has been configured + with {draftMemberCount} member + {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. + Click Launch to select a model and start the team. +

+
+ +
+
+ {launchDialogOpen && ( - - ); - } - - if (error) { - return ( -
-
-

Failed to load team

-

{error}

-
-
- ); - } - - if (!data) { - return ( -
-
- -
-
- Team data will appear once provisioning completes -
-
- ); - } - - const headerColorSet = data.config.color - ? getTeamColorSet(data.config.color) - : nameColorSet(data.config.name); + )} + + ); + } + if (error) { return ( - <> -
- +
+
+

Failed to load team

+

{error}

+
+
+ ); + } - {/* Messages sidebar (left, after context panel) */} - +
+ +
+
+ Team data will appear once provisioning completes +
+
+ ); + } + + const headerColorSet = data.config.color + ? getTeamColorSet(data.config.color) + : nameColorSet(data.config.name); + + return ( + <> +
+ + + {/* Messages sidebar (left, after context panel) */} + + - - - - + messagesPanelProps={sharedMessagesPanelProps} + isResizing={isMessagesPanelResizing} + onResizeMouseDown={messagesPanelHandleProps.onMouseDown} + logsHeight={sidebarLogsHeight} + isLogsResizing={isLogsPanelResizing} + onLogsResizeMouseDown={logsPanelHandleProps.onMouseDown} + /> + + -
-
-
- {headerColorSet ? ( -
- ) : null} +
+
+
+ {headerColorSet ? (
-
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} -
-
-
+ className="pointer-events-none absolute inset-0 z-0" + style={{ backgroundColor: getThemedBadge(headerColorSet, isLight) }} + /> + ) : null} +
+
+
+

+ {data.config.name} +

{data.isAlive && ( - - - - - Stop team - + + + Running + )} - - - - - - {isTeamProvisioning - ? 'Edit team is unavailable while provisioning is still in progress' - : 'Edit team'} - - + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
+
+
+ {data.isAlive && ( - Delete team + Stop team -
-
- {data.config.description && ( -

- {data.config.description} -

- )} -
-
- {data.config.projectPath && ( - - - - - - {data.config.projectPath - .replace(/\\/g, '/') - .split('/') - .filter(Boolean) - .pop() ?? data.config.projectPath} - - - - - {formatProjectPath(data.config.projectPath)} - - - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} -
- Open team graph + + {isTeamProvisioning + ? 'Edit team is unavailable while provisioning is still in progress' + : 'Edit team'} + + + + + + + Delete team
- {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter( - (p) => p !== currentPath - ); - if (!history || history.length === 0) return null; - return ( -
+ {data.config.description && ( +

+ {data.config.description} +

+ )} +
+
+ {data.config.projectPath && ( + + + + + + {data.config.projectPath + .replace(/\\/g, '/') + .split('/') + .filter(Boolean) + .pop() ?? data.config.projectPath} + + + + + {formatProjectPath(data.config.projectPath)} + + + + + + + + Open project in built-in editor + + + )} + {leadBranch && ( + - - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - -
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( - openLaunchDialog('launch')} - /> - ) : null} - -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. + + {leadBranch} + + )}
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ -
+ + + + Open team graph + +
+ {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} +
- } - > - -
+ ); + })()} +
- } - defaultOpen={false} - > - - setKanbanFilter((prev) => ({ ...prev, sessionId: id })) - } - projectPath={data.config.projectPath} - /> - + {!data.isAlive && !isTeamProvisioning ? ( + openLaunchDialog('launch')} + /> + ) : null} - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - action={ +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ +
- } - > - - } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { - op: 'set_column', - column: 'approved', - }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` - ); - } - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); - - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, - }); - } catch { - // best-effort - } - } - - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { - void (async () => { - try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); - } catch { - // error via store - } - })(); - }} - onScrollToTask={(taskId) => { - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => - openCreateTaskDialog('', '', '', startImmediately) - } - onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} - /> - - - } - defaultOpen={false} - > - - - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null - } - defaultOpen - > - - - )} - - {messagesPanelMode !== 'sidebar' && } - - {messagesPanelMode === 'inline' && ( - - )} - - setRequestChangesTaskId(null)} - onSubmit={(comment, taskRefs) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - taskRefs, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - + } + > + { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - openCreateTaskDialog('', '', name); - }} + onMemberClick={handleSelectMember} + onSendMessage={handleSendMessageToMember} + onAssignTask={handleAssignTaskToMember} + onOpenTask={handleOpenTaskById} onRestartMember={handleRestartMember} - onTaskClick={(task) => { - closeSelectedMemberDialog(); - setSelectedTask(task); + onSkipMemberForLaunch={handleSkipMemberForLaunch} + /> + + + } + defaultOpen={false} + > + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + action={ + + } + > + + } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { + op: 'set_column', + column: 'approved', + }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` + ); + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener( + 'animationend', + () => el.classList.remove('kanban-card-focus-pulse'), + { once: true } + ); } }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - closeSelectedMemberDialog(); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => + openCreateTaskDialog('', '', '', startImmediately) + } + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} /> + + } + defaultOpen={false} + > + + + + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + {messagesPanelMode !== 'sidebar' && } + + {messagesPanelMode === 'inline' && ( + + )} + + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; + } + void (async () => { + try { + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); + } catch { + // error state is handled in the store and shown in the view + } + })(); + }} + /> + + { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + setSendDialogRecipient(name || undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + openCreateTaskDialog('', '', name); + }} + onRestartMember={handleRestartMember} + onTaskClick={(task) => { + closeSelectedMemberDialog(); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + closeSelectedMemberDialog(); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} + /> + + {createTaskDialog.open && ( + )} - !isLeadMember(m))} - leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} - resolvedMemberColorMap={resolvedMemberColorMap} - isTeamAlive={data.isAlive && !isTeamProvisioning} - isTeamProvisioning={isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - onChangeLeadRuntime={handleChangeLeadRuntime} - onSaved={() => void selectTeam(teamName)} - /> + !isLeadMember(m))} + leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} + resolvedMemberColorMap={resolvedMemberColorMap} + isTeamAlive={data.isAlive && !isTeamProvisioning} + isTeamProvisioning={isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + onChangeLeadRuntime={handleChangeLeadRuntime} + onSaved={() => void selectTeam(teamName)} + /> - m.name)} - existingMembers={membersWithLiveBranches} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(entries: AddMemberEntry[]) => { - setAddingMemberLoading(true); - void (async () => { - try { - for (const entry of entries) { - await addMember(teamName, { - name: entry.name, - role: entry.role, - workflow: entry.workflow, - isolation: entry.isolation, - providerId: entry.providerId, - model: entry.model, - effort: entry.effort, - }); - } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); + m.name)} + existingMembers={membersWithLiveBranches} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(entries: AddMemberEntry[]) => { + setAddingMemberLoading(true); + void (async () => { + try { + for (const entry of entries) { + await addMember(teamName, { + name: entry.name, + role: entry.role, + workflow: entry.workflow, + isolation: entry.isolation, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + }); } - })(); - }} - /> + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); + } + })(); + }} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages - will be preserved, but this name cannot be reused. - - - - - - - - + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages + will be preserved, but this name cannot be reused. + + + + + + + + - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. - All team data and tasks will be deleted. - - - - - - - - + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All + team data and tasks will be deleted. + + + + + + + + + {launchDialogOpen && ( + )} + {sendDialogOpen && ( + )} + {selectedTask !== null && ( + )} - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> + {reviewDialogState.open && ( -
-
- {messagesPanelMode === 'bottom-sheet' && ( - )}
+
+ {messagesPanelMode === 'bottom-sheet' && ( + + )}
+
- {editorOpen && data.config.projectPath && ( - - setEditorOpen(false)} - onEditorAction={handleEditorAction} - /> - - )} + {editorOpen && data.config.projectPath && ( + + setEditorOpen(false)} + onEditorAction={handleEditorAction} + /> + + )} - {graphOpen && ( - - setGraphOpen(false)} - onPinAsTab={() => { - setGraphOpen(false); - useStore - .getState() - .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); - }} - onSendMessage={(memberName) => { - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); - }} - onOpenTaskDetail={(taskId) => { - const task = data.tasks.find((t) => t.id === taskId); - if (task) setSelectedTask(task); - }} - onOpenMemberProfile={(memberName, options) => { - const member = members.find((m) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab: options?.initialTab, - initialActivityFilter: options?.initialActivityFilter, - }); - } - }} - /> - - )} - - ); - }; - - return ( - <> - {spawnStatusWatcher} - {teamAgentRuntimeWatcher} - {leadContextWatcher} - {renderBody()} + {graphOpen && ( + + setGraphOpen(false)} + onPinAsTab={() => { + setGraphOpen(false); + useStore + .getState() + .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); + }} + onSendMessage={(memberName) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} + onOpenTaskDetail={(taskId) => { + const task = data.tasks.find((t) => t.id === taskId); + if (task) setSelectedTask(task); + }} + onOpenMemberProfile={(memberName, options) => { + const member = members.find((m) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab: options?.initialTab, + initialActivityFilter: options?.initialActivityFilter, + }); + } + }} + /> + + )} ); - } -); + }; + + return ( + <> + {spawnStatusWatcher} + {teamAgentRuntimeWatcher} + {leadContextWatcher} + {renderBody()} + + ); +}); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index df540b6e..5e597278 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -45,7 +45,6 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import { @@ -54,13 +53,7 @@ import { teamMatchesProjectSelection, } from './teamProjectSelection'; -const CreateTeamDialog = lazy(() => - import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) -); -const LaunchTeamDialog = lazy(() => - import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) -); - +import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamStatus } from '@renderer/utils/teamListStatus'; import type { @@ -72,6 +65,13 @@ import type { TeamSummaryMember, } from '@shared/types'; +const CreateTeamDialog = lazy(() => + import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) +); +const LaunchTeamDialog = lazy(() => + import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); + function generateUniqueName(sourceName: string, existingNames: string[]): string { const base = sourceName.replace(/-\d+$/, ''); const existing = new Set(existingNames); @@ -238,7 +238,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { } }; -export const TeamListView = memo((): React.JSX.Element => { +export const TeamListView = memo(function TeamListView(): React.JSX.Element { const { isLight } = useTheme(); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 056f0014..e5241390 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -311,123 +311,119 @@ const SortableKanbanTaskCard = ({ ); }; -export const KanbanBoard = memo( - ({ - tasks, - teamName, - kanbanState, - filter, - sort, - sessions, - leadSessionId, - members, - onFilterChange, - onSortChange, - onRequestReview, - onApprove, - onRequestChanges, - onMoveBackToDone, - onStartTask, - onCompleteTask, - onCancelTask, - onScrollToTask, - onTaskClick, - onViewChanges, - onColumnOrderChange, - toolbarLeft, - onAddTask, - onDeleteTask, - deletedTaskCount, - onOpenTrash, - }: KanbanBoardProps): React.JSX.Element => { - const boardRef = useRef(null); - const scrollRestoreTimeoutsRef = useRef([]); - const [viewMode, setViewMode] = useState('grid'); - const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); - const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); - const hasReviewers = kanbanState.reviewers.length > 0; - const enableTaskSorting = - viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; +export const KanbanBoard = memo(function KanbanBoard({ + tasks, + teamName, + kanbanState, + filter, + sort, + sessions, + leadSessionId, + members, + onFilterChange, + onSortChange, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, + onScrollToTask, + onTaskClick, + onViewChanges, + onColumnOrderChange, + toolbarLeft, + onAddTask, + onDeleteTask, + deletedTaskCount, + onOpenTrash, +}: KanbanBoardProps): React.JSX.Element { + const boardRef = useRef(null); + const scrollRestoreTimeoutsRef = useRef([]); + const [viewMode, setViewMode] = useState('grid'); + const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); + const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); + const hasReviewers = kanbanState.reviewers.length > 0; + const enableTaskSorting = + viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; - const stableTaskMapRef = useRef<{ - signatures: string[]; - map: Map; - } | null>(null); - const taskMap = useMemo(() => { - const signatures = tasks.map( - (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` - ); - const previous = stableTaskMapRef.current; - if ( - previous?.signatures.length === signatures.length && - previous.signatures.every((signature, index) => signature === signatures[index]) - ) { - return previous.map; - } - - const next = new Map(tasks.map((task) => [task.id, task])); - stableTaskMapRef.current = { signatures, map: next }; - return next; - }, [tasks]); - const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); - const grouped = useMemo(() => { - const result = new Map( - 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(); - for (const column of COLUMNS) { - const columnTasks = grouped.get(column.id) ?? []; - const order = kanbanState.columnOrder?.[column.id]; - result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); - } - return result; - }, [grouped, kanbanState.columnOrder, sort.field]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 8 }, - }) + const stableTaskMapRef = useRef<{ + signatures: string[]; + map: Map; + } | null>(null); + const taskMap = useMemo(() => { + const signatures = tasks.map( + (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` ); + const previous = stableTaskMapRef.current; + if ( + previous?.signatures.length === signatures.length && + previous.signatures.every((signature, index) => signature === signatures[index]) + ) { + return previous.map; + } - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!onColumnOrderChange || !over || active.id === over.id) { - return; - } - const activeData = active.data.current; - if (activeData?.type !== 'kanban-task') { - return; - } - const columnId = activeData.columnId as KanbanColumnId; - const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; - const oldIndex = orderedIds.indexOf(active.id as string); - const newIndex = orderedIds.indexOf(over.id as string); - if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { - return; - } - const newOrder = arrayMove(orderedIds, oldIndex, newIndex); - onColumnOrderChange(columnId, newOrder); - }, - [onColumnOrderChange, groupedOrdered] + const next = new Map(tasks.map((task) => [task.id, task])); + stableTaskMapRef.current = { signatures, map: next }; + return next; + }, [tasks]); + const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); + const grouped = useMemo(() => { + const result = new Map( + COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) ); + for (const task of tasks) { + const column = getTaskColumn(task, kanbanState); + if (!column) { + continue; + } + result.get(column)?.push(task); + } + return result; + }, [tasks, kanbanState]); - const renderCards = ( - columnId: KanbanColumnId, - columnTasks: TeamTask[], - compact?: boolean - ): React.JSX.Element => { + const groupedOrdered = useMemo(() => { + const result = new Map(); + for (const column of COLUMNS) { + const columnTasks = grouped.get(column.id) ?? []; + const order = kanbanState.columnOrder?.[column.id]; + result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); + } + return result; + }, [grouped, kanbanState.columnOrder, sort.field]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!onColumnOrderChange || !over || active.id === over.id) { + return; + } + const activeData = active.data.current; + if (activeData?.type !== 'kanban-task') { + return; + } + const columnId = activeData.columnId as KanbanColumnId; + const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; + const oldIndex = orderedIds.indexOf(active.id as string); + const newIndex = orderedIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return; + } + const newOrder = arrayMove(orderedIds, oldIndex, newIndex); + onColumnOrderChange(columnId, newOrder); + }, + [onColumnOrderChange, groupedOrdered] + ); + + const renderCards = useCallback( + (columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean): React.JSX.Element => { const addHandler = onAddTask && columnId === 'todo' ? () => onAddTask(false) @@ -517,248 +513,266 @@ export const KanbanBoard = memo( {addButton} ); - }; + }, + [ + enableTaskSorting, + hasReviewers, + kanbanState, + memberColorMap, + onAddTask, + onApprove, + onCancelTask, + onCompleteTask, + onDeleteTask, + onMoveBackToDone, + onRequestChanges, + onRequestReview, + onScrollToTask, + onStartTask, + onTaskClick, + onViewChanges, + taskMap, + teamName, + ] + ); - const visibleColumns = useMemo( - () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), - [filter.columns] - ); - const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; + const visibleColumns = useMemo( + () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), + [filter.columns] + ); + const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; - const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); - const { widths: columnWidths, getHandleProps } = useResizableColumns({ - storageKey: teamName, - columnIds: resizableColumnIds, - }); - const columnModeSearchWidth = - primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; - const toolbarLeftWidth = - viewMode === 'grid' - ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) - : columnModeSearchWidth; + const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); + const { widths: columnWidths, getHandleProps } = useResizableColumns({ + storageKey: teamName, + columnIds: resizableColumnIds, + }); + const columnModeSearchWidth = + primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; + const toolbarLeftWidth = + viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; - const clearScheduledScrollRestore = useCallback(() => { - for (const timeoutId of scrollRestoreTimeoutsRef.current) { - window.clearTimeout(timeoutId); + const clearScheduledScrollRestore = useCallback(() => { + for (const timeoutId of scrollRestoreTimeoutsRef.current) { + window.clearTimeout(timeoutId); + } + scrollRestoreTimeoutsRef.current = []; + }, []); + + useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); + + const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let current = startNode?.parentElement ?? null; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { + return current; } - scrollRestoreTimeoutsRef.current = []; - }, []); + current = current.parentElement; + } + return null; + }, []); - useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); - - const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { - let current = startNode?.parentElement ?? null; - while (current) { - const { overflowY } = window.getComputedStyle(current); - if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { - return current; - } - current = current.parentElement; + const scheduleScrollRestore = useCallback( + (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { + const container = findScrollContainer(boardRef.current); + if (!container) { + return; } - return null; - }, []); - const scheduleScrollRestore = useCallback( - (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { - const container = findScrollContainer(boardRef.current); - if (!container) { - return; - } + const savedScrollTop = container.scrollTop; + clearScheduledScrollRestore(); - const savedScrollTop = container.scrollTop; - clearScheduledScrollRestore(); + const restore = (): void => { + container.scrollTop = savedScrollTop; + }; - const restore = (): void => { - container.scrollTop = savedScrollTop; + const delays = + nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; + + scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); + }, + [clearScheduledScrollRestore, findScrollContainer] + ); + + const switchViewMode = useCallback( + (nextViewMode: KanbanViewMode) => { + const nextSkeletonDelayMs = + nextViewMode === 'grid' && viewMode === 'columns' + ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH + : SKELETON_HIDE_DELAY_MS; + + setGridSkeletonDelayMs(nextSkeletonDelayMs); + scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); + setViewMode(nextViewMode); + }, + [scheduleScrollRestore, viewMode] + ); + + const gridColumns = useMemo( + () => + visibleColumns.map((column) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + return { + id: column.id, + title: column.title, + count: columnTasks.length, + icon: accent.icon, + headerBg: accent.headerBg, + bodyBg: accent.bodyBg, + content: renderCards(column.id, columnTasks), + showAddButton: columnSupportsAddButton(column.id, onAddTask), + skeletonCards: columnTasks.map((task) => ({ + key: task.id, + height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), + })), }; + }), + [visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers] + ); - const delays = - nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; - - scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); - }, - [clearScheduledScrollRestore, findScrollContainer] - ); - - const switchViewMode = useCallback( - (nextViewMode: KanbanViewMode) => { - const nextSkeletonDelayMs = - nextViewMode === 'grid' && viewMode === 'columns' - ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH - : SKELETON_HIDE_DELAY_MS; - - setGridSkeletonDelayMs(nextSkeletonDelayMs); - scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); - setViewMode(nextViewMode); - }, - [scheduleScrollRestore, viewMode] - ); - - const boardContent = ( -
-
- {toolbarLeft != null && ( -
- {toolbarLeft} -
- )} -
-
- -
- -
- {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( - - - - - Trash - - ) : null} -
- - - - - Grid view - - - - - - Columns view - -
-
-
- - {viewMode === 'grid' ? ( - 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 - ), - })), - }; - })} - /> - ) : ( -
-
- {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 ( -
-
- - {renderCards(column.id, columnTasks, true)} - -
- {index < visibleColumns.length - 1 ? ( -
-
-
- ) : null} -
- ); - })} -
+ const boardContent = ( +
+
+ {toolbarLeft != null && ( +
+ {toolbarLeft}
)} +
+
+ +
+ +
+ {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( + + + + + Trash + + ) : null} +
+ + + + + Grid view + + + + + + Columns view + +
+
+ + {viewMode === 'grid' ? ( + column.id)} + primaryColumnId={primaryVisibleColumnId} + onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} + skeletonDelayMs={gridSkeletonDelayMs} + columns={gridColumns} + /> + ) : ( +
+
+ {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 ( +
+
+ + {renderCards(column.id, columnTasks, true)} + +
+ {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null} +
+ ); + })} +
+
+ )} +
+ ); + + if (enableTaskSorting) { + return ( + + {boardContent} + ); - - if (enableTaskSorting) { - return ( - - {boardContent} - - ); - } - - return boardContent; } -); + + return boardContent; +}); diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index dbfe9c34..70396058 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -91,632 +91,622 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): { }; } -export const MemberCard = memo( - ({ +export const MemberCard = memo(function MemberCard({ + member, + memberColor, + runtimeSummary, + runtimeEntry, + runtimeRunId, + taskCounts, + isTeamAlive, + isTeamProvisioning, + leadActivity, + currentTask, + reviewTask, + isAwaitingReply, + isRemoved, + spawnStatus, + spawnEntry, + spawnError, + spawnLivenessSource, + spawnLaunchState, + spawnRuntimeAlive, + isLaunchSettling, + onOpenTask, + onOpenReviewTask, + onClick, + onSendMessage, + onAssignTask, + onRestartMember, + onSkipMemberForLaunch, +}: MemberCardProps): React.JSX.Element { + // NOTE: lead context display disabled — usage formula is inaccurate + // const teamName = useStore((s) => s.selectedTeamName); + // const leadContext = useStore((s) => + // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + // ); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const [retryingLaunch, setRetryingLaunch] = useState(false); + const [retryLaunchError, setRetryLaunchError] = useState(null); + const [skippingLaunch, setSkippingLaunch] = useState(false); + const [skipLaunchError, setSkipLaunchError] = useState(null); + const teamMembers = useStore((s) => + selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] + ); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const launchPresentation = buildMemberLaunchPresentation({ member, - memberColor, - runtimeSummary, + spawnStatus, + spawnLaunchState, + spawnLivenessSource, + spawnRuntimeAlive, runtimeEntry, - runtimeRunId, - taskCounts, + runtimeAdvisory: member.runtimeAdvisory, + isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity, - currentTask, - reviewTask, - isAwaitingReply, - isRemoved, - spawnStatus, - spawnEntry, - spawnError, - spawnLivenessSource, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - onOpenTask, - onOpenReviewTask, - onClick, - onSendMessage, - onAssignTask, - onRestartMember, - onSkipMemberForLaunch, - }: MemberCardProps): React.JSX.Element => { - // NOTE: lead context display disabled — usage formula is inaccurate - // const teamName = useStore((s) => s.selectedTeamName); - // const leadContext = useStore((s) => - // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined - // ); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const [retryingLaunch, setRetryingLaunch] = useState(false); - const [retryLaunchError, setRetryLaunchError] = useState(null); - const [skippingLaunch, setSkippingLaunch] = useState(false); - const [skipLaunchError, setSkipLaunchError] = useState(null); - const teamMembers = useStore((s) => - selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] - ); - const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); - const launchPresentation = buildMemberLaunchPresentation({ - member, - spawnStatus, - spawnLaunchState, - spawnLivenessSource, - spawnRuntimeAlive, - runtimeEntry, - runtimeAdvisory: member.runtimeAdvisory, - isLaunchSettling, - isTeamAlive, - isTeamProvisioning, - leadActivity, - }); - const dotClass = launchPresentation.dotClass; - const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; - const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; - const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; - const presenceLabel = launchPresentation.presenceLabel; - const spawnCardClass = launchPresentation.cardClass; - const launchVisualState = launchPresentation.launchVisualState; - const launchStatusLabel = launchPresentation.launchStatusLabel; - const displayPresenceLabel = + }); + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; + const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; + const presenceLabel = launchPresentation.presenceLabel; + const spawnCardClass = launchPresentation.cardClass; + const launchVisualState = launchPresentation.launchVisualState; + const launchStatusLabel = launchPresentation.launchStatusLabel; + const displayPresenceLabel = + launchVisualState === 'queued' || + launchVisualState === 'runtime_pending' || + launchVisualState === 'permission_pending' || + launchVisualState === 'shell_only' || + launchVisualState === 'runtime_candidate' || + launchVisualState === 'registered_only' || + launchVisualState === 'stale_runtime' + ? (launchStatusLabel ?? presenceLabel) + : presenceLabel; + const colors = getTeamColorSet(memberColor); + const { isLight } = useTheme(); + const pending = taskCounts?.pending ?? 0; + const inProgress = taskCounts?.inProgress ?? 0; + const completed = taskCounts?.completed ?? 0; + const totalTasks = pending + inProgress + completed; + const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); + const { summary: runtimeSummaryText, memory: memoryLabel } = + splitRuntimeSummaryMemory(runtimeSummary); + const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); + const isLead = isLeadMember(member); + const workspacePath = member.cwd?.trim(); + const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; + const workspaceTooltipLines = [ + 'Worktree isolation is enabled.', + workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', + member.gitBranch ? `Branch: ${member.gitBranch}` : null, + ].filter((line): line is string => Boolean(line)); + const activityTask = currentTask ?? reviewTask ?? null; + const activityTitle = currentTask + ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` + : reviewTask + ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` + : undefined; + const showStartingSkeleton = + !isRemoved && + presenceLabel === 'starting' && + spawnLaunchState !== 'failed_to_start' && + !activityTask && + !runtimeSummary; + const showLaunchBadge = + !isRemoved && + !runtimeAdvisoryLabel && + (presenceLabel === 'starting' || + presenceLabel === 'connecting' || launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || - launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime' - ? (launchStatusLabel ?? presenceLabel) - : presenceLabel; - const colors = getTeamColorSet(memberColor); - const { isLight } = useTheme(); - const pending = taskCounts?.pending ?? 0; - const inProgress = taskCounts?.inProgress ?? 0; - const completed = taskCounts?.completed ?? 0; - const totalTasks = pending + inProgress + completed; - const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - const { summary: runtimeSummaryText, memory: memoryLabel } = - splitRuntimeSummaryMemory(runtimeSummary); - const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); - const isLead = isLeadMember(member); - const workspacePath = member.cwd?.trim(); - const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; - const workspaceTooltipLines = [ - 'Worktree isolation is enabled.', - workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', - member.gitBranch ? `Branch: ${member.gitBranch}` : null, - ].filter((line): line is string => Boolean(line)); - const activityTask = currentTask ?? reviewTask ?? null; - const activityTitle = currentTask - ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` - : reviewTask - ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` - : undefined; - const showStartingSkeleton = - !isRemoved && - presenceLabel === 'starting' && - spawnLaunchState !== 'failed_to_start' && - !activityTask && - !runtimeSummary; - const showLaunchBadge = - !isRemoved && - !runtimeAdvisoryLabel && - (presenceLabel === 'starting' || - presenceLabel === 'connecting' || - launchVisualState === 'queued' || - launchVisualState === 'runtime_pending' || - launchVisualState === 'shell_only' || - launchVisualState === 'runtime_candidate' || - launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime'); - const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; - const launchDiagnosticsPayload = useMemo( - () => - buildMemberLaunchDiagnosticsPayload({ - teamName: selectedTeamName, - runId: runtimeRunId, - memberName: member.name, - spawnStatus, - launchState: spawnLaunchState, - livenessSource: spawnLivenessSource, - spawnEntry, - runtimeEntry, - }), - [ - member.name, - runtimeEntry, - runtimeRunId, - selectedTeamName, - spawnEntry, - spawnLaunchState, - spawnLivenessSource, + launchVisualState === 'stale_runtime'); + const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; + const launchDiagnosticsPayload = useMemo( + () => + buildMemberLaunchDiagnosticsPayload({ + teamName: selectedTeamName, + runId: runtimeRunId, + memberName: member.name, spawnStatus, - ] - ); - const showCopyDiagnostics = - !isRemoved && - hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && - hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); - const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; - const isSkippedLaunch = - spawnStatus === 'skipped' || - spawnLaunchState === 'skipped_for_launch' || - spawnEntry?.skippedForLaunch === true; - const showFailedLaunchBadge = !isRemoved && isFailedLaunch; - const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; - const hasLiveLaunchControls = - isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; - const hasRestartMemberControl = - !isRemoved && - !isLeadMember(member) && - Boolean(onRestartMember) && - hasLiveLaunchControls && - runtimeEntry?.restartable !== false; - const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ - member, - spawnEntry, + launchState: spawnLaunchState, + livenessSource: spawnLivenessSource, + spawnEntry, + runtimeEntry, + }), + [ + member.name, runtimeEntry, - }); - const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; - const canRetryLaunch = - (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; - const canSkipFailedLaunch = - showFailedLaunchBadge && - !isLeadMember(member) && - Boolean(onSkipMemberForLaunch) && - hasLiveLaunchControls; - const showRuntimeAdvisoryBadge = - !isRemoved && - Boolean(runtimeAdvisoryLabel) && - !showLaunchBadge && - !isFailedLaunch && - !isSkippedLaunch && - (Boolean(activityTask) || !isAwaitingReply); - const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; - const restartActionBusyLabel = canRelaunchOpenCode - ? 'Relaunching OpenCode teammate' - : 'Retrying teammate'; - const restartActionErrorFallback = canRelaunchOpenCode - ? 'Failed to relaunch OpenCode teammate' - : 'Failed to retry teammate'; - const handleRestartMember = async ( - event: React.MouseEvent - ): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onRestartMember || retryingLaunch) { - return; - } - setRetryLaunchError(null); - setRetryingLaunch(true); - try { - await onRestartMember(member.name); - } catch (error) { - setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); - } finally { - setRetryingLaunch(false); - } - }; - const handleSkipFailedLaunch = async ( - event: React.MouseEvent - ): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onSkipMemberForLaunch || skippingLaunch) { - return; - } - setSkipLaunchError(null); - setSkippingLaunch(true); - try { - await onSkipMemberForLaunch(member.name); - } catch (error) { - setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); - } finally { - setSkippingLaunch(false); - } - }; + runtimeRunId, + selectedTeamName, + spawnEntry, + spawnLaunchState, + spawnLivenessSource, + spawnStatus, + ] + ); + const showCopyDiagnostics = + !isRemoved && + hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && + hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); + const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; + const isSkippedLaunch = + spawnStatus === 'skipped' || + spawnLaunchState === 'skipped_for_launch' || + spawnEntry?.skippedForLaunch === true; + const showFailedLaunchBadge = !isRemoved && isFailedLaunch; + const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; + const hasLiveLaunchControls = + isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; + const hasRestartMemberControl = + !isRemoved && + !isLeadMember(member) && + Boolean(onRestartMember) && + hasLiveLaunchControls && + runtimeEntry?.restartable !== false; + const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ + member, + spawnEntry, + runtimeEntry, + }); + const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; + const canRetryLaunch = + (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; + const canSkipFailedLaunch = + showFailedLaunchBadge && + !isLeadMember(member) && + Boolean(onSkipMemberForLaunch) && + hasLiveLaunchControls; + const showRuntimeAdvisoryBadge = + !isRemoved && + Boolean(runtimeAdvisoryLabel) && + !showLaunchBadge && + !isFailedLaunch && + !isSkippedLaunch && + (Boolean(activityTask) || !isAwaitingReply); + const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; + const restartActionBusyLabel = canRelaunchOpenCode + ? 'Relaunching OpenCode teammate' + : 'Retrying teammate'; + const restartActionErrorFallback = canRelaunchOpenCode + ? 'Failed to relaunch OpenCode teammate' + : 'Failed to retry teammate'; + const handleRestartMember = async (event: React.MouseEvent): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onRestartMember || retryingLaunch) { + return; + } + setRetryLaunchError(null); + setRetryingLaunch(true); + try { + await onRestartMember(member.name); + } catch (error) { + setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); + } finally { + setRetryingLaunch(false); + } + }; + const handleSkipFailedLaunch = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onSkipMemberForLaunch || skippingLaunch) { + return; + } + setSkipLaunchError(null); + setSkippingLaunch(true); + try { + await onSkipMemberForLaunch(member.name); + } catch (error) { + setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); + } finally { + setSkippingLaunch(false); + } + }; - return ( + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(); + } + }} > -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick?.(); - } - }} - > -
-
-
-
- {member.name} -
- +
+
+
+
+ {member.name}
-
-
- - {displayMemberName(member.name)} + +
+
+
+ + {displayMemberName(member.name)} + + {member.gitBranch && !showWorkspaceBadge ? ( + + + {member.gitBranch} - {member.gitBranch && !showWorkspaceBadge ? ( - - - {member.gitBranch} - - ) : null} - {showWorkspaceBadge ? ( - - - - worktree - - - -
- {workspaceTooltipLines.map((line) => ( -

- {line} -

- ))} -
-
-
- ) : null} - {currentTask ? ( - - ) : null} - {reviewTask ? ( - - ) : null} - {!activityTask && isAwaitingReply ? ( - <> - {runtimeAdvisoryTone === 'error' ? ( - - ) : ( - - )} - - {runtimeAdvisoryLabel ?? 'awaiting reply'} - - - ) : null} -
- {showStartingSkeleton ? ( - + {showStartingSkeleton ? ( +