import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; 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 } from '@renderer/constants/teamColors'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { Bell, CheckCheck, FolderOpen, GitBranch, History, Pencil, Play, Plus, Search, Square, Trash2, UserPlus, 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'; 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 { 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 { Session } from '@renderer/types/data'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; 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; } 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 [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 [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(), }); const { data, loading, error, projects, teams, selectTeam, updateKanban, updateKanbanColumnOrder, updateTaskStatus, updateTaskOwner, sendTeamMessage, requestReview, createTeamTask, startTask, deleteTeam, openTeamsTab, sendingMessage, sendMessageError, lastSendMessageResult, reviewActionError, addMember, removeMember, updateMemberRole, launchTeam, provisioningError, isTeamProvisioning, leadActivityByTeam, refreshTeamData, kanbanFilterQuery, clearKanbanFilter, softDeleteTask, fetchDeletedTasks, deletedTasks, } = useStore( useShallow((s) => ({ data: s.selectedTeamData, loading: s.selectedTeamLoading, error: s.selectedTeamError, projects: s.projects, teams: s.teams, 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, isTeamProvisioning: Object.values(s.provisioningRuns).some( (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) ), leadActivityByTeam: s.leadActivityByTeam, refreshTeamData: s.refreshTeamData, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, softDeleteTask: s.softDeleteTask, fetchDeletedTasks: s.fetchDeletedTasks, deletedTasks: s.deletedTasks, })) ); const [kanbanSearch, setKanbanSearch] = useState(''); const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); const [messagesFilter, setMessagesFilter] = useState({ from: new Set(), to: new Set(), }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); 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(() => { if (!data?.config.projectPath) return null; return projects.find((p) => p.path === data.config.projectPath)?.id ?? null; }, [projects, data?.config.projectPath]); 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]); // Resolve lead's git branch from project path const [leadBranch, setLeadBranch] = useState(null); useEffect(() => { const projectPath = data?.config.projectPath?.trim(); if (!projectPath || typeof api.teams?.getProjectBranch !== 'function') { setLeadBranch(null); return; } let cancelled = false; void api.teams.getProjectBranch(projectPath).then( (branch) => { if (!cancelled) setLeadBranch(branch); }, () => { if (!cancelled) setLeadBranch(null); } ); return () => { cancelled = true; }; }, [data?.config.projectPath]); // 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 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 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 handleDeleteTask = useCallback( (taskId: string) => { void softDeleteTask(teamName, taskId); }, [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) : null; return (
{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)} )} {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} { e.stopPropagation(); setAddMemberDialogOpen(true); }} > Member } > { setSendDialogRecipient(member.name); setReplyQuote(undefined); setSendDialogOpen(true); }} onAssignTask={(member) => { openCreateTaskDialog('', '', member.name); }} onOpenTask={(task) => setSelectedTask(task)} /> setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} projectPath={data.config.projectPath} /> 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 requestReview(teamName, taskId); }} onApprove={(taskId) => { void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); }} onRequestChanges={(taskId) => { setRequestChangesTaskId(taskId); }} onMoveBackToDone={(taskId) => { void (async () => { await updateKanban(teamName, taskId, { op: 'remove' }); await updateTaskStatus(teamName, taskId, 'completed'); })(); }} 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 updateTaskStatus(teamName, taskId, 'completed'); }} 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 && ( !p.stoppedAt).length} defaultOpen > )} 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" />
} > { 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); setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); setSendDialogOpen(true); }} onMessageVisible={handleMessageVisible} 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); 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)} adding={addingMemberLoading} onClose={() => setAddMemberDialogOpen(false)} onAdd={(name, role) => { setAddingMemberLoading(true); void (async () => { try { await addMember(teamName, { name, role }); 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 }); } catch { setPendingRepliesByMember((prev) => { if (prev[member] !== sentAtMs) return prev; const next = { ...prev }; delete next[member]; return next; }); } })(); }} onClose={() => { setSendDialogOpen(false); setReplyQuote(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} onDeleteTask={handleDeleteTask} /> setTrashOpen(false)} /> setReviewDialogState((prev) => ({ ...prev, open, ...(open ? {} : { initialFilePath: undefined }), })) } teamName={teamName} mode={reviewDialogState.mode} memberName={reviewDialogState.memberName} taskId={reviewDialogState.taskId} initialFilePath={reviewDialogState.initialFilePath} />
); };