- claude-devtools
+ Claude Agent Teams UI
+
+
+
+ Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
+ badges. Works on macOS, Linux, and Windows.
+
+
+ void api.openExternal('https://github.com/777genius/claude-notifications-go')
+ }
+ className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
+ style={{
+ backgroundColor: 'var(--color-border-emphasis)',
+ color: 'var(--color-text)',
+ }}
+ >
+
+ Install claude-notifications-go plugin
+
+
+
Notifications from these repositories will be ignored
diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx
new file mode 100644
index 00000000..f5e91817
--- /dev/null
+++ b/src/renderer/components/sidebar/GlobalTaskList.tsx
@@ -0,0 +1,224 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+import { useStore } from '@renderer/store';
+import { getNonEmptyTaskCategories, groupTasksByDate } from '@renderer/utils/taskGrouping';
+import { ListTodo, Search, X } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
+
+import { SidebarTaskItem } from './SidebarTaskItem';
+
+import type { GlobalTask } from '@shared/types';
+
+type StatusFilter = 'all' | 'active' | 'done';
+
+const filterButtons: { value: StatusFilter; label: string }[] = [
+ { value: 'all', label: 'All' },
+ { value: 'active', label: 'Active' },
+ { value: 'done', label: 'Done' },
+];
+
+const dateCategoryLabels: Record = {
+ 'Previous 7 Days': 'Last 7 Days',
+ Older: 'Earlier',
+};
+
+function normalizePath(p: string): string {
+ return p.endsWith('/') ? p.slice(0, -1) : p;
+}
+
+function applyFilter(tasks: GlobalTask[], filter: StatusFilter): GlobalTask[] {
+ if (filter === 'all') return tasks;
+ if (filter === 'active')
+ return tasks.filter((t) => t.status === 'pending' || t.status === 'in_progress');
+ return tasks.filter((t) => t.status === 'completed');
+}
+
+function applySearch(tasks: GlobalTask[], query: string): GlobalTask[] {
+ if (!query.trim()) return tasks;
+ const q = query.toLowerCase();
+ return tasks.filter(
+ (t) =>
+ t.subject.toLowerCase().includes(q) ||
+ t.owner?.toLowerCase().includes(q) ||
+ t.teamDisplayName.toLowerCase().includes(q)
+ );
+}
+
+function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): GlobalTask[] {
+ if (!projectPath) return tasks;
+ const normalized = normalizePath(projectPath);
+ return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized);
+}
+
+export const GlobalTaskList = (): React.JSX.Element => {
+ const {
+ globalTasks,
+ globalTasksLoading,
+ fetchAllTasks,
+ projects,
+ activeProjectId,
+ viewMode,
+ repositoryGroups,
+ selectedRepositoryId,
+ selectedWorktreeId,
+ } = useStore(
+ useShallow((s) => ({
+ globalTasks: s.globalTasks,
+ globalTasksLoading: s.globalTasksLoading,
+ fetchAllTasks: s.fetchAllTasks,
+ projects: s.projects,
+ activeProjectId: s.activeProjectId,
+ viewMode: s.viewMode,
+ repositoryGroups: s.repositoryGroups,
+ selectedRepositoryId: s.selectedRepositoryId,
+ selectedWorktreeId: s.selectedWorktreeId,
+ }))
+ );
+
+ const [filter, setFilter] = useState('all');
+ const [searchQuery, setSearchQuery] = useState('');
+ const searchInputRef = useRef(null);
+
+ useEffect(() => {
+ if (globalTasks.length === 0 && !globalTasksLoading) {
+ void fetchAllTasks();
+ }
+ }, [globalTasks.length, globalTasksLoading, fetchAllTasks]);
+
+ const selectedProjectPath = useMemo(() => {
+ if (viewMode === 'grouped') {
+ const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
+ const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId);
+ return worktree?.path ?? null;
+ }
+ const project = projects.find((p) => p.id === activeProjectId);
+ return project?.path ?? null;
+ }, [
+ viewMode,
+ repositoryGroups,
+ selectedRepositoryId,
+ selectedWorktreeId,
+ projects,
+ activeProjectId,
+ ]);
+
+ const filtered = useMemo(() => {
+ let result = globalTasks;
+ result = applyProjectFilter(result, selectedProjectPath);
+ result = applyFilter(result, filter);
+ result = applySearch(result, searchQuery);
+ return result;
+ }, [globalTasks, selectedProjectPath, filter, searchQuery]);
+
+ const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]);
+ const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
+
+ return (
+
+ {/* Header + Filter bar */}
+
+
Tasks
+
+ {filterButtons.map((btn) => (
+ setFilter(btn.value)}
+ >
+ {btn.label}
+
+ ))}
+
+
+
+ {/* Search bar */}
+
+
+ setSearchQuery(e.target.value)}
+ className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none"
+ />
+ {searchQuery && (
+ {
+ setSearchQuery('');
+ searchInputRef.current?.focus();
+ }}
+ >
+
+
+ )}
+
+
+ {/* Content */}
+
+ {globalTasksLoading && globalTasks.length === 0 && (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ )}
+
+ {!globalTasksLoading && categories.length === 0 && (
+
+
+
+ {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'}
+
+
+ )}
+
+ {categories.map((category) => {
+ const tasks = grouped[category];
+ let lastTeam: string | null = null;
+
+ return (
+
+ {/* Date header */}
+
+ {dateCategoryLabels[category] ?? category}
+
+
+ {tasks.map((task) => {
+ const showTeamHeader = task.teamName !== lastTeam;
+ lastTeam = task.teamName;
+
+ return (
+
+ {showTeamHeader && (
+
+ Team: {task.teamDisplayName}
+
+ )}
+
+
+ );
+ })}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx
new file mode 100644
index 00000000..6d9d498f
--- /dev/null
+++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx
@@ -0,0 +1,58 @@
+import { useStore } from '@renderer/store';
+import { format, isThisYear, isToday, isYesterday } from 'date-fns';
+import { CheckCircle2, Circle, Loader2 } from 'lucide-react';
+
+import type { GlobalTask, TeamTaskStatus } from '@shared/types';
+import type { LucideIcon } from 'lucide-react';
+
+const statusConfig: Record = {
+ pending: { icon: Circle, color: 'text-amber-400', label: 'pending' },
+ in_progress: { icon: Loader2, color: 'text-blue-400', label: 'in progress' },
+ completed: { icon: CheckCircle2, color: 'text-emerald-400', label: 'completed' },
+ deleted: { icon: Circle, color: 'text-zinc-500', label: 'deleted' },
+};
+
+function formatTaskDate(dateStr: string | undefined): string | null {
+ if (!dateStr) return null;
+ const d = new Date(dateStr);
+ if (isNaN(d.getTime())) return null;
+ if (isToday(d)) return format(d, 'HH:mm');
+ if (isYesterday(d)) return 'Yesterday';
+ if (isThisYear(d)) return format(d, 'MMM d');
+ return format(d, 'MMM d, yyyy');
+}
+
+interface SidebarTaskItemProps {
+ task: GlobalTask;
+}
+
+export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Element => {
+ const openTeamTab = useStore((s) => s.openTeamTab);
+ const cfg = statusConfig[task.status] ?? statusConfig.pending;
+ const StatusIcon = cfg.icon;
+ const dateLabel = formatTaskDate(task.createdAt);
+
+ return (
+ openTeamTab(task.teamName, undefined, task.id)}
+ >
+
+ {task.subject}
+
+
+
+ {task.owner ?? 'unassigned'}
+ ·
+ {task.teamDisplayName}
+ {dateLabel && (
+ <>
+ ·
+ {dateLabel}
+ >
+ )}
+
+
+ );
+};
diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx
index 1bafa55b..386af8d9 100644
--- a/src/renderer/components/team/CollapsibleTeamSection.tsx
+++ b/src/renderer/components/team/CollapsibleTeamSection.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { ChevronRight } from 'lucide-react';
@@ -7,6 +7,7 @@ interface CollapsibleTeamSectionProps {
title: string;
badge?: string | number;
defaultOpen?: boolean;
+ forceOpen?: boolean;
action?: React.ReactNode;
children: React.ReactNode;
}
@@ -15,11 +16,16 @@ export const CollapsibleTeamSection = ({
title,
badge,
defaultOpen = true,
+ forceOpen,
action,
children,
}: CollapsibleTeamSectionProps): React.JSX.Element => {
const [open, setOpen] = useState(defaultOpen);
+ useEffect(() => {
+ if (forceOpen) setOpen(true);
+ }, [forceOpen]);
+
return (
@@ -34,7 +40,10 @@ export const CollapsibleTeamSection = ({
/>
{title}
{badge != null && (
-
+
{badge}
)}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index f09971e9..141f89a1 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -1,18 +1,30 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
+import { getTeamColorSet } from '@renderer/constants/teamColors';
+import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
-import { Plus, Trash2 } from 'lucide-react';
+import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ActivityTimeline } from './activity/ActivityTimeline';
-import { MessageComposer } from './activity/MessageComposer';
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 { KanbanBoard } from './kanban/KanbanBoard';
+import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
+import { MemberDetailDialog } from './members/MemberDetailDialog';
import { MemberList } from './members/MemberList';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
+import { TeamSessionsSection } from './TeamSessionsSection';
+
+import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
+import type { Session } from '@renderer/types/data';
+import type { ResolvedTeamMember, TeamTask } from '@shared/types';
interface TeamDetailViewProps {
teamName: string;
@@ -22,21 +34,57 @@ interface CreateTaskDialogState {
open: boolean;
defaultSubject: string;
defaultDescription: string;
+ defaultOwner: string;
+}
+
+interface TimeWindow {
+ start: number;
+ end: number;
+}
+
+function filterKanbanTasks(tasks: TeamTask[], query: string): TeamTask[] {
+ if (query.startsWith('#')) {
+ const id = query.slice(1);
+ return tasks.filter((t) => t.id === id);
+ }
+ const lower = query.toLowerCase();
+ return tasks.filter(
+ (t) =>
+ t.id.toLowerCase().includes(lower) ||
+ t.subject.toLowerCase().includes(lower) ||
+ (t.owner?.toLowerCase().includes(lower) ?? false)
+ );
}
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
const [requestChangesTaskId, setRequestChangesTaskId] = useState(null);
+ const [selectedMember, setSelectedMember] = useState(null);
const [createTaskDialog, setCreateTaskDialog] = useState({
open: false,
defaultSubject: '',
defaultDescription: '',
+ defaultOwner: '',
});
const [creatingTask, setCreatingTask] = useState(false);
+ const [editDialogOpen, setEditDialogOpen] = useState(false);
+ const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
+ const [sendDialogOpen, setSendDialogOpen] = useState(false);
+ const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined);
+
+ // Session loading and filtering state
+ const [sessions, setSessions] = useState([]);
+ const [sessionsLoading, setSessionsLoading] = useState(false);
+ const [sessionsError, setSessionsError] = useState(null);
+ const [kanbanFilter, setKanbanFilter] = useState({
+ sessionId: null,
+ selectedOwners: new Set(),
+ });
const {
data,
loading,
error,
+ projects,
selectTeam,
updateKanban,
updateTaskStatus,
@@ -49,11 +97,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendMessageError,
lastSendMessageResult,
reviewActionError,
+ launchTeam,
+ provisioningError,
+ kanbanFilterQuery,
+ clearKanbanFilter,
} = useStore(
useShallow((s) => ({
data: s.selectedTeamData,
loading: s.selectedTeamLoading,
error: s.selectedTeamError,
+ projects: s.projects,
selectTeam: s.selectTeam,
updateKanban: s.updateKanban,
updateTaskStatus: s.updateTaskStatus,
@@ -66,9 +119,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendMessageError: s.sendMessageError,
lastSendMessageResult: s.lastSendMessageResult,
reviewActionError: s.reviewActionError,
+ launchTeam: s.launchTeam,
+ provisioningError: s.provisioningError,
+ kanbanFilterQuery: s.kanbanFilterQuery,
+ clearKanbanFilter: s.clearKanbanFilter,
}))
);
+ const [kanbanSearch, setKanbanSearch] = useState('');
+
useEffect(() => {
if (!teamName) {
return;
@@ -76,17 +135,149 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
void selectTeam(teamName);
}, [teamName, selectTeam]);
- const openCreateTaskDialog = (subject = '', description = ''): void => {
- setCreateTaskDialog({ open: true, defaultSubject: subject, defaultDescription: description });
+ useEffect(() => {
+ if (kanbanFilterQuery) {
+ setKanbanSearch(kanbanFilterQuery);
+ clearKanbanFilter();
+ }
+ }, [kanbanFilterQuery, clearKanbanFilter]);
+
+ // Load sessions for the team's project
+ const projectId = useMemo(() => {
+ if (!data?.config.projectPath) return null;
+ return projects.find((p) => p.path === data.config.projectPath)?.id ?? null;
+ }, [projects, data?.config.projectPath]);
+
+ useEffect(() => {
+ if (!projectId) return;
+
+ let cancelled = false;
+ setSessionsLoading(true);
+ setSessionsError(null);
+
+ void (async () => {
+ try {
+ const result = await api.getSessions(projectId);
+ if (!cancelled) {
+ setSessions(result);
+ }
+ } catch (e) {
+ if (!cancelled) {
+ setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions');
+ }
+ } finally {
+ if (!cancelled) {
+ setSessionsLoading(false);
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [projectId]);
+
+ // Filter sessions to team-only using sessionHistory + leadSessionId
+ const teamSessions = useMemo(() => {
+ const sessionIds = new Set();
+ if (data?.config.leadSessionId) {
+ sessionIds.add(data.config.leadSessionId);
+ }
+ if (data?.config.sessionHistory) {
+ for (const id of data.config.sessionHistory) {
+ sessionIds.add(id);
+ }
+ }
+ // If no session IDs known (backward compat), show all sessions
+ if (sessionIds.size === 0) return sessions;
+ return sessions.filter((s) => sessionIds.has(s.id));
+ }, [sessions, data?.config.leadSessionId, data?.config.sessionHistory]);
+
+ // Auto-reset session filter if the selected session is no longer in teamSessions
+ useEffect(() => {
+ if (
+ kanbanFilter.sessionId !== null &&
+ !teamSessions.some((s) => s.id === kanbanFilter.sessionId)
+ ) {
+ setKanbanFilter((prev) => ({ ...prev, sessionId: null }));
+ }
+ }, [kanbanFilter.sessionId, teamSessions]);
+
+ // Compute time-window for session filtering
+ const timeWindow = useMemo(() => {
+ if (kanbanFilter.sessionId === null) return null;
+
+ const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt);
+ const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId);
+ if (idx === -1) return null;
+
+ const start = sorted[idx].createdAt;
+ const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity;
+ return { start, end };
+ }, [kanbanFilter.sessionId, teamSessions]);
+
+ // Filter tasks by time-window and owner
+ const filteredTasks = useMemo(() => {
+ if (!data) return [];
+ let result = data.tasks;
+
+ // Session time-window filter
+ if (timeWindow) {
+ result = result.filter((t) => {
+ if (!t.createdAt) return true; // legacy tasks always included
+ const ts = new Date(t.createdAt).getTime();
+ return ts >= timeWindow.start && ts < timeWindow.end;
+ });
+ }
+
+ // Owner filter
+ if (kanbanFilter.selectedOwners.size > 0) {
+ result = result.filter((t) =>
+ t.owner
+ ? kanbanFilter.selectedOwners.has(t.owner)
+ : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER)
+ );
+ }
+
+ return result;
+ }, [data, timeWindow, kanbanFilter.selectedOwners]);
+
+ const filteredMessages = useMemo(() => {
+ if (!data) return [];
+ if (!timeWindow) return data.messages;
+ return data.messages.filter((m) => {
+ const ts = new Date(m.timestamp).getTime();
+ return ts >= timeWindow.start && ts < timeWindow.end;
+ });
+ }, [data, timeWindow]);
+
+ const kanbanDisplayTasks = useMemo(() => {
+ const query = kanbanSearch.trim();
+ if (!query) return filteredTasks;
+ return filterKanbanTasks(filteredTasks, query);
+ }, [filteredTasks, kanbanSearch]);
+
+ const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
+ setCreateTaskDialog({
+ open: true,
+ defaultSubject: subject,
+ defaultDescription: description,
+ defaultOwner: owner,
+ });
};
const closeCreateTaskDialog = (): void => {
- setCreateTaskDialog({ open: false, defaultSubject: '', defaultDescription: '' });
+ setCreateTaskDialog({
+ open: false,
+ defaultSubject: '',
+ defaultDescription: '',
+ defaultOwner: '',
+ });
};
const handleDeleteTeam = useCallback((): void => {
const confirmed = window.confirm(
- `Удалить команду "${teamName}"? Это действие необратимо. Будут удалены все данные команды и задачи.`
+ `Delete team "${teamName}"? This action is irreversible. All team data and tasks will be deleted.`
);
if (!confirmed) {
return;
@@ -105,7 +296,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
subject: string,
description: string,
owner?: string,
- blockedBy?: string[]
+ blockedBy?: string[],
+ prompt?: string
): void => {
setCreatingTask(true);
void (async () => {
@@ -115,7 +307,18 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
description: description || undefined,
owner,
blockedBy,
+ prompt,
});
+
+ if (prompt && owner && data?.isAlive) {
+ 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
@@ -150,7 +353,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return (
-
Не удалось загрузить команду
+
Failed to load team
{error}
@@ -160,25 +363,80 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
if (!data) {
return (
- Нет данных по команде
+ No team data available
);
}
+ const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : null;
+
return (
-
-
{data.config.name}
- {data.config.description && (
-
{data.config.description}
- )}
+
+ {headerColorSet ? (
+
+ ) : null}
+
+
+
{data.config.name}
+ {data.config.description && (
+
+ {data.config.description}
+
+ )}
+
+
+ {!data.isAlive ? (
+
setLaunchDialogOpen(true)}
+ >
+
+ Launch
+
+ ) : null}
+
setEditDialogOpen(true)}
+ >
+
+
+
+
+
+
+
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
- Не удалось полностью загрузить kanban. Отображены безопасные данные.
+ Failed to fully load kanban. Displaying safe data.
) : null}
{reviewActionError ? (
@@ -187,14 +445,31 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
) : null}
-
-
+
+
+
+
+
+ setKanbanFilter((prev) => ({ ...prev, sessionId: id }))}
+ projectPath={data.config.projectPath}
+ />
0}
action={
- Задача
+ Task
}
>
+
+
+ setKanbanSearch(e.target.value)}
+ className="h-8 w-full 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 && (
+ setKanbanSearch('')}
+ >
+
+
+ )}
+
{
void requestReview(teamName, taskId);
}}
@@ -239,26 +541,33 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
/>
-
-
-
{
- void sendTeamMessage(teamName, { member, text, summary });
+ {
+ e.stopPropagation();
+ setSendDialogRecipient(undefined);
+ setSendDialogOpen(true);
}}
- />
-
-
{
- openCreateTaskDialog(subject, description);
- }}
- />
-
-
+ >
+
+ Message
+
+ }
+ >
+ {
+ openCreateTaskDialog(subject, description);
+ }}
+ />
+ setSelectedMember(null)}
+ onSendMessage={() => {
+ const name = selectedMember?.name ?? '';
+ setSelectedMember(null);
+ setSendDialogRecipient(name || undefined);
+ setSendDialogOpen(true);
+ }}
+ onAssignTask={() => {
+ const name = selectedMember?.name ?? '';
+ setSelectedMember(null);
+ openCreateTaskDialog('', '', name);
+ }}
+ />
+
-
-
-
- Удалить команду
-
-
+ setEditDialogOpen(false)}
+ onSaved={() => void selectTeam(teamName)}
+ />
+
+ setLaunchDialogOpen(false)}
+ onLaunch={async (request) => {
+ await launchTeam(request);
+ }}
+ />
+
+ {
+ void sendTeamMessage(teamName, { member, text, summary });
+ }}
+ onClose={() => setSendDialogOpen(false)}
+ />
);
};
diff --git a/src/renderer/components/team/TeamEmptyState.tsx b/src/renderer/components/team/TeamEmptyState.tsx
index f3042a9e..ed7fe81f 100644
--- a/src/renderer/components/team/TeamEmptyState.tsx
+++ b/src/renderer/components/team/TeamEmptyState.tsx
@@ -2,9 +2,9 @@ export const TeamEmptyState = (): React.JSX.Element => {
return (
-
Команды не найдены
+
No teams found
- Создайте команду в Claude Code, затем обновите список.
+ Create a team in Claude Code, then refresh the list.
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx
index 87d2b255..5c151994 100644
--- a/src/renderer/components/team/TeamListView.tsx
+++ b/src/renderer/components/team/TeamListView.tsx
@@ -1,18 +1,95 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { isElectronMode } from '@renderer/api';
+import { api, isElectronMode } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
+import { Input } from '@renderer/components/ui/input';
+import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
-import { Trash2 } from 'lucide-react';
+import { Copy, FolderOpen, Search, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
+import type { TeamCopyData } from './dialogs/CreateTeamDialog';
+import type { TeamProvisioningProgress, TeamSummary } from '@shared/types';
+
+function generateUniqueName(sourceName: string, existingNames: string[]): string {
+ const base = sourceName.replace(/-\d+$/, '');
+ const existing = new Set(existingNames);
+ for (let i = 1; ; i++) {
+ const candidate = `${base}-${i}`;
+ if (!existing.has(candidate)) {
+ return candidate;
+ }
+ }
+}
+
+type TeamStatus = 'running' | 'provisioning' | 'offline';
+
+function getRecentProjects(team: TeamSummary): string[] {
+ const history = team.projectPathHistory;
+ if (!history || history.length === 0) {
+ return team.projectPath ? [team.projectPath] : [];
+ }
+ return history.slice(-3).reverse();
+}
+
+function folderName(fullPath: string): string {
+ const parts = fullPath.replace(/\/+$/, '').split('/');
+ return parts[parts.length - 1] || fullPath;
+}
+
+function resolveTeamStatus(
+ teamName: string,
+ aliveTeams: string[],
+ provisioningRuns: Record
+): TeamStatus {
+ if (aliveTeams.includes(teamName)) {
+ return 'running';
+ }
+ const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
+ for (const run of Object.values(provisioningRuns)) {
+ if (run.teamName === teamName && activeStates.has(run.state)) {
+ return 'provisioning';
+ }
+ }
+ return 'offline';
+}
+
+const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
+ switch (status) {
+ case 'running':
+ return (
+
+
+ Running
+
+ );
+ case 'provisioning':
+ return (
+
+
+ Launching...
+
+ );
+ case 'offline':
+ return (
+
+
+ Offline
+
+ );
+ }
+};
+
export const TeamListView = (): React.JSX.Element => {
const electronMode = isElectronMode();
const [showCreateDialog, setShowCreateDialog] = useState(false);
+ const [copyData, setCopyData] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [aliveTeams, setAliveTeams] = useState([]);
const { teams, teamsLoading, teamsError, fetchTeams, openTeamTab, deleteTeam } = useStore(
useShallow((s) => ({
teams: s.teams,
@@ -23,33 +100,49 @@ export const TeamListView = (): React.JSX.Element => {
deleteTeam: s.deleteTeam,
}))
);
- const {
- connectionMode,
- createTeam,
- cancelProvisioning,
- provisioningRuns,
- activeProvisioningRunId,
- provisioningError,
- } = useStore(
+ const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore(
useShallow((s) => ({
connectionMode: s.connectionMode,
createTeam: s.createTeam,
- cancelProvisioning: s.cancelProvisioning,
- provisioningRuns: s.provisioningRuns,
- activeProvisioningRunId: s.activeProvisioningRunId,
provisioningError: s.provisioningError,
+ provisioningRuns: s.provisioningRuns,
}))
);
- const activeProgress = useMemo(
- () => (activeProvisioningRunId ? (provisioningRuns[activeProvisioningRunId] ?? null) : null),
- [activeProvisioningRunId, provisioningRuns]
- );
const canCreate = electronMode && connectionMode === 'local';
+ // Fetch alive teams on mount and when teams list changes
+ useEffect(() => {
+ if (!electronMode) return;
+ let cancelled = false;
+ const fetchAlive = async (): Promise => {
+ try {
+ const list = await api.teams.aliveList();
+ if (!cancelled) setAliveTeams(list);
+ } catch {
+ // best-effort
+ }
+ };
+ void fetchAlive();
+ return () => {
+ cancelled = true;
+ };
+ }, [electronMode, teams]);
+
+ const filteredTeams = useMemo(() => {
+ const q = searchQuery.trim().toLowerCase();
+ if (!q) return teams;
+ return teams.filter(
+ (t) =>
+ t.teamName.toLowerCase().includes(q) ||
+ t.displayName.toLowerCase().includes(q) ||
+ t.description.toLowerCase().includes(q)
+ );
+ }, [teams, searchQuery]);
+
const handleDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
- const confirmed = window.confirm(`Удалить команду "${teamName}"? Это действие необратимо.`);
+ const confirmed = window.confirm(`Delete team "${teamName}"? This action is irreversible.`);
if (!confirmed) {
return;
}
@@ -58,6 +151,33 @@ export const TeamListView = (): React.JSX.Element => {
[deleteTeam]
);
+ const handleCopyTeam = useCallback(
+ (teamName: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ void (async () => {
+ try {
+ const data = await api.teams.getData(teamName);
+ const existingNames = teams.map((t) => t.teamName);
+ const uniqueName = generateUniqueName(teamName, existingNames);
+ const members = (data.config.members ?? []).map((m) => ({
+ name: m.name,
+ role: m.role,
+ }));
+ setCopyData({
+ teamName: uniqueName,
+ description: data.config.description,
+ color: data.config.color,
+ members,
+ });
+ setShowCreateDialog(true);
+ } catch {
+ // silently ignore — team data may be unavailable
+ }
+ })();
+ },
+ [teams]
+ );
+
useEffect(() => {
if (!electronMode) {
return;
@@ -70,10 +190,10 @@ export const TeamListView = (): React.JSX.Element => {
- Teams доступен только в Electron-режиме
+ Teams is only available in Electron mode
- В browser mode доступ к локальным папкам `~/.claude/teams` недоступен.
+ In browser mode, access to local `~/.claude/teams` directories is not available.
@@ -85,15 +205,15 @@ export const TeamListView = (): React.JSX.Element => {
open={showCreateDialog}
canCreate={canCreate}
provisioningError={provisioningError}
- progress={activeProgress}
existingTeamNames={teams.map((t) => t.teamName)}
- onClose={() => setShowCreateDialog(false)}
+ initialData={copyData ?? undefined}
+ onClose={() => {
+ setShowCreateDialog(false);
+ setCopyData(null);
+ }}
onCreate={async (request) => {
await createTeam(request);
}}
- onCancelProvisioning={async (runId) => {
- await cancelProvisioning(runId);
- }}
onOpenTeam={openTeamTab}
/>
);
@@ -118,15 +238,31 @@ export const TeamListView = (): React.JSX.Element => {
void fetchTeams();
}}
>
- Обновить
+ Refresh