import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Button } from '@renderer/components/ui/button'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; 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 { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { AlertTriangle, Bell, CheckCheck, Code, Columns3, FolderOpen, GitBranch, History, MessageSquare, Pencil, Play, Plus, Search, Square, Terminal, Trash2, UserPlus, Users, X, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { ActiveTasksBlock } from './activity/ActiveTasksBlock'; import { ActivityTimeline } from './activity/ActivityTimeline'; import { PendingRepliesBlock } from './activity/PendingRepliesBlock'; 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 { TrashDialog } from './kanban/TrashDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); import { MemberList } from './members/MemberList'; import { MessageComposer } from './messages/MessageComposer'; import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { ProcessesSection } from './ProcessesSection'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { teamName: string; } const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']); interface CreateTaskDialogState { open: boolean; defaultSubject: string; defaultDescription: string; defaultOwner: string; defaultStartImmediately?: boolean; defaultChip?: InlineChip; } 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); } const lower = query.toLowerCase(); return tasks.filter( (t) => t.id.toLowerCase().includes(lower) || t.subject.toLowerCase().includes(lower) || (t.owner?.toLowerCase().includes(lower) ?? false) ); } export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => { const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(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 contentRef = useRef(null); const provisioningBannerRef = useRef(null); const wasProvisioningRef = useRef(false); // Set inert on background content when editor overlay is open (a11y focus trap) useEffect(() => { const el = contentRef.current; if (!el) return; if (editorOpen) { el.setAttribute('inert', ''); } else { el.removeAttribute('inert'); } }, [editorOpen]); 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; }>({ 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 { data, loading, error, projects, repositoryGroups, teams, fetchSessionDetail, initTabUIState, selectTeam, updateKanban, updateKanbanColumnOrder, updateTaskStatus, updateTaskOwner, sendTeamMessage, requestReview, createTeamTask, startTask, deleteTeam, openTeamsTab, sendingMessage, sendMessageError, lastSendMessageResult, reviewActionError, addMember, removeMember, updateMemberRole, launchTeam, provisioningError, clearProvisioningError, isTeamProvisioning, leadActivityByTeam, leadContextByTeam, refreshTeamData, kanbanFilterQuery, clearKanbanFilter, softDeleteTask, restoreTask, fetchDeletedTasks, deletedTasks, } = useStore( useShallow((s) => ({ data: s.selectedTeamData, loading: s.selectedTeamLoading, error: s.selectedTeamError, projects: s.projects, repositoryGroups: s.repositoryGroups, teams: s.teams, fetchSessionDetail: s.fetchSessionDetail, 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, startTask: s.startTask, deleteTeam: s.deleteTeam, openTeamsTab: s.openTeamsTab, 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: s.provisioningError, clearProvisioningError: s.clearProvisioningError, isTeamProvisioning: Object.values(s.provisioningRuns).some( (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) ), leadActivityByTeam: s.leadActivityByTeam, leadContextByTeam: s.leadContextByTeam, refreshTeamData: s.refreshTeamData, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, softDeleteTask: s.softDeleteTask, restoreTask: s.restoreTask, fetchDeletedTasks: s.fetchDeletedTasks, deletedTasks: s.deletedTasks, })) ); // Per-tab UI state (context panel visibility + selected phase) const { tabId, isContextPanelVisible, setContextPanelVisible, selectedContextPhase, setSelectedContextPhase, } = useTabUI(); const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); 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(''); const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); const [messagesFilter, setMessagesFilter] = useState({ from: new Set(), to: new Set(), }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); // 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]); // Fetch active teams when launch dialog opens (for conflict warning) useEffect(() => { if (!launchDialogOpen) return; let cancelled = false; void (async () => { try { const aliveList = await api.teams.aliveList(); if (cancelled) return; const aliveSet = new Set(aliveList); const refs = teams .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, teams]); 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] ); // Lead session context panel (reuses the same session context pipeline for exact stats) const leadSessionId = data?.config.leadSessionId ?? null; const leadTabData = useStore( useShallow((s) => (tabId ? s.tabSessionData[tabId] : null)) ); 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 leadContextPercent = leadContextByTeam[teamName]?.percent; const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) { return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; } // Determine which phase to show const effectivePhase = selectedContextPhase; // If a specific phase is selected, find the last AI group in that phase let targetAiGroupId: string | undefined; if (effectivePhase !== null && leadSessionPhaseInfo) { const phase = leadSessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); if (phase) { targetAiGroupId = phase.lastAIGroupId; } } // Default: use the last AI group overall if (!targetAiGroupId) { const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai'); if (lastAiItem?.type !== 'ai') { return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; } targetAiGroupId = lastAiItem.group.id; } const stats = leadSessionContextStats.get(targetAiGroupId); const injections = stats?.accumulatedInjections ?? []; // Get total tokens from the target AI group let totalTokens: number | 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) { const usage = msg.usage; totalTokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0); break; } } } return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; }, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]); const visibleContextTokens = useMemo(() => { return allContextInjections.reduce((sum, inj) => sum + (inj.estimatedTokens ?? 0), 0); }, [allContextInjections]); const visibleContextPercentOfTotal = useMemo(() => { if (lastAiGroupTotalTokens === undefined || lastAiGroupTotalTokens <= 0) return null; return Math.min((visibleContextTokens / lastAiGroupTotalTokens) * 100, 100); }, [visibleContextTokens, lastAiGroupTotalTokens]); 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 polling for the team's project path const teamProjectPath = data?.config.projectPath?.trim() ?? null; const branchSyncPaths = useMemo( () => (teamProjectPath ? [teamProjectPath] : []), [teamProjectPath] ); useBranchSync(branchSyncPaths, { live: true }); const leadBranch = useStore((s) => teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null ); // Filter sessions to team-only using sessionHistory + leadSessionId const teamSessions = 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); } } // If no session IDs known (backward compat), show all sessions if (sessionIds.size === 0) return sessions; return sessions.filter((s) => sessionIds.has(s.id)); }, [sessions, data?.config.leadSessionId, data?.config.sessionHistory]); // 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 filteredMessages = useMemo(() => { if (!data) return []; let list = data.messages; if (timeWindow) { list = list.filter((m) => { const ts = new Date(m.timestamp).getTime(); return ts >= timeWindow.start && ts < timeWindow.end; }); } if (messagesFilter.from.size > 0) { list = list.filter((m) => m.from?.trim() && messagesFilter.from.has(m.from.trim())); } if (messagesFilter.to.size > 0) { list = list.filter((m) => m.to?.trim() && messagesFilter.to.has(m.to.trim())); } const q = messagesSearchQuery.trim().toLowerCase(); if (q) { list = list.filter((m) => { const text = (m.text ?? '').toLowerCase(); const summary = (m.summary ?? '').toLowerCase(); const from = (m.from ?? '').toLowerCase(); const to = (m.to ?? '').toLowerCase(); return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q); }); } return list; }, [data, timeWindow, messagesFilter, messagesSearchQuery]); const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? ''); const messagesUnreadCount = useMemo( () => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length, [filteredMessages, readSet] ); const handleMessageVisible = useCallback( (message: InboxMessage) => markRead(toMessageKey(message)), [markRead] ); const handleMarkAllRead = useCallback(() => { const keys = filteredMessages .filter((m) => !m.read && !readSet.has(toMessageKey(m))) .map((m) => toMessageKey(m)); markAllRead(keys); }, [filteredMessages, readSet, markAllRead]); const kanbanDisplayTasks = useMemo(() => { const query = kanbanSearch.trim(); if (!query) return filteredTasks; return filterKanbanTasks(filteredTasks, query); }, [filteredTasks, kanbanSearch]); const activeMembers = useMemo( () => (data?.members ?? []).filter((m) => !m.removedAt), [data?.members] ); const activeTeammateCount = useMemo( () => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length, [activeMembers] ); const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); useEffect(() => { if (!data || Object.keys(pendingRepliesByMember).length === 0) return; const next = { ...pendingRepliesByMember }; let changed = false; for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { const hasReply = data.messages.some((m) => { if (m.from !== memberName) return false; const ts = Date.parse(m.timestamp); return Number.isFinite(ts) && ts > sentAtMs; }); if (hasReply) { delete next[memberName]; changed = true; } } if (changed) setPendingRepliesByMember(next); }, [data, pendingRepliesByMember]); const openCreateTaskDialog = ( subject = '', description = '', owner = '', startImmediately?: boolean ): void => { setCreateTaskDialog({ open: true, defaultSubject: subject, defaultDescription: description, defaultOwner: owner, defaultStartImmediately: startImmediately, }); }; const closeCreateTaskDialog = (): void => { setCreateTaskDialog({ open: false, defaultSubject: '', defaultDescription: '', defaultOwner: '', defaultStartImmediately: undefined, }); }; 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]); const selectReviewFile = useStore((s) => s.selectReviewFile); const pendingReviewRequest = useStore((s) => s.pendingReviewRequest); const setPendingReviewRequest = useStore((s) => s.setPendingReviewRequest); // Pick up pending review request from GlobalTaskDetailDialog useEffect(() => { if (!pendingReviewRequest) return; setReviewDialogState({ open: true, mode: 'task', taskId: pendingReviewRequest.taskId, initialFilePath: pendingReviewRequest.filePath, }); if (pendingReviewRequest.filePath) { selectReviewFile(pendingReviewRequest.filePath); } setPendingReviewRequest(null); }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); const handleDeleteTask = useCallback( (taskId: string) => { void (async () => { const confirmed = await confirm({ title: 'Delete task', message: `Move task #${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) => { setReviewDialogState({ open: true, mode: 'task', taskId }); }, []); const handleViewChangesForFile = useCallback( (taskId: string, filePath?: string) => { setReviewDialogState({ open: true, mode: 'task', taskId }); if (filePath) { selectReviewFile(filePath); } }, [selectReviewFile] ); const handleDeleteTeam = useCallback((): void => { setDeleteConfirmOpen(true); }, []); const confirmDeleteTeam = useCallback((): void => { setDeleteConfirmOpen(false); void (async () => { try { await deleteTeam(teamName); openTeamsTab(); } catch { // error is shown via store } })(); }, [teamName, deleteTeam, openTeamsTab]); const handleCreateTask = ( subject: string, description: string, owner?: string, blockedBy?: string[], related?: string[], prompt?: string, startImmediately?: boolean ): void => { setCreatingTask(true); void (async () => { try { await createTeamTask(teamName, { subject, description: description || undefined, owner, blockedBy, related, prompt, 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); } })(); }; if (!teamName) { return (
Invalid team tab
); } if (loading && !data) { return (
); } if (error) { return (

Failed to load team

{error}

); } if (!data) { return (
No team data available
); } const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : nameColorSet(data.config.name); return ( <>
{/* Sticky context button in top-right while scrolling */} {leadSessionId && (
)}
{headerColorSet ? (
) : null}

{data.config.name}

{data.isAlive && ( Stop team )} Edit team Delete team
{data.config.description && (

{data.config.description}

)} {(data.config.projectPath || leadBranch) && (
{data.config.projectPath && ( {formatProjectPath(data.config.projectPath)} Open project in built-in editor )} {leadBranch && ( {leadBranch} )} {data.isAlive && ( Running )} {!data.isAlive && isTeamProvisioning && ( Launching... )}
)} {(() => { 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 ? (
Team is offline
) : 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={ } > { setSendDialogRecipient(member.name); setSendDialogDefaultText(undefined); setSendDialogDefaultChip(undefined); setReplyQuote(undefined); setSendDialogOpen(true); }} onAssignTask={(member) => { openCreateTaskDialog('', '', member.name); }} onOpenTask={(task) => setSelectedTask(task)} /> } defaultOpen={false} > setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} projectPath={data.config.projectPath} /> } badge={filteredTasks.length} defaultOpen forceOpen={kanbanSearch.trim().length > 0} action={ } > setKanbanSearch(e.target.value)} className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" /> {kanbanSearch && ( Clear search )}
} 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 startTask(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 #${taskId} "${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 #${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 #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, summary: `Task #${taskId} 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 #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); } catch { // best-effort } } } catch { // error via store } })(); }} onColumnOrderChange={(columnId, orderedTaskIds) => { void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); }} onScrollToTask={(taskId) => { const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); el.classList.add('ring-2', 'ring-blue-400/50'); setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); } }} onTaskClick={(task) => setSelectedTask(task)} onViewChanges={handleViewChanges} onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} onDeleteTask={handleDeleteTask} deletedTaskCount={deletedTasks.length} onOpenTrash={() => setTrashOpen(true)} /> {(data.processes?.length ?? 0) > 0 && ( } badge={data.processes.filter((p) => !p.stoppedAt).length} defaultOpen > )} } badge={filteredMessages.length} secondaryBadge={ filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined } headerExtra={ Desktop notifications plugin } defaultOpen action={
{messagesUnreadCount > 0 && ( Mark all as read )}
setMessagesSearchQuery(e.target.value)} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" /> {messagesSearchQuery && ( )}
} > { const sentAtMs = Date.now(); setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => { setPendingRepliesByMember((prev) => { if (prev[member] !== sentAtMs) return prev; const next = { ...prev }; delete next[member]; return next; }); }); }} /> { openCreateTaskDialog(subject, description); }} onReplyToMessage={(message) => { setSendDialogRecipient(message.from); setSendDialogDefaultText(undefined); setSendDialogDefaultChip(undefined); setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); setSendDialogOpen(true); }} onMessageVisible={handleMessageVisible} onRestartTeam={() => setLaunchDialogOpen(true)} onTaskIdClick={(taskId) => { const task = taskMap.get(taskId); if (task) setSelectedTask(task); }} />
setRequestChangesTaskId(null)} onSubmit={(comment) => { if (!requestChangesTaskId) { return; } void (async () => { try { await updateKanban(teamName, requestChangesTaskId, { op: 'request_changes', comment, }); setRequestChangesTaskId(null); } catch { // error state is handled in the store and shown in the view } })(); }} /> setSelectedMember(null)} onSendMessage={() => { const name = selectedMember?.name ?? ''; setSelectedMember(null); setSendDialogRecipient(name || undefined); setSendDialogDefaultText(undefined); setSendDialogDefaultChip(undefined); setReplyQuote(undefined); setSendDialogOpen(true); }} onAssignTask={() => { const name = selectedMember?.name ?? ''; setSelectedMember(null); openCreateTaskDialog('', '', name); }} onTaskClick={(task) => { setSelectedMember(null); 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) => { setSelectedMember(null); setReviewDialogState({ open: true, mode: 'agent', memberName, initialFilePath: filePath, }); }} /> setEditDialogOpen(false)} onSaved={() => void selectTeam(teamName)} /> m.name)} existingMembers={data.members} projectPath={data.config.projectPath} adding={addingMemberLoading} onClose={() => setAddMemberDialogOpen(false)} onAdd={(name, role, workflow) => { setAddingMemberLoading(true); void (async () => { try { await addMember(teamName, { name, role, workflow }); 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 }); } 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.add('ring-2', 'ring-blue-400/50'); setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); } }} onOwnerChange={(taskId, owner) => { void updateTaskOwner(teamName, taskId, owner); }} onViewChanges={handleViewChangesForFile} onOpenInEditor={(filePath) => { const { revealFileInEditor } = useStore.getState(); revealFileInEditor(filePath); }} onDeleteTask={handleDeleteTask} /> setTrashOpen(false)} onRestore={(taskId) => { void restoreTask(teamName, taskId); }} /> setReviewDialogState((prev) => ({ ...prev, open, ...(open ? {} : { initialFilePath: undefined }), })) } teamName={teamName} mode={reviewDialogState.mode} memberName={reviewDialogState.memberName} taskId={reviewDialogState.taskId} initialFilePath={reviewDialogState.initialFilePath} projectPath={data.config.projectPath} onEditorAction={handleEditorAction} />
{/* Context panel sidebar */} {isContextPanelVisible && leadSessionId && (
{leadSessionLoaded ? ( setContextPanelVisible(false)} projectRoot={leadSessionDetail?.session?.projectPath ?? data.config.projectPath} totalSessionTokens={lastAiGroupTotalTokens} sessionMetrics={leadSessionDetail?.metrics} subagentCostUsd={leadSubagentCostUsd} phaseInfo={leadSessionPhaseInfo ?? undefined} selectedPhase={selectedContextPhase} onPhaseChange={setSelectedContextPhase} /> ) : (

Visible Context

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

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

)}
)}
{editorOpen && data.config.projectPath && ( setEditorOpen(false)} onEditorAction={handleEditorAction} /> )} ); };