From f764af17d8a5e66ba1018a6b979ce64a24c4b52a Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 20:29:19 +0500 Subject: [PATCH] perf(renderer): wrap heavy view components in React.memo TeamDetailView (3166L), TeamListView (1180L), DateGroupedSessions (1117L), and MarkdownViewer (1198L) were re-rendering on every parent render cycle. Wrapping them in memo() prevents cascading re-renders when their props and store subscriptions have not changed, targeting VSCode-level UI responsiveness. --- .../chat/viewers/MarkdownViewer.tsx | 424 +- .../sidebar/DateGroupedSessions.tsx | 6 +- .../components/team/TeamDetailView.tsx | 4222 +++++++++-------- src/renderer/components/team/TeamListView.tsx | 6 +- 4 files changed, 2344 insertions(+), 2314 deletions(-) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 5bd3b977..2c7d3922 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -946,47 +946,200 @@ export const CompactMarkdownPreview: React.FC = Rea } ); -export const MarkdownViewer: React.FC = ({ - 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( + ({ + 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, - }; - }) - ); + // 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; - // 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 (
= ({ } } > + {/* Copy button overlay (when no label header) */} {copyable && !label && ( )} + {/* Optional header - matches CodeBlockViewer style */} {label && (
= ({ {label} - - Raw - - - - {copyable && } -
- )} - - {!label && ( -
- Raw preview - -
- )} - - {isTooLarge && ( -
- Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to - keep the UI responsive. + {copyable && ( + <> + + + + )}
)} + {/* Markdown content with scroll */}
-
-            {shown}
-          
- {isTruncated && ( -
- - Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars - -
- - -
-
- )} +
+ + {content} + +
); } - - // 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/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index fe578e07..39827dcb 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -4,7 +4,7 @@ * Supports multi-select with bulk actions and hidden session filtering. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; @@ -184,7 +184,7 @@ function matchesSessionSearch(session: Session, query: string): boolean { return haystack.includes(query); } -export const DateGroupedSessions = (): React.JSX.Element => { +export const DateGroupedSessions = memo((): React.JSX.Element => { const { sessions, selectedSessionId, @@ -1114,4 +1114,4 @@ export const DateGroupedSessions = (): React.JSX.Element => { ); -}; +}); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index dc992781..99f44e83 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -947,1734 +947,2117 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( ); }); -export const 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 +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) ); - - 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({ + const [createTaskDialog, setCreateTaskDialog] = useState({ 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({ + 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, - 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] - ); + }, [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] + ); - 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) => { - 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); - } else if (action.type === 'createTask') { - if (chip) { - setCreateTaskDialog({ - open: true, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - defaultStartImmediately: undefined, - defaultChip: chip, - }); - } else { - openCreateTaskDialog('', action.formattedContext); - } + // 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]); - 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]); + // 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); - // 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]); - - // 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 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) { + // 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 { - await softDeleteTask(teamName, taskId); + 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 + /* 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); } } })(); - }, - [teamName, softDeleteTask] - ); - const handleViewChanges = useCallback( - (taskId: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, - }); - }, - [taskMap] - ); + return () => { + cancelled = true; + }; + }, [projectId]); - 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); + // 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); } - }, - [selectReviewFile, taskMap] - ); - const handleDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(true); - }, []); + 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 []; - 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]); + 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; - 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 (member.gitBranch === nextGitBranch) { + return member; } - 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 -
+ const nextMember: ResolvedTeamMember = { ...member }; + if (nextGitBranch) { + nextMember.gitBranch = nextGitBranch; + } else { + delete nextMember.gitBranch; + } + return nextMember; + }); + }, [leadBranch, members, trackedBranches]); + const resolvedMemberColorMap = useMemo( + () => buildMemberColorMap(membersWithLiveBranches), + [membersWithLiveBranches] ); - } - const spawnStatusWatcher = ( - - ); - const teamAgentRuntimeWatcher = ( - - ); - const leadContextWatcher = shouldShowLeadContextUi ? ( - - ) : null; + // 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 renderBody = (): React.JSX.Element => { - if ((loading && !data) || (data && data.teamName !== teamName)) { + 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 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) => { + 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); + } else if (action.type === 'createTask') { + if (chip) { + setCreateTaskDialog({ + open: true, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + defaultStartImmediately: undefined, + defaultChip: chip, + }); + } else { + openCreateTaskDialog('', action.formattedContext); + } + } + }, + + [] + ); + + 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; + 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]); + + // 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 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) : {}, + }); + }, + [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 + } + } + + 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 (error === 'TEAM_DRAFT') { - const draftTeamSummary = useStore.getState().teamByName[teamName]; - const draftDisplayName = draftTeamSummary?.displayName || teamName; - const draftMemberCount = draftTeamSummary?.memberCount ?? 0; + const spawnStatusWatcher = ( + + ); + const teamAgentRuntimeWatcher = ( + + ); + const leadContextWatcher = shouldShowLeadContextUi ? ( + + ) : null; - return ( - <> -
+ const renderBody = (): React.JSX.Element => { + if ((loading && !data) || (data && data.teamName !== teamName)) { + 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. -

-
- - +
+
+
+
+
+
+ ); + } + + 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. +

