- Updated README to clarify that the app runs entirely locally and added a comprehensive table of contents. - Enhanced the description of the Claude Agent Teams UI to better convey its functionality and target audience. - Improved the SortableTab component to ensure immediate rendering of team colors based on available data. - Filtered out team lead members in the TeamDetailView to streamline member display. - Eagerly patched team data in the state management to improve UI responsiveness and data accuracy.
1926 lines
72 KiB
TypeScript
1926 lines
72 KiB
TypeScript
import { lazy, 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 } from '@renderer/constants/teamColors';
|
||
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||
import { cn } from '@renderer/lib/utils';
|
||
import { useStore } from '@renderer/store';
|
||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||
import { formatPercentOfTotal, 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 { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||
import {
|
||
AlertTriangle,
|
||
Bell,
|
||
CheckCheck,
|
||
Code,
|
||
Columns3,
|
||
FolderOpen,
|
||
GitBranch,
|
||
History,
|
||
MessageSquare,
|
||
Pencil,
|
||
Play,
|
||
Plus,
|
||
Search,
|
||
Square,
|
||
Terminal,
|
||
Trash2,
|
||
UserPlus,
|
||
Users,
|
||
X,
|
||
} from 'lucide-react';
|
||
import { useShallow } from 'zustand/react/shallow';
|
||
|
||
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
|
||
import { ActivityTimeline } from './activity/ActivityTimeline';
|
||
import { PendingRepliesBlock } from './activity/PendingRepliesBlock';
|
||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
||
import { ReviewDialog } from './dialogs/ReviewDialog';
|
||
import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
||
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
||
import { KanbanBoard } from './kanban/KanbanBoard';
|
||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||
import { TrashDialog } from './kanban/TrashDialog';
|
||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||
|
||
const ProjectEditorOverlay = lazy(() =>
|
||
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
|
||
);
|
||
import { MemberList } from './members/MemberList';
|
||
import { MessageComposer } from './messages/MessageComposer';
|
||
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
||
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
|
||
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
||
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 { ContextInjection } from '@renderer/types/contextInjection';
|
||
import type { Session } from '@renderer/types/data';
|
||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||
import type { EditorSelectionAction } from '@shared/types/editor';
|
||
|
||
interface TeamDetailViewProps {
|
||
teamName: string;
|
||
}
|
||
|
||
const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
|
||
|
||
interface CreateTaskDialogState {
|
||
open: boolean;
|
||
defaultSubject: string;
|
||
defaultDescription: string;
|
||
defaultOwner: string;
|
||
defaultStartImmediately?: boolean;
|
||
defaultChip?: InlineChip;
|
||
}
|
||
|
||
interface TimeWindow {
|
||
start: number;
|
||
end: number;
|
||
}
|
||
|
||
function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTaskWithKanban[] {
|
||
if (query.startsWith('#')) {
|
||
const id = query.slice(1);
|
||
return tasks.filter((t) => t.id === id);
|
||
}
|
||
const lower = query.toLowerCase();
|
||
return tasks.filter(
|
||
(t) =>
|
||
t.id.toLowerCase().includes(lower) ||
|
||
t.subject.toLowerCase().includes(lower) ||
|
||
(t.owner?.toLowerCase().includes(lower) ?? false)
|
||
);
|
||
}
|
||
|
||
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
|
||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
||
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
|
||
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||
open: false,
|
||
defaultSubject: '',
|
||
defaultDescription: '',
|
||
defaultOwner: '',
|
||
});
|
||
const [creatingTask, setCreatingTask] = useState(false);
|
||
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
|
||
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
|
||
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
||
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
|
||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
|
||
const [editorOpen, setEditorOpen] = useState(false);
|
||
const contentRef = useRef<HTMLDivElement>(null);
|
||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||
const wasProvisioningRef = useRef(false);
|
||
|
||
// Set inert on background content when editor overlay is open (a11y focus trap)
|
||
useEffect(() => {
|
||
const el = contentRef.current;
|
||
if (!el) return;
|
||
if (editorOpen) {
|
||
el.setAttribute('inert', '');
|
||
} else {
|
||
el.removeAttribute('inert');
|
||
}
|
||
}, [editorOpen]);
|
||
|
||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||
const [stoppingTeam, setStoppingTeam] = useState(false);
|
||
const [trashOpen, setTrashOpen] = useState(false);
|
||
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
|
||
const [sendDialogDefaultText, setSendDialogDefaultText] = useState<string | undefined>(undefined);
|
||
const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState<InlineChip | undefined>(
|
||
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<Session[]>([]);
|
||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
||
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
|
||
sessionId: null,
|
||
selectedOwners: new Set(),
|
||
columns: new Set(),
|
||
});
|
||
|
||
const {
|
||
data,
|
||
loading,
|
||
error,
|
||
projects,
|
||
repositoryGroups,
|
||
teams,
|
||
fetchSessionDetail,
|
||
initTabUIState,
|
||
selectTeam,
|
||
updateKanban,
|
||
updateKanbanColumnOrder,
|
||
updateTaskStatus,
|
||
updateTaskOwner,
|
||
sendTeamMessage,
|
||
requestReview,
|
||
createTeamTask,
|
||
startTask,
|
||
deleteTeam,
|
||
openTeamsTab,
|
||
sendingMessage,
|
||
sendMessageError,
|
||
lastSendMessageResult,
|
||
reviewActionError,
|
||
addMember,
|
||
removeMember,
|
||
updateMemberRole,
|
||
launchTeam,
|
||
provisioningError,
|
||
clearProvisioningError,
|
||
isTeamProvisioning,
|
||
leadActivityByTeam,
|
||
refreshTeamData,
|
||
kanbanFilterQuery,
|
||
clearKanbanFilter,
|
||
softDeleteTask,
|
||
restoreTask,
|
||
fetchDeletedTasks,
|
||
deletedTasks,
|
||
} = useStore(
|
||
useShallow((s) => ({
|
||
data: s.selectedTeamData,
|
||
loading: s.selectedTeamLoading,
|
||
error: s.selectedTeamError,
|
||
projects: s.projects,
|
||
repositoryGroups: s.repositoryGroups,
|
||
teams: s.teams,
|
||
fetchSessionDetail: s.fetchSessionDetail,
|
||
initTabUIState: s.initTabUIState,
|
||
selectTeam: s.selectTeam,
|
||
updateKanban: s.updateKanban,
|
||
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
|
||
updateTaskStatus: s.updateTaskStatus,
|
||
updateTaskOwner: s.updateTaskOwner,
|
||
sendTeamMessage: s.sendTeamMessage,
|
||
requestReview: s.requestReview,
|
||
createTeamTask: s.createTeamTask,
|
||
startTask: s.startTask,
|
||
deleteTeam: s.deleteTeam,
|
||
openTeamsTab: s.openTeamsTab,
|
||
sendingMessage: s.sendingMessage,
|
||
sendMessageError: s.sendMessageError,
|
||
lastSendMessageResult: s.lastSendMessageResult,
|
||
reviewActionError: s.reviewActionError,
|
||
addMember: s.addMember,
|
||
removeMember: s.removeMember,
|
||
updateMemberRole: s.updateMemberRole,
|
||
launchTeam: s.launchTeam,
|
||
provisioningError: s.provisioningError,
|
||
clearProvisioningError: s.clearProvisioningError,
|
||
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
||
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
||
),
|
||
leadActivityByTeam: s.leadActivityByTeam,
|
||
refreshTeamData: s.refreshTeamData,
|
||
kanbanFilterQuery: s.kanbanFilterQuery,
|
||
clearKanbanFilter: s.clearKanbanFilter,
|
||
softDeleteTask: s.softDeleteTask,
|
||
restoreTask: s.restoreTask,
|
||
fetchDeletedTasks: s.fetchDeletedTasks,
|
||
deletedTasks: s.deletedTasks,
|
||
}))
|
||
);
|
||
|
||
// Per-tab UI state (context panel visibility + selected phase)
|
||
const {
|
||
tabId,
|
||
isContextPanelVisible,
|
||
setContextPanelVisible,
|
||
selectedContextPhase,
|
||
setSelectedContextPhase,
|
||
} = useTabUI();
|
||
const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (tabId) {
|
||
initTabUIState(tabId);
|
||
}
|
||
}, [tabId, initTabUIState]);
|
||
|
||
useEffect(() => {
|
||
const wasProvisioning = wasProvisioningRef.current;
|
||
wasProvisioningRef.current = isTeamProvisioning;
|
||
if (!wasProvisioning && isTeamProvisioning) {
|
||
provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}, [isTeamProvisioning]);
|
||
|
||
const [kanbanSearch, setKanbanSearch] = useState('');
|
||
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
|
||
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
|
||
from: new Set(),
|
||
to: new Set(),
|
||
showNoise: false,
|
||
});
|
||
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
|
||
|
||
// Open editor overlay when a file reveal is requested (e.g. from chip click)
|
||
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
|
||
useEffect(() => {
|
||
if (pendingRevealFile && data?.config.projectPath) {
|
||
setEditorOpen(true);
|
||
}
|
||
}, [pendingRevealFile, data?.config.projectPath]);
|
||
|
||
useEffect(() => {
|
||
if (!teamName) {
|
||
return;
|
||
}
|
||
void selectTeam(teamName);
|
||
void fetchDeletedTasks(teamName);
|
||
}, [teamName, selectTeam, fetchDeletedTasks]);
|
||
|
||
// Fetch active teams when launch dialog opens (for conflict warning)
|
||
useEffect(() => {
|
||
if (!launchDialogOpen) return;
|
||
let cancelled = false;
|
||
void (async () => {
|
||
try {
|
||
const aliveList = await api.teams.aliveList();
|
||
if (cancelled) return;
|
||
const aliveSet = new Set(aliveList);
|
||
const refs = teams
|
||
.filter((t) => aliveSet.has(t.teamName) && t.projectPath)
|
||
.map((t) => ({
|
||
teamName: t.teamName,
|
||
displayName: t.displayName,
|
||
projectPath: t.projectPath!,
|
||
}));
|
||
setActiveTeamsForLaunch(refs);
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [launchDialogOpen, teams]);
|
||
|
||
useEffect(() => {
|
||
if (kanbanFilterQuery) {
|
||
setKanbanSearch(kanbanFilterQuery);
|
||
clearKanbanFilter();
|
||
}
|
||
}, [kanbanFilterQuery, clearKanbanFilter]);
|
||
|
||
// Load sessions for the team's project
|
||
const projectId = useMemo(
|
||
() => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups),
|
||
[projects, repositoryGroups, data?.config.projectPath]
|
||
);
|
||
|
||
// Lead session context panel (reuses the same session context pipeline for exact stats)
|
||
const leadSessionId = data?.config.leadSessionId ?? null;
|
||
const leadTabData = useStore(useShallow((s) => (tabId ? s.tabSessionData[tabId] : null)));
|
||
const leadSessionDetail = leadTabData?.sessionDetail ?? null;
|
||
const leadConversation = leadTabData?.conversation ?? null;
|
||
const leadSessionContextStats = leadTabData?.sessionContextStats ?? null;
|
||
const leadSessionPhaseInfo = leadTabData?.sessionPhaseInfo ?? null;
|
||
const leadSessionLoading = leadTabData?.sessionDetailLoading ?? false;
|
||
const leadSessionLoaded = Boolean(
|
||
leadSessionId && leadSessionDetail?.session?.id === leadSessionId
|
||
);
|
||
|
||
const leadSubagentCostUsd = useMemo(() => {
|
||
const processes = leadSessionDetail?.processes;
|
||
if (!processes || processes.length === 0) return undefined;
|
||
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
|
||
return total > 0 ? total : undefined;
|
||
}, [leadSessionDetail?.processes]);
|
||
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
|
||
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
|
||
return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined };
|
||
}
|
||
|
||
// Determine which phase to show
|
||
const effectivePhase = selectedContextPhase;
|
||
|
||
// If a specific phase is selected, find the last AI group in that phase
|
||
let targetAiGroupId: string | undefined;
|
||
if (effectivePhase !== null && leadSessionPhaseInfo) {
|
||
const phase = leadSessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase);
|
||
if (phase) {
|
||
targetAiGroupId = phase.lastAIGroupId;
|
||
}
|
||
}
|
||
|
||
// Default: use the last AI group overall
|
||
if (!targetAiGroupId) {
|
||
const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai');
|
||
if (lastAiItem?.type !== 'ai') {
|
||
return {
|
||
allContextInjections: [] as ContextInjection[],
|
||
lastAiGroupTotalTokens: undefined,
|
||
};
|
||
}
|
||
targetAiGroupId = lastAiItem.group.id;
|
||
}
|
||
|
||
const stats = leadSessionContextStats.get(targetAiGroupId);
|
||
const injections = stats?.accumulatedInjections ?? [];
|
||
|
||
// Get total tokens from the target AI group
|
||
let totalTokens: number | undefined;
|
||
const targetItem = leadConversation.items.find(
|
||
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
|
||
);
|
||
if (targetItem?.type === 'ai') {
|
||
const responses = targetItem.group.responses || [];
|
||
for (let i = responses.length - 1; i >= 0; i--) {
|
||
const msg = responses[i];
|
||
if (msg.type === 'assistant' && msg.usage) {
|
||
const usage = msg.usage;
|
||
totalTokens =
|
||
(usage.input_tokens ?? 0) +
|
||
(usage.output_tokens ?? 0) +
|
||
(usage.cache_read_input_tokens ?? 0) +
|
||
(usage.cache_creation_input_tokens ?? 0);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
||
}, [
|
||
leadSessionLoaded,
|
||
leadSessionContextStats,
|
||
leadConversation,
|
||
selectedContextPhase,
|
||
leadSessionPhaseInfo,
|
||
]);
|
||
|
||
const visibleContextTokens = useMemo(
|
||
() => sumContextInjectionTokens(allContextInjections),
|
||
[allContextInjections]
|
||
);
|
||
const visibleContextPercentLabel = useMemo(
|
||
() => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens),
|
||
[visibleContextTokens, lastAiGroupTotalTokens]
|
||
);
|
||
|
||
const activeTabId = useStore((s) => s.activeTabId);
|
||
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
||
|
||
// Keep lead-session context fresh in the background while the team tab is active.
|
||
// This keeps the button value current even when the panel is closed.
|
||
// For offline teams: fetch once on mount so the percentage shows immediately.
|
||
// For alive teams: fetch on mount + periodic refresh every 30s.
|
||
useEffect(() => {
|
||
if (!isThisTabActive) return;
|
||
if (!tabId || !projectId || !leadSessionId) return;
|
||
|
||
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
|
||
|
||
if (!data?.isAlive) return;
|
||
|
||
const id = window.setInterval(() => {
|
||
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
|
||
}, 30_000);
|
||
return () => window.clearInterval(id);
|
||
}, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]);
|
||
|
||
useEffect(() => {
|
||
if (!projectId) return;
|
||
|
||
let cancelled = false;
|
||
setSessionsLoading(true);
|
||
setSessionsError(null);
|
||
|
||
void (async () => {
|
||
try {
|
||
const result = await api.getSessions(projectId);
|
||
if (!cancelled) {
|
||
setSessions(result);
|
||
}
|
||
} catch (e) {
|
||
if (!cancelled) {
|
||
setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions');
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setSessionsLoading(false);
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId]);
|
||
|
||
// Live git branch polling for the team's project path
|
||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||
const branchSyncPaths = useMemo(
|
||
() => (teamProjectPath ? [teamProjectPath] : []),
|
||
[teamProjectPath]
|
||
);
|
||
useBranchSync(branchSyncPaths, { live: true });
|
||
const leadBranch = useStore((s) =>
|
||
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
|
||
);
|
||
|
||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||
const teamSessions = useMemo(() => {
|
||
const sessionIds = new Set<string>();
|
||
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<TimeWindow | null>(() => {
|
||
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 = useMemo(
|
||
() => (data?.members ?? []).filter((m) => !m.removedAt),
|
||
[data?.members]
|
||
);
|
||
|
||
const leadMemberName = useMemo(
|
||
() => activeMembers.find((m) => m.agentType === 'team-lead')?.name,
|
||
[activeMembers]
|
||
);
|
||
|
||
const filteredMessages = useMemo(() => {
|
||
if (!data) return [];
|
||
let list = data.messages;
|
||
// Temporarily hide lead→user messages from the UI
|
||
// (notifications and other processing still receive them via data.messages)
|
||
if (leadMemberName) {
|
||
list = list.filter((m) => !(m.to?.trim() === 'user' && m.from?.trim() === leadMemberName));
|
||
}
|
||
if (timeWindow) {
|
||
list = list.filter((m) => {
|
||
const ts = new Date(m.timestamp).getTime();
|
||
return ts >= timeWindow.start && ts < timeWindow.end;
|
||
});
|
||
}
|
||
if (!messagesFilter.showNoise) {
|
||
list = list.filter((m) => !isInboxNoiseMessage(typeof m.text === 'string' ? m.text : ''));
|
||
}
|
||
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, leadMemberName]);
|
||
|
||
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 activeTeammateCount = useMemo(
|
||
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
|
||
[activeMembers]
|
||
);
|
||
|
||
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||
|
||
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
||
|
||
useEffect(() => {
|
||
if (!data || Object.keys(pendingRepliesByMember).length === 0) return;
|
||
const next = { ...pendingRepliesByMember };
|
||
let changed = false;
|
||
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
||
const hasReply = data.messages.some((m) => {
|
||
if (m.from !== memberName) return false;
|
||
const ts = Date.parse(m.timestamp);
|
||
return Number.isFinite(ts) && ts > sentAtMs;
|
||
});
|
||
if (hasReply) {
|
||
delete next[memberName];
|
||
changed = true;
|
||
}
|
||
}
|
||
if (changed) setPendingRepliesByMember(next);
|
||
}, [data, pendingRepliesByMember]);
|
||
|
||
const openCreateTaskDialog = (
|
||
subject = '',
|
||
description = '',
|
||
owner = '',
|
||
startImmediately?: boolean
|
||
): void => {
|
||
setCreateTaskDialog({
|
||
open: true,
|
||
defaultSubject: subject,
|
||
defaultDescription: description,
|
||
defaultOwner: owner,
|
||
defaultStartImmediately: startImmediately,
|
||
});
|
||
};
|
||
|
||
const closeCreateTaskDialog = (): void => {
|
||
setCreateTaskDialog({
|
||
open: false,
|
||
defaultSubject: '',
|
||
defaultDescription: '',
|
||
defaultOwner: '',
|
||
defaultStartImmediately: undefined,
|
||
});
|
||
};
|
||
|
||
const handleEditorAction = useCallback(
|
||
(action: EditorSelectionAction) => {
|
||
const chip = createChipFromSelection(action, []) ?? undefined;
|
||
if (action.type === 'sendMessage') {
|
||
setSendDialogDefaultText(chip ? undefined : action.formattedContext);
|
||
setSendDialogDefaultChip(chip);
|
||
setSendDialogRecipient(undefined);
|
||
setReplyQuote(undefined);
|
||
setSendDialogOpen(true);
|
||
} else if (action.type === 'createTask') {
|
||
if (chip) {
|
||
setCreateTaskDialog({
|
||
open: true,
|
||
defaultSubject: '',
|
||
defaultDescription: '',
|
||
defaultOwner: '',
|
||
defaultStartImmediately: undefined,
|
||
defaultChip: chip,
|
||
});
|
||
} else {
|
||
openCreateTaskDialog('', action.formattedContext);
|
||
}
|
||
}
|
||
},
|
||
|
||
[]
|
||
);
|
||
|
||
const handleStopTeam = useCallback(async (): Promise<void> => {
|
||
setStoppingTeam(true);
|
||
try {
|
||
await api.teams.stop(teamName);
|
||
// Backend sends 'disconnected' progress which triggers store refresh,
|
||
// but refresh here too as a safety net (e.g. if progress event is missed).
|
||
await refreshTeamData(teamName);
|
||
} catch (err) {
|
||
console.error('Failed to stop team:', err);
|
||
} finally {
|
||
setStoppingTeam(false);
|
||
}
|
||
}, [teamName, refreshTeamData]);
|
||
|
||
const selectReviewFile = useStore((s) => s.selectReviewFile);
|
||
const pendingReviewRequest = useStore((s) => s.pendingReviewRequest);
|
||
const setPendingReviewRequest = useStore((s) => s.setPendingReviewRequest);
|
||
|
||
// Pick up pending review request from GlobalTaskDetailDialog
|
||
useEffect(() => {
|
||
if (!pendingReviewRequest) return;
|
||
setReviewDialogState({
|
||
open: true,
|
||
mode: 'task',
|
||
taskId: pendingReviewRequest.taskId,
|
||
initialFilePath: pendingReviewRequest.filePath,
|
||
});
|
||
if (pendingReviewRequest.filePath) {
|
||
selectReviewFile(pendingReviewRequest.filePath);
|
||
}
|
||
setPendingReviewRequest(null);
|
||
}, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]);
|
||
|
||
const handleDeleteTask = useCallback(
|
||
(taskId: string) => {
|
||
void (async () => {
|
||
const confirmed = await confirm({
|
||
title: 'Delete task',
|
||
message: `Move task #${taskId} to trash?`,
|
||
confirmLabel: 'Delete',
|
||
cancelLabel: 'Cancel',
|
||
variant: 'danger',
|
||
});
|
||
if (confirmed) {
|
||
try {
|
||
await softDeleteTask(teamName, taskId);
|
||
} catch {
|
||
// error via store
|
||
}
|
||
}
|
||
})();
|
||
},
|
||
[teamName, softDeleteTask]
|
||
);
|
||
|
||
const handleViewChanges = useCallback((taskId: string) => {
|
||
setReviewDialogState({ open: true, mode: 'task', taskId });
|
||
}, []);
|
||
|
||
const handleViewChangesForFile = useCallback(
|
||
(taskId: string, filePath?: string) => {
|
||
setReviewDialogState({ open: true, mode: 'task', taskId });
|
||
if (filePath) {
|
||
selectReviewFile(filePath);
|
||
}
|
||
},
|
||
[selectReviewFile]
|
||
);
|
||
|
||
const handleDeleteTeam = useCallback((): void => {
|
||
setDeleteConfirmOpen(true);
|
||
}, []);
|
||
|
||
const confirmDeleteTeam = useCallback((): void => {
|
||
setDeleteConfirmOpen(false);
|
||
void (async () => {
|
||
try {
|
||
await deleteTeam(teamName);
|
||
openTeamsTab();
|
||
} catch {
|
||
// error is shown via store
|
||
}
|
||
})();
|
||
}, [teamName, deleteTeam, openTeamsTab]);
|
||
|
||
const handleCreateTask = (
|
||
subject: string,
|
||
description: string,
|
||
owner?: string,
|
||
blockedBy?: string[],
|
||
related?: string[],
|
||
prompt?: string,
|
||
startImmediately?: boolean
|
||
): void => {
|
||
setCreatingTask(true);
|
||
void (async () => {
|
||
try {
|
||
await createTeamTask(teamName, {
|
||
subject,
|
||
description: description || undefined,
|
||
owner,
|
||
blockedBy,
|
||
related,
|
||
prompt,
|
||
startImmediately,
|
||
});
|
||
|
||
if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) {
|
||
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
|
||
try {
|
||
await api.teams.processSend(teamName, msg);
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
}
|
||
|
||
closeCreateTaskDialog();
|
||
} catch {
|
||
// error shown via store
|
||
} finally {
|
||
setCreatingTask(false);
|
||
}
|
||
})();
|
||
};
|
||
|
||
if (!teamName) {
|
||
return (
|
||
<div className="flex size-full items-center justify-center p-6 text-sm text-red-400">
|
||
Invalid team tab
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (loading && !data) {
|
||
return (
|
||
<div className="size-full overflow-auto p-4">
|
||
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||
<div ref={provisioningBannerRef}>
|
||
<TeamProvisioningBanner teamName={teamName} />
|
||
</div>
|
||
<div className="space-y-3">
|
||
<div className="h-24 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||
<div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||
<div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex size-full items-center justify-center p-6">
|
||
<div className="text-center">
|
||
<p className="text-sm font-medium text-red-400">Failed to load team</p>
|
||
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{error}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!data) {
|
||
return (
|
||
<div className="flex size-full items-center justify-center p-6 text-sm text-[var(--color-text-muted)]">
|
||
No team data available
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const headerColorSet = data.config.color
|
||
? getTeamColorSet(data.config.color)
|
||
: nameColorSet(data.config.name);
|
||
|
||
return (
|
||
<>
|
||
<div className="flex size-full overflow-hidden">
|
||
<div
|
||
ref={contentRef}
|
||
className="relative size-full flex-1 overflow-auto p-4"
|
||
data-team-name={teamName}
|
||
>
|
||
{/* Context button pinned to bottom-right of viewport */}
|
||
{leadSessionId && (
|
||
<div
|
||
className="pointer-events-none fixed bottom-4 z-20"
|
||
style={{ right: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }}
|
||
>
|
||
<button
|
||
onClick={() => {
|
||
const next = !isContextPanelVisible;
|
||
setContextPanelVisible(next);
|
||
if (tabId && projectId && leadSessionId) {
|
||
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
|
||
}
|
||
>
|
||
{visibleContextPercentLabel ?? 'Context'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3"
|
||
style={
|
||
headerColorSet
|
||
? { borderLeftWidth: '3px', borderLeftColor: headerColorSet.border }
|
||
: undefined
|
||
}
|
||
>
|
||
{headerColorSet ? (
|
||
<div
|
||
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
|
||
style={{ backgroundColor: headerColorSet.badge }}
|
||
/>
|
||
) : null}
|
||
<div
|
||
className={cn(
|
||
'flex items-start justify-between gap-2',
|
||
headerColorSet && 'relative z-10'
|
||
)}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<h2 className="text-base font-semibold text-[var(--color-text)]">
|
||
{data.config.name}
|
||
</h2>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
{data.isAlive && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||
disabled={stoppingTeam}
|
||
onClick={() => void handleStopTeam()}
|
||
>
|
||
<Square size={12} className={stoppingTeam ? 'animate-pulse' : ''} />
|
||
Stop
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">Stop team</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||
onClick={() => setEditDialogOpen(true)}
|
||
>
|
||
<Pencil size={12} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">Edit team</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||
onClick={handleDeleteTeam}
|
||
>
|
||
<Trash2 size={12} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">Delete team</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
{data.config.description && (
|
||
<p
|
||
className={cn(
|
||
'min-w-0 truncate text-xs text-[var(--color-text-muted)]',
|
||
headerColorSet && 'relative z-10'
|
||
)}
|
||
>
|
||
{data.config.description}
|
||
</p>
|
||
)}
|
||
{(data.config.projectPath || leadBranch) && (
|
||
<div
|
||
className={cn(
|
||
'mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5',
|
||
headerColorSet && 'relative z-10'
|
||
)}
|
||
>
|
||
{data.config.projectPath && (
|
||
<span
|
||
className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"
|
||
title={data.config.projectPath}
|
||
>
|
||
<FolderOpen size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||
<span className="max-w-60 truncate font-mono">
|
||
{formatProjectPath(data.config.projectPath)}
|
||
</span>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
onClick={() => 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)]"
|
||
>
|
||
<Code size={10} className="shrink-0" /> Edit code
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Open project in built-in editor</TooltipContent>
|
||
</Tooltip>
|
||
</span>
|
||
)}
|
||
{leadBranch && (
|
||
<span
|
||
className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"
|
||
title={leadBranch}
|
||
>
|
||
<GitBranch size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||
<span className="max-w-32 truncate">{leadBranch}</span>
|
||
</span>
|
||
)}
|
||
{data.isAlive && (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||
<span className="size-1.5 rounded-full bg-emerald-400" />
|
||
Running
|
||
</span>
|
||
)}
|
||
{!data.isAlive && isTeamProvisioning && (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
||
<span className="size-1.5 animate-pulse rounded-full bg-yellow-400" />
|
||
Launching...
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{(() => {
|
||
const currentPath = data.config.projectPath;
|
||
const history = data.config.projectPathHistory?.filter((p) => p !== currentPath);
|
||
if (!history || history.length === 0) return null;
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]',
|
||
headerColorSet && 'relative z-10'
|
||
)}
|
||
>
|
||
<History size={10} className="shrink-0" />
|
||
<span className="truncate">
|
||
Previous: {history.map((p) => formatProjectPath(p)).join(', ')}
|
||
</span>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{!data.isAlive && !isTeamProvisioning ? (
|
||
<div
|
||
className="mb-3 flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||
style={{
|
||
backgroundColor: 'var(--warning-bg)',
|
||
borderColor: 'var(--warning-border)',
|
||
color: 'var(--warning-text)',
|
||
}}
|
||
>
|
||
<span className="flex items-center gap-1.5 text-xs">
|
||
<AlertTriangle size={14} className="shrink-0" />
|
||
Team is offline
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 shrink-0 gap-1 px-2 text-xs text-[var(--step-done-text)] hover:bg-[var(--step-done-bg)]"
|
||
onClick={() => setLaunchDialogOpen(true)}
|
||
>
|
||
<Play size={12} />
|
||
Launch
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
<div ref={provisioningBannerRef}>
|
||
<TeamProvisioningBanner teamName={teamName} />
|
||
</div>
|
||
|
||
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
|
||
<div className="mb-3 rounded-md border border-[var(--step-warning-border)] bg-[var(--step-warning-bg)] px-3 py-2 text-xs text-[var(--step-warning-text)]">
|
||
Failed to fully load kanban. Displaying safe data.
|
||
</div>
|
||
) : null}
|
||
{reviewActionError ? (
|
||
<div className="mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-[var(--step-error-text)]">
|
||
{reviewActionError}
|
||
</div>
|
||
) : null}
|
||
|
||
<CollapsibleTeamSection
|
||
sectionId="team"
|
||
title="Team"
|
||
icon={<Users size={14} />}
|
||
badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount}
|
||
defaultOpen
|
||
action={
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setAddMemberDialogOpen(true);
|
||
}}
|
||
>
|
||
<UserPlus size={12} />
|
||
Member
|
||
</Button>
|
||
}
|
||
>
|
||
<MemberList
|
||
members={data.members}
|
||
memberTaskCounts={memberTaskCounts}
|
||
taskMap={taskMap}
|
||
pendingRepliesByMember={pendingRepliesByMember}
|
||
isTeamAlive={data.isAlive}
|
||
isTeamProvisioning={isTeamProvisioning}
|
||
leadActivity={leadActivityByTeam[teamName]}
|
||
onMemberClick={setSelectedMember}
|
||
onSendMessage={(member) => {
|
||
setSendDialogRecipient(member.name);
|
||
setSendDialogDefaultText(undefined);
|
||
setSendDialogDefaultChip(undefined);
|
||
setReplyQuote(undefined);
|
||
setSendDialogOpen(true);
|
||
}}
|
||
onAssignTask={(member) => {
|
||
openCreateTaskDialog('', '', member.name);
|
||
}}
|
||
onOpenTask={(task) => setSelectedTask(task)}
|
||
/>
|
||
</CollapsibleTeamSection>
|
||
|
||
<CollapsibleTeamSection
|
||
sectionId="sessions"
|
||
title="Sessions"
|
||
icon={<History size={14} />}
|
||
defaultOpen={false}
|
||
>
|
||
<TeamSessionsSection
|
||
sessions={teamSessions}
|
||
sessionsLoading={sessionsLoading}
|
||
sessionsError={sessionsError}
|
||
leadSessionId={data.config.leadSessionId}
|
||
selectedSessionId={kanbanFilter.sessionId}
|
||
onSelectSession={(id) => setKanbanFilter((prev) => ({ ...prev, sessionId: id }))}
|
||
projectPath={data.config.projectPath}
|
||
/>
|
||
</CollapsibleTeamSection>
|
||
|
||
<CollapsibleTeamSection
|
||
sectionId="kanban"
|
||
title="Kanban"
|
||
icon={<Columns3 size={14} />}
|
||
badge={filteredTasks.length}
|
||
defaultOpen
|
||
forceOpen={kanbanSearch.trim().length > 0}
|
||
action={
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openCreateTaskDialog();
|
||
}}
|
||
>
|
||
<Plus size={12} />
|
||
Task
|
||
</Button>
|
||
}
|
||
>
|
||
<KanbanBoard
|
||
tasks={kanbanDisplayTasks}
|
||
teamName={teamName}
|
||
kanbanState={data.kanbanState}
|
||
filter={kanbanFilter}
|
||
sessions={teamSessions}
|
||
leadSessionId={data.config.leadSessionId}
|
||
members={activeMembers}
|
||
onFilterChange={setKanbanFilter}
|
||
toolbarLeft={
|
||
<div className="relative max-w-[240px]">
|
||
<Search
|
||
size={14}
|
||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||
/>
|
||
<input
|
||
type="text"
|
||
placeholder="Search tasks… (#id or text)"
|
||
value={kanbanSearch}
|
||
onChange={(e) => 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 && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||
onClick={() => setKanbanSearch('')}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">Clear search</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
}
|
||
onRequestReview={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await requestReview(teamName, taskId);
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onApprove={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' });
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onRequestChanges={(taskId) => {
|
||
setRequestChangesTaskId(taskId);
|
||
}}
|
||
onMoveBackToDone={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await updateKanban(teamName, taskId, { op: 'remove' });
|
||
await updateTaskStatus(teamName, taskId, 'completed');
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onStartTask={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
const result = await startTask(teamName, taskId);
|
||
if (data?.isAlive) {
|
||
const task = data.tasks.find((t) => t.id === taskId);
|
||
try {
|
||
if (result.notifiedOwner && task?.owner) {
|
||
await api.teams.processSend(
|
||
teamName,
|
||
`Task #${taskId} "${task.subject}" has started. Please begin working on it.`
|
||
);
|
||
} else if (!result.notifiedOwner) {
|
||
const desc = task?.description?.trim()
|
||
? `\nDescription: ${task.description.trim()}`
|
||
: '';
|
||
await api.teams.processSend(
|
||
teamName,
|
||
`Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.`
|
||
);
|
||
}
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
}
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onCompleteTask={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await updateTaskStatus(teamName, taskId, 'completed');
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onCancelTask={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
const task = data?.tasks.find((t) => t.id === taskId);
|
||
await updateTaskStatus(teamName, taskId, 'pending');
|
||
|
||
// Notify assignee directly via inbox — they'll see it immediately
|
||
if (task?.owner) {
|
||
try {
|
||
await api.teams.sendMessage(teamName, {
|
||
member: task.owner,
|
||
text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`,
|
||
summary: `Task #${taskId} cancelled`,
|
||
});
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
}
|
||
|
||
// Also notify team lead so they can reassign/coordinate
|
||
if (data?.isAlive) {
|
||
try {
|
||
const ownerSuffix = task?.owner
|
||
? ` ${task.owner} has been notified to stop.`
|
||
: '';
|
||
await api.teams.processSend(
|
||
teamName,
|
||
`Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}`
|
||
);
|
||
} catch {
|
||
// best-effort
|
||
}
|
||
}
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onColumnOrderChange={(columnId, orderedTaskIds) => {
|
||
void (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.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)}
|
||
/>
|
||
</CollapsibleTeamSection>
|
||
|
||
{(data.processes?.length ?? 0) > 0 && (
|
||
<CollapsibleTeamSection
|
||
sectionId="processes"
|
||
title="CLI Processes"
|
||
icon={<Terminal size={14} />}
|
||
badge={data.processes.filter((p) => !p.stoppedAt).length}
|
||
headerExtra={
|
||
data.processes.some((p) => !p.stoppedAt) ? (
|
||
<span
|
||
className="pointer-events-none relative inline-flex size-2 shrink-0"
|
||
title="Active"
|
||
>
|
||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||
</span>
|
||
) : null
|
||
}
|
||
defaultOpen
|
||
>
|
||
<ProcessesSection />
|
||
</CollapsibleTeamSection>
|
||
)}
|
||
|
||
<ClaudeLogsSection teamName={teamName} />
|
||
|
||
<CollapsibleTeamSection
|
||
sectionId="messages"
|
||
title="Messages"
|
||
icon={<MessageSquare size={14} />}
|
||
badge={filteredMessages.length}
|
||
secondaryBadge={
|
||
filteredMessages.length > 0 && messagesUnreadCount > 0
|
||
? messagesUnreadCount
|
||
: undefined
|
||
}
|
||
afterBadge={
|
||
messagesUnreadCount > 0 ? (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleMarkAllRead();
|
||
}}
|
||
>
|
||
<CheckCheck size={12} />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">Mark all as read</TooltipContent>
|
||
</Tooltip>
|
||
) : undefined
|
||
}
|
||
headerExtra={
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
void window.electronAPI.openExternal(
|
||
'https://github.com/777genius/claude-notifications-go'
|
||
);
|
||
}}
|
||
>
|
||
<Bell size={12} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
|
||
</Tooltip>
|
||
}
|
||
defaultOpen
|
||
action={
|
||
<div className="flex items-center gap-2 pl-2">
|
||
<div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search..."
|
||
value={messagesSearchQuery}
|
||
onChange={(e) => setMessagesSearchQuery(e.target.value)}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||
/>
|
||
{messagesSearchQuery && (
|
||
<button
|
||
type="button"
|
||
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||
onClick={() => setMessagesSearchQuery('')}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<MessagesFilterPopover
|
||
filter={messagesFilter}
|
||
messages={data?.messages ?? []}
|
||
open={messagesFilterOpen}
|
||
onOpenChange={setMessagesFilterOpen}
|
||
onApply={setMessagesFilter}
|
||
/>
|
||
</div>
|
||
}
|
||
>
|
||
<MessageComposer
|
||
teamName={teamName}
|
||
members={activeMembers}
|
||
isTeamAlive={data.isAlive}
|
||
sending={sendingMessage}
|
||
sendError={sendMessageError}
|
||
onSend={(member, text, summary, attachments) => {
|
||
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;
|
||
});
|
||
});
|
||
}}
|
||
/>
|
||
<PendingRepliesBlock
|
||
members={data.members}
|
||
pendingRepliesByMember={pendingRepliesByMember}
|
||
onMemberClick={setSelectedMember}
|
||
/>
|
||
<ActiveTasksBlock
|
||
members={data.members}
|
||
tasks={data.tasks}
|
||
onMemberClick={setSelectedMember}
|
||
onTaskClick={setSelectedTask}
|
||
/>
|
||
<ActivityTimeline
|
||
messages={filteredMessages}
|
||
teamName={teamName}
|
||
members={data.members}
|
||
readState={{ readSet, getMessageKey: toMessageKey }}
|
||
onMemberClick={setSelectedMember}
|
||
onCreateTaskFromMessage={(subject, description) => {
|
||
openCreateTaskDialog(subject, description);
|
||
}}
|
||
onReplyToMessage={(message) => {
|
||
setSendDialogRecipient(message.from);
|
||
setSendDialogDefaultText(undefined);
|
||
setSendDialogDefaultChip(undefined);
|
||
setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) });
|
||
setSendDialogOpen(true);
|
||
}}
|
||
onMessageVisible={handleMessageVisible}
|
||
onRestartTeam={() => setLaunchDialogOpen(true)}
|
||
onTaskIdClick={(taskId) => {
|
||
const task = taskMap.get(taskId);
|
||
if (task) setSelectedTask(task);
|
||
}}
|
||
/>
|
||
</CollapsibleTeamSection>
|
||
|
||
<ReviewDialog
|
||
open={requestChangesTaskId !== null}
|
||
teamName={teamName}
|
||
taskId={requestChangesTaskId}
|
||
members={data?.members ?? []}
|
||
onCancel={() => 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
|
||
}
|
||
})();
|
||
}}
|
||
/>
|
||
|
||
<MemberDetailDialog
|
||
open={selectedMember !== null}
|
||
member={selectedMember}
|
||
teamName={teamName}
|
||
tasks={data.tasks}
|
||
messages={data.messages}
|
||
isTeamAlive={data.isAlive}
|
||
isTeamProvisioning={isTeamProvisioning}
|
||
leadActivity={leadActivityByTeam[teamName]}
|
||
onClose={() => 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,
|
||
});
|
||
}}
|
||
/>
|
||
|
||
<CreateTaskDialog
|
||
open={createTaskDialog.open}
|
||
teamName={teamName}
|
||
members={activeMembers}
|
||
tasks={data.tasks}
|
||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||
defaultSubject={createTaskDialog.defaultSubject}
|
||
defaultDescription={createTaskDialog.defaultDescription}
|
||
defaultOwner={createTaskDialog.defaultOwner}
|
||
defaultStartImmediately={createTaskDialog.defaultStartImmediately}
|
||
defaultChip={createTaskDialog.defaultChip}
|
||
onClose={closeCreateTaskDialog}
|
||
onSubmit={handleCreateTask}
|
||
submitting={creatingTask}
|
||
/>
|
||
|
||
<EditTeamDialog
|
||
open={editDialogOpen}
|
||
teamName={teamName}
|
||
currentName={data.config.name}
|
||
currentDescription={data.config.description ?? ''}
|
||
currentColor={data.config.color ?? ''}
|
||
currentMembers={data.members.filter((m) => m.agentType !== 'team-lead')}
|
||
projectPath={data.config.projectPath}
|
||
onClose={() => setEditDialogOpen(false)}
|
||
onSaved={() => void selectTeam(teamName)}
|
||
/>
|
||
|
||
<AddMemberDialog
|
||
open={addMemberDialogOpen}
|
||
teamName={teamName}
|
||
existingNames={data.members.map((m) => m.name)}
|
||
existingMembers={data.members}
|
||
projectPath={data.config.projectPath}
|
||
adding={addingMemberLoading}
|
||
onClose={() => setAddMemberDialogOpen(false)}
|
||
onAdd={(name, role, workflow) => {
|
||
setAddingMemberLoading(true);
|
||
void (async () => {
|
||
try {
|
||
await addMember(teamName, { name, role, workflow });
|
||
setAddMemberDialogOpen(false);
|
||
} catch {
|
||
// error shown via store
|
||
} finally {
|
||
setAddingMemberLoading(false);
|
||
}
|
||
})();
|
||
}}
|
||
/>
|
||
|
||
<Dialog
|
||
open={removeMemberConfirm !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) setRemoveMemberConfirm(null);
|
||
}}
|
||
>
|
||
<DialogContent className="max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle>Remove member</DialogTitle>
|
||
<DialogDescription>
|
||
Remove “{removeMemberConfirm}” from the team? Tasks and messages will
|
||
be preserved, but this name cannot be reused.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="ghost" size="sm" onClick={() => setRemoveMemberConfirm(null)}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
onClick={() => {
|
||
const name = removeMemberConfirm;
|
||
setRemoveMemberConfirm(null);
|
||
setSelectedMember(null);
|
||
if (name) void removeMember(teamName, name);
|
||
}}
|
||
>
|
||
Remove
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||
<DialogContent className="max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle>Delete team</DialogTitle>
|
||
<DialogDescription>
|
||
Delete team “{data.config.name}”? This action is irreversible. All
|
||
team data and tasks will be deleted.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirmOpen(false)}>
|
||
Cancel
|
||
</Button>
|
||
<Button variant="destructive" size="sm" onClick={confirmDeleteTeam}>
|
||
Delete
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<LaunchTeamDialog
|
||
open={launchDialogOpen}
|
||
teamName={teamName}
|
||
members={data?.members ?? []}
|
||
defaultProjectPath={data.config.projectPath}
|
||
provisioningError={provisioningError}
|
||
clearProvisioningError={clearProvisioningError}
|
||
activeTeams={activeTeamsForLaunch}
|
||
onClose={() => setLaunchDialogOpen(false)}
|
||
onLaunch={async (request) => {
|
||
await launchTeam(request);
|
||
}}
|
||
/>
|
||
|
||
<SendMessageDialog
|
||
open={sendDialogOpen}
|
||
teamName={teamName}
|
||
members={activeMembers}
|
||
defaultRecipient={sendDialogRecipient}
|
||
defaultText={sendDialogDefaultText}
|
||
defaultChip={sendDialogDefaultChip}
|
||
quotedMessage={replyQuote}
|
||
isTeamAlive={data.isAlive}
|
||
sending={sendingMessage}
|
||
sendError={sendMessageError}
|
||
lastResult={lastSendMessageResult}
|
||
onSend={(member, text, summary, attachments) => {
|
||
void (async () => {
|
||
const sentAtMs = Date.now();
|
||
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||
try {
|
||
await sendTeamMessage(teamName, { member, text, summary, attachments });
|
||
} catch {
|
||
setPendingRepliesByMember((prev) => {
|
||
if (prev[member] !== sentAtMs) return prev;
|
||
const next = { ...prev };
|
||
delete next[member];
|
||
return next;
|
||
});
|
||
}
|
||
})();
|
||
}}
|
||
onClose={() => {
|
||
setSendDialogOpen(false);
|
||
setReplyQuote(undefined);
|
||
setSendDialogDefaultText(undefined);
|
||
setSendDialogDefaultChip(undefined);
|
||
}}
|
||
/>
|
||
|
||
<TaskDetailDialog
|
||
open={selectedTask !== null}
|
||
task={selectedTask}
|
||
teamName={teamName}
|
||
kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined}
|
||
taskMap={taskMap}
|
||
members={activeMembers}
|
||
onClose={() => 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 (async () => {
|
||
try {
|
||
await updateTaskOwner(teamName, taskId, owner);
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
onViewChanges={handleViewChangesForFile}
|
||
onOpenInEditor={(filePath) => {
|
||
const { revealFileInEditor } = useStore.getState();
|
||
revealFileInEditor(filePath);
|
||
}}
|
||
onDeleteTask={handleDeleteTask}
|
||
/>
|
||
|
||
<TrashDialog
|
||
open={trashOpen}
|
||
tasks={deletedTasks}
|
||
onClose={() => setTrashOpen(false)}
|
||
onRestore={(taskId) => {
|
||
void (async () => {
|
||
try {
|
||
await restoreTask(teamName, taskId);
|
||
} catch {
|
||
// error via store
|
||
}
|
||
})();
|
||
}}
|
||
/>
|
||
|
||
<ChangeReviewDialog
|
||
open={reviewDialogState.open}
|
||
onOpenChange={(open) =>
|
||
setReviewDialogState((prev) => ({
|
||
...prev,
|
||
open,
|
||
...(open ? {} : { initialFilePath: undefined }),
|
||
}))
|
||
}
|
||
teamName={teamName}
|
||
mode={reviewDialogState.mode}
|
||
memberName={reviewDialogState.memberName}
|
||
taskId={reviewDialogState.taskId}
|
||
initialFilePath={reviewDialogState.initialFilePath}
|
||
projectPath={data.config.projectPath}
|
||
onEditorAction={handleEditorAction}
|
||
/>
|
||
</div>
|
||
|
||
{/* Context panel sidebar */}
|
||
{isContextPanelVisible && leadSessionId && (
|
||
<div className="w-80 shrink-0">
|
||
{leadSessionLoaded ? (
|
||
<SessionContextPanel
|
||
injections={allContextInjections}
|
||
onClose={() => setContextPanelVisible(false)}
|
||
projectRoot={leadSessionDetail?.session?.projectPath ?? data.config.projectPath}
|
||
totalSessionTokens={lastAiGroupTotalTokens}
|
||
sessionMetrics={leadSessionDetail?.metrics}
|
||
subagentCostUsd={leadSubagentCostUsd}
|
||
phaseInfo={leadSessionPhaseInfo ?? undefined}
|
||
selectedPhase={selectedContextPhase}
|
||
onPhaseChange={setSelectedContextPhase}
|
||
/>
|
||
) : (
|
||
<div
|
||
className="flex h-full flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)]"
|
||
style={{ backgroundColor: 'var(--color-surface)' }}
|
||
>
|
||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium text-[var(--color-text)]">Visible Context</p>
|
||
<p className="text-[10px] text-[var(--color-text-muted)]">
|
||
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||
onClick={() => setContextPanelVisible(false)}
|
||
aria-label="Close panel"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-1 items-center justify-center p-4">
|
||
<p className="text-xs text-[var(--color-text-muted)]">
|
||
{leadSessionLoading
|
||
? 'Loading context…'
|
||
: 'Open the team lead session to view context.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{editorOpen && data.config.projectPath && (
|
||
<Suspense fallback={null}>
|
||
<ProjectEditorOverlay
|
||
projectPath={data.config.projectPath}
|
||
onClose={() => setEditorOpen(false)}
|
||
onEditorAction={handleEditorAction}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</>
|
||
);
|
||
};
|