import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useResizablePanel } from '@renderer/hooks/useResizablePanel'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, isTeamProvisioningActive, } from '@renderer/store/slices/teamSlice'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; import { buildTaskChangeRequestOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { deriveContextMetrics } from '@shared/utils/contextMetrics'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, Clock, Code, Columns3, FolderOpen, GitBranch, History, Network, Pencil, Play, Plus, Square, Terminal, Trash2, UserPlus, Users, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog'; import { ReviewDialog } from './dialogs/ReviewDialog'; import { SendMessageDialog } from './dialogs/SendMessageDialog'; import { TaskDetailDialog } from './dialogs/TaskDetailDialog'; import { KanbanBoard } from './kanban/KanbanBoard'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; import { KanbanSearchInput } from './kanban/KanbanSearchInput'; import { TrashDialog } from './kanban/TrashDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog'; import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { ComponentProps, CSSProperties } from 'react'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); const TeamGraphOverlay = lazy(() => import('@features/agent-graph/renderer').then((m) => ({ default: m.TeamGraphOverlay, })) ); import { MemberList } from './members/MemberList'; import { MessagesPanel } from './messages/MessagesPanel'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { ScheduleSection } from './schedule/ScheduleSection'; import { TeamSidebarHost } from './sidebar/TeamSidebarHost'; import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource'; import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { isLeadSessionMissing, shouldSuppressMissingLeadSessionFetch, } from './teamSessionFetchGuards'; import { TeamSessionsSection } from './TeamSessionsSection'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover'; import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MemberSpawnStatusEntry, ResolvedTeamMember, TaskRef, TeamTaskWithKanban, } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; import type { ContextUsageLike } from '@shared/utils/contextMetrics'; interface TeamDetailViewProps { teamName: string; isPaneFocused?: boolean; } interface CreateTaskDialogState { open: boolean; defaultSubject: string; defaultDescription: string; defaultOwner: string; defaultStartImmediately?: boolean; defaultChip?: InlineChip; } const logger = createLogger('Component:TeamDetailView'); const TEAM_DETAIL_COMMIT_WARN_MS = 32; const TEAM_DETAIL_RENDER_BURST_WINDOW_MS = 4_000; const TEAM_DETAIL_RENDER_BURST_WARN_COUNT = 8; const TEAM_DETAIL_RENDER_WARN_THROTTLE_MS = 2_000; const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000; function areResolvedMembersEqual( prev: readonly ResolvedTeamMember[], next: readonly ResolvedTeamMember[] ): boolean { if (prev === next) return true; if (prev.length !== next.length) return false; for (let i = 0; i < prev.length; i++) { const prevMember = prev[i]; const nextMember = next[i]; if ( prevMember.name !== nextMember.name || prevMember.status !== nextMember.status || prevMember.currentTaskId !== nextMember.currentTaskId || prevMember.color !== nextMember.color || prevMember.agentType !== nextMember.agentType || prevMember.role !== nextMember.role || prevMember.workflow !== nextMember.workflow || prevMember.providerId !== nextMember.providerId || prevMember.model !== nextMember.model || prevMember.effort !== nextMember.effort || prevMember.cwd !== nextMember.cwd || prevMember.gitBranch !== nextMember.gitBranch || prevMember.removedAt !== nextMember.removedAt || prevMember.runtimeAdvisory?.kind !== nextMember.runtimeAdvisory?.kind || prevMember.runtimeAdvisory?.observedAt !== nextMember.runtimeAdvisory?.observedAt || prevMember.runtimeAdvisory?.retryUntil !== nextMember.runtimeAdvisory?.retryUntil || prevMember.runtimeAdvisory?.retryDelayMs !== nextMember.runtimeAdvisory?.retryDelayMs || prevMember.runtimeAdvisory?.reasonCode !== nextMember.runtimeAdvisory?.reasonCode || prevMember.runtimeAdvisory?.message !== nextMember.runtimeAdvisory?.message ) { return false; } } return true; } function useStableActiveMembers( members: readonly ResolvedTeamMember[] | undefined ): ResolvedTeamMember[] { const filteredMembers = useMemo( () => (members ?? []).filter((member) => !member.removedAt), [members] ); const stableMembersRef = useRef(filteredMembers); if (!areResolvedMembersEqual(stableMembersRef.current, filteredMembers)) { stableMembersRef.current = filteredMembers; } return stableMembersRef.current; } interface TimeWindow { start: number; end: number; } function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTaskWithKanban[] { if (query.startsWith('#')) { const id = query.slice(1); return tasks.filter((t) => t.id === id || t.displayId === id); } const lower = query.toLowerCase(); return tasks.filter( (t) => t.id.toLowerCase().includes(lower) || (t.displayId?.toLowerCase().includes(lower) ?? false) || t.subject.toLowerCase().includes(lower) || (t.owner?.toLowerCase().includes(lower) ?? false) ); } const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({ teamName, onLaunch, }: { teamName: string; onLaunch: () => void; }): React.JSX.Element { const summary = useStore( useShallow((s) => { const team = s.teamByName[teamName]; if (!team) { return null; } return { memberCount: team.memberCount, expectedMemberCount: team.expectedMemberCount, confirmedCount: team.confirmedCount, runtimeAlivePendingCount: team.runtimeAlivePendingCount, teamLaunchState: team.teamLaunchState, partialLaunchFailure: team.partialLaunchFailure, missingMemberCount: team.missingMembers?.length ?? 0, }; }) ); const message = summary?.teamLaunchState === 'partial_pending' ? summary.runtimeAlivePendingCount != null && summary.runtimeAlivePendingCount > 0 ? `Last launch is still reconciling - ${summary.confirmedCount ?? 0}/${summary.expectedMemberCount ?? summary.memberCount} teammates confirmed alive, ${summary.runtimeAlivePendingCount} runtime${summary.runtimeAlivePendingCount === 1 ? '' : 's'} pending bootstrap` : 'Last launch is still reconciling' : summary?.partialLaunchFailure ? summary.missingMemberCount > 0 ? `Last launch failed partway - ${summary.missingMemberCount}/${summary.expectedMemberCount ?? summary.missingMemberCount} teammates did not join` : 'Last launch failed partway' : 'Team is offline'; return (
{message}
); }); type TeamMessagesPanelBridgeProps = Omit< ComponentProps, 'leadActivity' | 'leadContextUpdatedAt' >; type SharedTeamMessagesPanelProps = Omit; type TeamMemberListBridgeProps = Omit< ComponentProps, 'leadActivity' | 'memberSpawnStatuses' > & { teamName: string; }; type TeamMemberDetailDialogBridgeProps = Omit< ComponentProps, 'leadActivity' | 'spawnEntry' >; type TeamSidebarRailBridgeProps = Omit< ComponentProps, 'messagesPanelProps' > & { messagesPanelProps: SharedTeamMessagesPanelProps; }; interface LeadContextWatcherProps { teamName: string; tabId: string | null; projectId: string | null; leadSessionId: string | null; sessionHistoryKey: string; isThisTabActive: boolean; isTeamAlive?: boolean; sessions: readonly Session[]; sessionsLoading: boolean; } interface LeadContextBridgeProps { teamName: string; tabId: string | null; projectId: string | null; leadSessionId: string | null; fallbackProjectRoot?: string; } function buildMemberSpawnStatusMap( memberSpawnStatuses: Record | undefined ): Map | undefined { if (!memberSpawnStatuses) { return undefined; } const map = new Map(Object.entries(memberSpawnStatuses)); return map.size > 0 ? map : undefined; } const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ teamName, isTeamProvisioning, isTeamAlive, }: { teamName: string; isTeamProvisioning: boolean; isTeamAlive?: boolean; }): null { const { leadActivity, memberSpawnStatuses, fetchMemberSpawnStatuses } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses, })) ); useEffect(() => { const shouldFetchSpawnStatuses = isTeamProvisioning || (memberSpawnStatuses == null && (isTeamAlive === true || leadActivity === 'active' || leadActivity === 'idle')); if (shouldFetchSpawnStatuses) { void fetchMemberSpawnStatuses(teamName); } }, [ fetchMemberSpawnStatuses, isTeamAlive, isTeamProvisioning, leadActivity, memberSpawnStatuses, teamName, ]); return null; }); const LeadContextWatcher = memo(function LeadContextWatcher({ teamName, tabId, projectId, leadSessionId, sessionHistoryKey, isThisTabActive, isTeamAlive, sessions, sessionsLoading, }: LeadContextWatcherProps): null { const fetchSessionDetail = useStore((s) => s.fetchSessionDetail); const missingLeadSessionFetchKeyRef = useRef(null); const missingLeadSessionFetchKey = useMemo( () => `${teamName}:${projectId ?? ''}:${leadSessionId ?? ''}:${sessionHistoryKey}`, [teamName, projectId, leadSessionId, sessionHistoryKey] ); useEffect(() => { missingLeadSessionFetchKeyRef.current = null; }, [missingLeadSessionFetchKey]); useEffect(() => { if (!isThisTabActive) return; if (!tabId || !projectId || !leadSessionId) return; const leadSessionMissing = isLeadSessionMissing({ leadSessionId, projectId, sessionsLoading, knownSessions: sessions, }); if (leadSessionMissing) { missingLeadSessionFetchKeyRef.current = missingLeadSessionFetchKey; return; } const fetchLeadSessionDetail = () => { const suppressRepeatedFetch = shouldSuppressMissingLeadSessionFetch({ leadSessionId, projectId, sessionsLoading, knownSessions: sessions, suppressionKey: missingLeadSessionFetchKeyRef.current, currentKey: missingLeadSessionFetchKey, }); if (suppressRepeatedFetch) { return; } void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); }; fetchLeadSessionDetail(); if (!isTeamAlive) return; const id = window.setInterval(() => { fetchLeadSessionDetail(); }, 10_000); return () => window.clearInterval(id); }, [ fetchSessionDetail, isTeamAlive, isThisTabActive, leadSessionId, missingLeadSessionFetchKey, projectId, sessions, sessionsLoading, tabId, ]); return null; }); const LeadContextBridge = memo(function LeadContextBridge({ teamName, tabId, projectId, leadSessionId, fallbackProjectRoot, }: LeadContextBridgeProps): React.JSX.Element | null { const { leadTabData, leadContextSnapshot, isContextPanelVisible, selectedContextPhase, setContextPanelVisibleForTab, setSelectedContextPhaseForTab, fetchSessionDetail, } = useStore( useShallow((s) => ({ leadTabData: tabId ? (s.tabSessionData[tabId] ?? null) : null, leadContextSnapshot: s.leadContextByTeam[teamName] ?? null, isContextPanelVisible: tabId ? (s.tabUIStates.get(tabId)?.showContextPanel ?? false) : false, selectedContextPhase: tabId ? (s.tabUIStates.get(tabId)?.selectedContextPhase ?? null) : null, setContextPanelVisibleForTab: s.setContextPanelVisibleForTab, setSelectedContextPhaseForTab: s.setSelectedContextPhaseForTab, fetchSessionDetail: s.fetchSessionDetail, })) ); const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); const setContextPanelVisible = useCallback( (visible: boolean) => { if (!tabId) return; setContextPanelVisibleForTab(tabId, visible); }, [setContextPanelVisibleForTab, tabId] ); const setSelectedContextPhase = useCallback( (phase: number | null) => { if (!tabId) return; setSelectedContextPhaseForTab(tabId, phase); }, [setSelectedContextPhaseForTab, tabId] ); const leadSessionDetail = leadTabData?.sessionDetail ?? null; const leadConversation = leadTabData?.conversation ?? null; const leadSessionContextStats = leadTabData?.sessionContextStats ?? null; const leadSessionPhaseInfo = leadTabData?.sessionPhaseInfo ?? null; const leadSessionLoading = leadTabData?.sessionDetailLoading ?? false; const leadSessionLoaded = Boolean( leadSessionId && leadSessionDetail?.session?.id === leadSessionId ); const leadSubagentCostUsd = useMemo(() => { const processes = leadSessionDetail?.processes; if (!processes || processes.length === 0) return undefined; const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0); return total > 0 ? total : undefined; }, [leadSessionDetail?.processes]); const { allContextInjections, lastAssistantUsage, lastAssistantModelName } = useMemo(() => { if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) { return { allContextInjections: [] as ContextInjection[], lastAssistantUsage: null as ContextUsageLike | null, lastAssistantModelName: undefined as string | undefined, }; } const effectivePhase = selectedContextPhase; let targetAiGroupId: string | undefined; if (effectivePhase !== null && leadSessionPhaseInfo) { const phase = leadSessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); if (phase) { targetAiGroupId = phase.lastAIGroupId; } } if (!targetAiGroupId) { const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai'); if (lastAiItem?.type !== 'ai') { return { allContextInjections: [] as ContextInjection[], lastAssistantUsage: null, lastAssistantModelName: undefined, }; } targetAiGroupId = lastAiItem.group.id; } const stats = leadSessionContextStats.get(targetAiGroupId); const injections = stats?.accumulatedInjections ?? []; let lastUsage: ContextUsageLike | null = null; let lastModelName: string | undefined; const targetItem = leadConversation.items.find( (item) => item.type === 'ai' && item.group.id === targetAiGroupId ); if (targetItem?.type === 'ai') { const responses = targetItem.group.responses || []; for (let i = responses.length - 1; i >= 0; i--) { const msg = responses[i]; if (msg.type === 'assistant' && msg.usage) { lastUsage = msg.usage; lastModelName = msg.model; break; } } } return { allContextInjections: injections, lastAssistantUsage: lastUsage, lastAssistantModelName: lastModelName, }; }, [ leadConversation, leadSessionContextStats, leadSessionLoaded, leadSessionPhaseInfo, selectedContextPhase, ]); const visibleContextTokens = useMemo( () => sumContextInjectionTokens(allContextInjections), [allContextInjections] ); const contextMetrics = useMemo( () => deriveContextMetrics({ usage: lastAssistantUsage, modelName: lastAssistantModelName, contextWindowTokens: leadContextSnapshot?.contextWindowTokens ?? null, visibleContextTokens, }), [ lastAssistantModelName, lastAssistantUsage, leadContextSnapshot?.contextWindowTokens, visibleContextTokens, ] ); const contextUsedPercentLabel = useMemo(() => { const percent = contextMetrics.contextUsedPercentOfContextWindow ?? leadContextSnapshot?.contextUsedPercent; return percent === null || percent === undefined ? null : `${percent.toFixed(1)}%`; }, [contextMetrics.contextUsedPercentOfContextWindow, leadContextSnapshot?.contextUsedPercent]); if (!leadSessionId) { return null; } return ( <> {isContextPanelVisible && (
{leadSessionLoaded ? ( setContextPanelVisible(false)} projectRoot={leadSessionDetail?.session?.projectPath ?? fallbackProjectRoot} contextMetrics={contextMetrics} sessionMetrics={leadSessionDetail?.metrics} subagentCostUsd={leadSubagentCostUsd} phaseInfo={leadSessionPhaseInfo ?? undefined} selectedPhase={selectedContextPhase} onPhaseChange={setSelectedContextPhase} side="left" /> ) : (

Context

{leadSessionLoading ? 'Loading…' : 'No session loaded'}

{leadSessionLoading ? 'Loading context…' : 'Open the team lead session to view context.'}

)}
)}
); }); const TeamMemberListBridge = memo(function TeamMemberListBridge({ teamName, ...props }: TeamMemberListBridgeProps): React.JSX.Element { const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], progress: getCurrentProvisioningProgressForTeam(s, teamName), memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], })) ); const memberSpawnStatusMap = useMemo( () => buildMemberSpawnStatusMap(memberSpawnStatuses), [memberSpawnStatuses] ); const isLaunchSettling = useMemo(() => { if (progress?.state !== 'ready') { return false; } return getLaunchJoinState( getLaunchJoinMilestonesFromMembers({ members: props.members, memberSpawnStatuses, memberSpawnSnapshot, }) ).hasMembersStillJoining; }, [memberSpawnSnapshot, memberSpawnStatuses, progress?.state, props.members]); return ( ); }); const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({ teamName, ...props }: TeamMessagesPanelBridgeProps): React.JSX.Element { const { leadActivity, leadContextUpdatedAt } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], leadContextUpdatedAt: s.leadContextByTeam[teamName]?.updatedAt, })) ); return ( ); }); const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({ messagesPanelProps, ...props }: TeamSidebarRailBridgeProps): React.JSX.Element { const { leadActivity, leadContextUpdatedAt } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[messagesPanelProps.teamName], leadContextUpdatedAt: s.leadContextByTeam[messagesPanelProps.teamName]?.updatedAt, })) ); const bridgedMessagesPanelProps = useMemo( () => ({ ...messagesPanelProps, leadActivity, leadContextUpdatedAt, }), [leadActivity, leadContextUpdatedAt, messagesPanelProps] ); return ; }); const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge({ teamName, member, ...props }: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null { const { leadActivity, progress, members: launchMembers, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry, } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], progress: getCurrentProvisioningProgressForTeam(s, teamName), members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [], memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, })) ); const isLaunchSettling = useMemo(() => { if (progress?.state !== 'ready') { return false; } return getLaunchJoinState( getLaunchJoinMilestonesFromMembers({ members: launchMembers, memberSpawnStatuses, memberSpawnSnapshot, }) ).hasMembersStillJoining; }, [launchMembers, memberSpawnSnapshot, memberSpawnStatuses, progress?.state]); return ( ); }); export const TeamDetailView = ({ teamName, isPaneFocused = false, }: TeamDetailViewProps): React.JSX.Element => { const renderStartedAtRef = useRef(performance.now()); const renderDiagnosticsRef = useRef({ windowStartedAt: Date.now(), count: 0, lastWarnAt: 0, }); renderStartedAtRef.current = performance.now(); 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>({}); 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 [launchDialogOpen, setLaunchDialogOpen] = useState(false); 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 pendingReplyRefreshTimerRef = useRef(null); 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 = data.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 }[] >([]); // 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, loading, error, projects, repositoryGroups, initTabUIState, selectTeam, updateKanban, updateKanbanColumnOrder, updateTaskStatus, updateTaskOwner, sendTeamMessage, requestReview, createTeamTask, startTaskByUser, deleteTeam, openTeamsTab, closeTab, sendingMessage, sendMessageError, lastSendMessageResult, reviewActionError, addMember, removeMember, updateMemberRole, launchTeam, provisioningError, clearProvisioningError, isTeamProvisioning, refreshTeamData, 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, lastSendMessageResult: s.lastSendMessageResult, reviewActionError: s.reviewActionError, addMember: s.addMember, 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, loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, error: s.selectedTeamName === teamName ? s.selectedTeamError : null, refreshTeamData: s.refreshTeamData, 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; useEffect(() => { const now = Date.now(); const diagnostic = renderDiagnosticsRef.current; if (now - diagnostic.windowStartedAt > TEAM_DETAIL_RENDER_BURST_WINDOW_MS) { diagnostic.windowStartedAt = now; diagnostic.count = 0; } diagnostic.count += 1; const commitMs = performance.now() - renderStartedAtRef.current; const messagesCount = data?.messages.length ?? 0; const tasksCount = data?.tasks.length ?? 0; const membersCount = data?.members.length ?? 0; const processesCount = data?.processes.length ?? 0; const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS; const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT; const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80; if ( (shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) && now - diagnostic.lastWarnAt >= TEAM_DETAIL_RENDER_WARN_THROTTLE_MS ) { diagnostic.lastWarnAt = now; logger.warn( `[perf] commit team=${teamName} ms=${commitMs.toFixed(1)} renders=${diagnostic.count} windowMs=${ now - diagnostic.windowStartedAt } activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${ loading ? 'yes' : 'no' } messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}` ); } }); // 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(() => { 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]); // 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 sessionHistoryKey = useMemo( () => (data?.config.sessionHistory ?? []).join('|'), [data?.config.sessionHistory] ); // Keep team message state fresh while we are explicitly waiting for a reply. // Use a delayed single-shot refresh instead of a tight polling loop so we // don't keep rewriting the whole team snapshot every 2 seconds. useEffect(() => { if (pendingReplyRefreshTimerRef.current != null) { window.clearTimeout(pendingReplyRefreshTimerRef.current); pendingReplyRefreshTimerRef.current = null; } if (!isThisTabActive) return; if (!data?.isAlive) return; if (Object.keys(pendingRepliesByMember).length === 0) return; pendingReplyRefreshTimerRef.current = window.setTimeout(() => { pendingReplyRefreshTimerRef.current = null; void refreshTeamData(teamName, { withDedup: true }); }, TEAM_PENDING_REPLY_REFRESH_DELAY_MS); return () => { if (pendingReplyRefreshTimerRef.current != null) { window.clearTimeout(pendingReplyRefreshTimerRef.current); pendingReplyRefreshTimerRef.current = null; } }; }, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, 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 = data?.members.find((member) => isLeadMember(member))?.cwd?.trim(); return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; }, [data?.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 data?.members ?? []) { addPath(member.cwd); } return Array.from(uniquePaths.values()); }, [data?.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 data.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; }); }, [data, leadBranch, trackedBranches]); // 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 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 handleRestartTeam = useCallback(() => { setLaunchDialogOpen(true); }, []); 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 ?? [], messages: data?.messages ?? [], isTeamAlive: data?.isAlive, timeWindow, teamSessionIds, currentLeadSessionId: data?.config.leadSessionId, pendingRepliesByMember, onPendingReplyChange: setPendingRepliesByMember, onMemberClick: handleSelectMember, onTaskClick: handleOpenTask, onCreateTaskFromMessage: handleCreateTaskFromMessage, onReplyToMessage: handleReplyToMessage, onRestartTeam: handleRestartTeam, onTaskIdClick: handleTaskIdClick, }), [ activeMembers, data?.config.leadSessionId, data?.isAlive, data?.messages, data?.tasks, handleCreateTaskFromMessage, handleOpenTask, handleReplyToMessage, handleRestartTeam, handleSelectMember, handleTaskIdClick, messagesPanelMountPoint, pendingRepliesByMember, teamName, teamSessionIds, timeWindow, changeMessagesPanelMode, ] ); if (!teamName) { return (
Invalid team tab
); } const spawnStatusWatcher = ( ); const leadContextWatcher = ( ); const renderBody = (): React.JSX.Element => { if ((loading && !data) || (data && data.teamName !== teamName)) { return (
); } if (error === 'TEAM_DRAFT') { const draftTeamSummary = useStore.getState().teamByName[teamName]; const draftDisplayName = draftTeamSummary?.displayName || teamName; const draftMemberCount = draftTeamSummary?.memberCount ?? 0; return ( <>

Team not launched yet

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

setLaunchDialogOpen(false)} onLaunch={async (request) => { await launchTeam(request); }} /> ); } 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) */}
{headerColorSet ? (
) : null}

