From affd9ac748f231016a6452134104cf81a2b47f03 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 9 Apr 2026 19:00:22 +0300 Subject: [PATCH] perf(renderer): split noisy team detail subscriptions --- .../components/team/TeamDetailView.tsx | 2232 +++++++++-------- 1 file changed, 1174 insertions(+), 1058 deletions(-) diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 9dca9155..1c28c44e 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,4 +1,5 @@ import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ComponentProps } from 'react'; import { api } from '@renderer/api'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; @@ -265,6 +266,160 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({ ); }); +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; +}; + +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 TeamMemberListBridge = memo(function TeamMemberListBridge({ + teamName, + ...props +}: TeamMemberListBridgeProps): React.JSX.Element { + const { leadActivity, memberSpawnStatuses } = useStore( + useShallow((s) => ({ + leadActivity: s.leadActivityByTeam[teamName], + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + })) + ); + const memberSpawnStatusMap = useMemo( + () => buildMemberSpawnStatusMap(memberSpawnStatuses), + [memberSpawnStatuses] + ); + + 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 = useStore((s) => s.leadActivityByTeam[teamName]); + const spawnEntry = useStore((s) => + member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined + ); + + return ( + + ); +}); + export const TeamDetailView = ({ teamName, isPaneFocused = false, @@ -532,10 +687,6 @@ export const TeamDetailView = ({ provisioningError, clearProvisioningError, isTeamProvisioning, - leadActivity, - leadContextUpdatedAt, - memberSpawnStatuses, - fetchMemberSpawnStatuses, refreshTeamData, kanbanFilterQuery, clearKanbanFilter, @@ -582,10 +733,6 @@ export const TeamDetailView = ({ data: s.selectedTeamName === teamName ? s.selectedTeamData : null, loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, error: s.selectedTeamName === teamName ? s.selectedTeamError : null, - leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined, - leadContextUpdatedAt: teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined, - memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, - fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses, refreshTeamData: s.refreshTeamData, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, @@ -683,52 +830,6 @@ export const TeamDetailView = ({ } }, [isTeamProvisioning]); - // Fetch initial spawn statuses when provisioning starts - useEffect(() => { - const shouldFetchSpawnStatuses = - Boolean(teamName) && - (isTeamProvisioning || - (memberSpawnStatuses == null && - (data?.isAlive === true || leadActivity === 'active' || leadActivity === 'idle'))); - if (teamName && shouldFetchSpawnStatuses) { - void fetchMemberSpawnStatuses(teamName); - } - }, [ - data?.isAlive, - fetchMemberSpawnStatuses, - isTeamProvisioning, - leadActivity, - memberSpawnStatuses, - teamName, - ]); - - // Convert Record → Map - const memberSpawnStatusMap = useMemo(() => { - if (!memberSpawnStatuses) return undefined; - const map = new Map< - string, - { - status: MemberSpawnStatusEntry['status']; - launchState: MemberSpawnStatusEntry['launchState']; - error?: string; - updatedAt: string; - runtimeAlive?: boolean; - livenessSource?: MemberSpawnStatusEntry['livenessSource']; - } - >(); - for (const [name, entry] of Object.entries(memberSpawnStatuses)) { - map.set(name, { - status: entry.status, - launchState: entry.launchState, - error: entry.error, - updatedAt: entry.updatedAt, - runtimeAlive: entry.runtimeAlive, - livenessSource: entry.livenessSource, - }); - } - return map.size > 0 ? map : undefined; - }, [memberSpawnStatuses]); - const [kanbanSearch, setKanbanSearch] = useState(''); // Open editor overlay when a file reveal is requested (e.g. from chip click) @@ -1373,7 +1474,7 @@ export const TeamDetailView = ({ })(); }; - const sharedMessagesPanelProps = useMemo( + const sharedMessagesPanelProps = useMemo( () => ({ teamName, onTogglePosition: toggleMessagesPanelMode, @@ -1381,8 +1482,6 @@ export const TeamDetailView = ({ tasks: data?.tasks ?? [], messages: data?.messages ?? [], isTeamAlive: data?.isAlive, - leadActivity, - leadContextUpdatedAt, timeWindow, teamSessionIds, currentLeadSessionId: data?.config.leadSessionId, @@ -1407,8 +1506,6 @@ export const TeamDetailView = ({ handleRestartTeam, handleSelectMember, handleTaskIdClick, - leadActivity, - leadContextUpdatedAt, pendingRepliesByMember, teamName, teamSessionIds, @@ -1425,645 +1522,989 @@ export const TeamDetailView = ({ ); } - if ((loading && !data) || (data && data.teamName !== teamName)) { - return ( -
-
-
- -
-
-
-
-
-
-
- ); - } + const spawnStatusWatcher = ( + + ); - if (error === 'TEAM_DRAFT') { - const draftTeamSummary = useStore.getState().teamByName[teamName]; - const draftDisplayName = draftTeamSummary?.displayName || teamName; - const draftMemberCount = draftTeamSummary?.memberCount ?? 0; - - return ( - <> -
+ const renderBody = (): React.JSX.Element => { + if ((loading && !data) || (data && data.teamName !== teamName)) { + return ( +
+
-
-
-

Team not launched yet

-

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

-
- - +
+
+
+
+
+
+ ); + } + + if (error === 'TEAM_DRAFT') { + const draftTeamSummary = useStore.getState().teamByName[teamName]; + const draftDisplayName = draftTeamSummary?.displayName || teamName; + const draftMemberCount = draftTeamSummary?.memberCount ?? 0; + + return ( + <> +
+
+ +
+
+
+

Team not launched yet

+

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

+
+ + +
-
- setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} - /> - - ); - } + 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); - 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 ( - <> -
- {/* Context panel sidebar (left) */} - {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} - side="left" - /> - ) : ( -
-
-
-

Visible Context

-

- {leadSessionLoading ? 'Loading…' : 'No session loaded'} + <> +

+ {/* Context panel sidebar (left) */} + {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} + side="left" + /> + ) : ( +
+
+
+

+ Visible Context +

+

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

+
+ +
+
+

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

-
-
-

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

-
-
- )} -
- )} - - {/* Messages sidebar (left, after context panel) */} - - - - - - -
- {/* Context button pinned to bottom-left of viewport */} - {leadSessionId && ( -
- + )}
)} -
- {headerColorSet ? ( -
- ) : null} -
+ -
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} -
+ + + + +
+ {/* Context button pinned to bottom-left of viewport */} + {leadSessionId && ( +
+
-
- {data.isAlive && ( + )} + +
+ {headerColorSet ? ( +
+ ) : null} +
+
+
+

+ {data.config.name} +

+ {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
+
+
+ {data.isAlive && ( + + + + + Stop team + + )} + + + + + Edit team + - Stop team + Delete team - )} - - - - - Edit team - - - - - - Delete team - +
-
- {data.config.description && ( -

- {data.config.description} -

- )} - {(data.config.projectPath || leadBranch) && ( -
- {data.config.projectPath && ( - - - - - - {data.config.projectPath - .replace(/\\/g, '/') - .split('/') - .filter(Boolean) - .pop() ?? data.config.projectPath} - - - - - {formatProjectPath(data.config.projectPath)} - - - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} -
- )} - {(() => { - 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={ -
- + + Open project in built-in editor + - - Graph - + )} + {leadBranch && ( + + + {leadBranch} + + )} +
+ )} + {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
+ ); + })()} +
+ + {!data.isAlive && !isTeamProvisioning ? ( + setLaunchDialogOpen(true)} + /> + ) : null} + +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ +
+ + +
+ } + > + +
+ + } + defaultOpen={false} + > + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + contentClassName="overflow-x-visible" + action={ -
- } - > - - - - } - defaultOpen={false} - > - setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} - projectPath={data.config.projectPath} - /> - - - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - contentClassName="overflow-x-visible" - action={ - - } - > - } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` + > + + } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); + }} + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { + op: 'set_column', + column: 'approved', + }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` : ''; await api.teams.processSend( teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); + } catch { + // best-effort } - } catch { - // best-effort } + } catch { + // error via store } - } catch { - // error via store + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener( + 'animationend', + () => el.classList.remove('kanban-card-focus-pulse'), + { once: true } + ); } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, - }); - } catch { - // best-effort - } - } + } + defaultOpen={false} + > + + - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + {messagesPanelMode !== 'sidebar' && } + + {messagesPanelMode === 'inline' && ( + + )} + + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; + } void (async () => { try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); } catch { - // error via store + // error state is handled in the store and shown in the view } })(); }} + /> + + 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, + }); + }} + /> + + + + !isLeadMember(m))} + isTeamAlive={data.isAlive && !isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + onSaved={() => void selectTeam(teamName)} + /> + + m.name)} + existingMembers={data.members} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(entries: AddMemberEntry[]) => { + setAddingMemberLoading(true); + void (async () => { + try { + for (const entry of entries) { + await addMember(teamName, { + name: entry.name, + role: entry.role, + workflow: entry.workflow, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + }); + } + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); + } + })(); + }} + /> + + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages + will be preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All + team data and tasks will be deleted. + + + + + + + + + + setLaunchDialogOpen(false)} + onLaunch={async (request) => { + await launchTeam(request); + }} + /> + + { + void (async () => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + } catch { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + })(); + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -2077,434 +2518,109 @@ export const TeamDetailView = ({ ); } }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} - onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} - /> - - - } - defaultOpen={false} - > - - - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null - } - defaultOpen - > - - - )} - - {messagesPanelMode !== 'sidebar' && } - - {messagesPanelMode === 'inline' && ( - - )} - - setRequestChangesTaskId(null)} - onSubmit={(comment, taskRefs) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - taskRefs, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - 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, - }); - }} - /> - - - - !isLeadMember(m))} - isTeamAlive={data.isAlive && !isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - onSaved={() => void selectTeam(teamName)} - /> - - m.name)} - existingMembers={data.members} - 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, - }); + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} + onDeleteTask={handleDeleteTask} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages will - be preserved, but this name cannot be reused. - - - - - - - - + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All - team data and tasks will be deleted. - - - - - - - - - - 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 } - ); + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open + ? {} + : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), + })) } - }} - 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} - /> + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} + /> +
-
- {editorOpen && data.config.projectPath && ( - - setEditorOpen(false)} - onEditorAction={handleEditorAction} - /> - - )} + {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) => { - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); - }} - /> - - )} + {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) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} + /> + + )} + + ); + }; + + return ( + <> + {spawnStatusWatcher} + {renderBody()} ); };