+
+ + +
-
- - - ); - } - - 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); - - return ( - <> -
- - - {/* Messages sidebar (left, after context panel) */} - - + + ); + } + + 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); + + return ( + <> +
+ + + {/* Messages sidebar (left, after context panel) */} + - - - + isActive={isThisTabActive} + isFocused={isPaneFocused} + > + + + -
-
-
- {headerColorSet ? ( +
+
+
+ {headerColorSet ? ( +
+ ) : null}
- ) : null} -
-
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} + className={cn( + 'flex items-start justify-between gap-2', + headerColorSet && 'relative z-10' + )} + > +
+
+

+ {data.config.name} +

+ {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
-
-
- {data.isAlive && ( +
+ {data.isAlive && ( + + + + + Stop team + + )} + + + + + + {isTeamProvisioning + ? 'Edit team is unavailable while provisioning is still in progress' + : 'Edit team'} + + - Stop team + Delete team - )} - - - - - - {isTeamProvisioning - ? 'Edit team is unavailable while provisioning is still in progress' - : 'Edit team'} - - - - - - - Delete 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 - -
- {(() => { - 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(', ')} - + {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} + + )}
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( - openLaunchDialog('launch')} - /> - ) : null} - -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. + + + + + 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(', ')} + +
+ ); + })()}
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ -
+ {!data.isAlive && !isTeamProvisioning ? ( + openLaunchDialog('launch')} + /> + ) : null} + +
+ +
+ + {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={ +
+ +
+ } + > + +
+ + } + defaultOpen={false} + > + + setKanbanFilter((prev) => ({ ...prev, sessionId: id })) + } + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + action={ -
- } - > - -
- - } - 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 - } - })(); - }} - 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()}` + > + + } + 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 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.` + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); + } catch { + // best-effort } - } catch { - // best-effort } + } catch { + // error via store } - } 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 } + ); } - })(); - }} - 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'); + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => + openCreateTaskDialog('', '', '', startImmediately) + } + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + - // 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 - } - } + } + defaultOpen={false} + > + + - // 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) => { + {(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 updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); } catch { - // error via store + // 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, + }); + }} + /> + + + + !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); + } + })(); + }} + /> + + { + 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. + + + + + + + + + + + + { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + const result = await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + if ( + result?.runtimeDelivery?.attempted === true && + result.runtimeDelivery.delivered === false + ) { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + return result; + } catch (error) { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + throw error; + } + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -2688,479 +3071,124 @@ export const TeamDetailView = ({ ); } }} - 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, - }); - }} - /> - - - - !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, - }); + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} + onDeleteTask={handleDeleteTask} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages - will be preserved, but this name cannot be reused. - - - - - - - - + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All - team data and tasks will be deleted. - - - - - - - - - - - - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - const result = await sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - taskRefs, - }); - if ( - result?.runtimeDelivery?.attempted === true && - result.runtimeDelivery.delivered === false - ) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - return result; - } catch (error) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - throw error; + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open + ? {} + : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), + })) } - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - }} + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} + /> +
+
+ {messagesPanelMode === 'bottom-sheet' && ( + + )} +
+
- setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store - } - })(); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> - - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open - ? {} - : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + {editorOpen && data.config.projectPath && ( + + setEditorOpen(false)} onEditorAction={handleEditorAction} /> -
-
- {messagesPanelMode === 'bottom-sheet' && ( - - )} -
-
+ + )} - {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, + }); + } + }} + /> + + )} + + ); + }; - {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()} ); - }; - - return ( - <> - {spawnStatusWatcher} - {teamAgentRuntimeWatcher} - {leadContextWatcher} - {renderBody()} - - ); -}; + } +); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 357ee15a..cc07708c 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { api, isElectronMode } from '@renderer/api'; @@ -233,7 +233,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { } }; -export const TeamListView = (): React.JSX.Element => { +export const TeamListView = memo((): React.JSX.Element => { const { isLight } = useTheme(); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -1177,4 +1177,4 @@ export const TeamListView = (): React.JSX.Element => {
); -}; +});