{data.config.name}

{data.isAlive && ( Running )} {!data.isAlive && isTeamProvisioning && ( Launching... )}
{data.isAlive && ( Stop team )} 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.isAlive && !isTeamProvisioning ? ( setLaunchDialogOpen(true)} /> ) : 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={ } > } onRequestReview={(taskId) => { void (async () => { try { await requestReview(teamName, taskId); } catch { // error via store } })(); }} onApprove={(taskId) => { void (async () => { try { await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved', }); } catch { // error via store } })(); }} onRequestChanges={(taskId) => { setRequestChangesTaskId(taskId); }} onMoveBackToDone={(taskId) => { void (async () => { try { await updateKanban(teamName, taskId, { op: 'remove' }); await updateTaskStatus(teamName, taskId, 'completed'); } catch { // error via store } })(); }} onStartTask={(taskId) => { void (async () => { try { const result = await startTaskByUser(teamName, taskId); if (data?.isAlive) { const task = data.tasks.find((t) => t.id === taskId); try { if (result.notifiedOwner && task?.owner) { await api.teams.processSend( teamName, `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` ); } else if (!result.notifiedOwner) { const desc = task?.description?.trim() ? `\nDescription: ${task.description.trim()}` : ''; await api.teams.processSend( teamName, `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` ); } } catch { // best-effort } } } catch { // error via store } })(); }} onCompleteTask={(taskId) => { void (async () => { try { await updateTaskStatus(teamName, taskId, 'completed'); } catch { // error via store } })(); }} onCancelTask={(taskId) => { void (async () => { try { const task = data?.tasks.find((t) => t.id === taskId); await updateTaskStatus(teamName, taskId, 'pending'); // Notify assignee directly via inbox — they'll see it immediately if (task?.owner) { try { await api.teams.sendMessage(teamName, { member: task.owner, text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, }); } catch { // best-effort } } // Also notify team lead so they can reassign/coordinate if (data?.isAlive) { try { const ownerSuffix = task?.owner ? ` ${task.owner} has been notified to stop.` : ''; await api.teams.processSend( teamName, `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); } catch { // best-effort } } } catch { // error via store } })(); }} onColumnOrderChange={(columnId, orderedTaskIds) => { void (async () => { try { await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); } catch { // error via store } })(); }} onScrollToTask={(taskId) => { const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); el.classList.remove('kanban-card-focus-pulse'); void (el as HTMLElement).offsetWidth; el.classList.add('kanban-card-focus-pulse'); el.addEventListener( 'animationend', () => el.classList.remove('kanban-card-focus-pulse'), { once: true } ); } }} onTaskClick={(task) => setSelectedTask(task)} onViewChanges={handleViewChanges} onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately) } onDeleteTask={handleDeleteTask} deletedTaskCount={deletedTasks.length} onOpenTrash={() => setTrashOpen(true)} /> } defaultOpen={false} > {(data.processes?.length ?? 0) > 0 && ( } badge={data.processes.filter((p) => !p.stoppedAt).length} headerExtra={ data.processes.some((p) => !p.stoppedAt) ? ( ) : null } defaultOpen > )} {messagesPanelMode !== 'sidebar' && } {messagesPanelMode === 'inline' && ( )} setRequestChangesTaskId(null)} onSubmit={(comment, taskRefs) => { if (!requestChangesTaskId) { return; } void (async () => { try { await updateKanban(teamName, requestChangesTaskId, { op: 'request_changes', comment, taskRefs, }); setRequestChangesTaskId(null); } catch { // error state is handled in the store and shown in the view } })(); }} /> { const name = selectedMember?.name ?? ''; closeSelectedMemberDialog(); setSendDialogRecipient(name || undefined); setSendDialogDefaultText(undefined); setSendDialogDefaultChip(undefined); setReplyQuote(undefined); setSendDialogOpen(true); }} onAssignTask={() => { const name = selectedMember?.name ?? ''; closeSelectedMemberDialog(); openCreateTaskDialog('', '', name); }} 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))} isTeamAlive={data.isAlive && !isTeamProvisioning} projectPath={data.config.projectPath} onClose={() => setEditDialogOpen(false)} 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, 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. setLaunchDialogOpen(false)} onLaunch={async (request) => { await launchTeam(request); }} /> { void (async () => { const sentAtMs = Date.now(); setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); try { await sendTeamMessage(teamName, { member, text, summary, attachments, actionMode, taskRefs, }); } catch { setPendingRepliesByMember((prev) => { if (prev[member] !== sentAtMs) return prev; const next = { ...prev }; delete next[member]; return next; }); } })(); }} 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' }); 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} projectPath={data.config.projectPath} 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 = data.members.find((m) => m.name === memberName); if (member) { setSelectedMember(member); setSelectedMemberView({ initialTab: options?.initialTab, initialActivityFilter: options?.initialActivityFilter, }); } }} /> )} ); }; return ( <> {spawnStatusWatcher} {leadContextWatcher} {renderBody()} ); };