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 (
);
});
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'}
setContextPanelVisible(false)}
aria-label={`Close ${teamName} context panel`}
>
×
{leadSessionLoading
? 'Loading context…'
: 'Open the team lead session to view context.'}
)}
)}
{
const next = !isContextPanelVisible;
setContextPanelVisible(next);
if (tabId && projectId) {
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
}
}}
onMouseEnter={() => setIsContextButtonHovered(true)}
onMouseLeave={() => setIsContextButtonHovered(false)}
className="pointer-events-auto flex w-fit items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg backdrop-blur-md transition-colors"
style={{
backgroundColor: isContextPanelVisible
? 'var(--context-btn-active-bg)'
: isContextButtonHovered
? 'var(--context-btn-bg-hover)'
: 'var(--context-btn-bg)',
color: isContextPanelVisible
? 'var(--context-btn-active-text)'
: 'var(--color-text-secondary)',
}}
title={
leadSessionLoaded
? `Session: ${leadSessionId}`
: leadSessionLoading
? 'Loading context…'
: leadSessionId
}
>
{contextUsedPercentLabel ?? '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(true)}
>
Launch
{
void api.teams.deleteDraft(teamName).catch(() => {});
}}
>
Delete
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 && (
void handleStopTeam()}
>
Stop
Stop team
)}
setEditDialogOpen(true)}
>
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)}
setEditorOpen(true)}
className="ml-1 flex items-center gap-0.5 rounded border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
>
Edit code
Open project in built-in editor
)}
{leadBranch && (
{leadBranch}
)}
Visualize
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={
{
e.stopPropagation();
setAddMemberDialogOpen(true);
}}
>
Member
}
>
}
defaultOpen={false}
>
setKanbanFilter((prev) => ({ ...prev, sessionId: id }))}
projectPath={data.config.projectPath}
/>
}
badge={filteredTasks.length}
defaultOpen
forceOpen={kanbanSearch.trim().length > 0}
action={
{
e.stopPropagation();
openCreateTaskDialog();
}}
>
Task
}
>
}
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.
setRemoveMemberConfirm(null)}>
Cancel
{
const name = removeMemberConfirm;
setRemoveMemberConfirm(null);
closeSelectedMemberDialog();
if (name) void removeMember(teamName, name);
}}
>
Remove
Delete team
Delete team “{data.config.name}”? This action is irreversible. All
team data and tasks will be deleted.
setDeleteConfirmOpen(false)}>
Cancel
Delete
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()}
>
);
};