feat(team-management): iteration 1, enhance Team Management feature with IPC integration and UI components

- Introduced TeamDataService for managing team-related data.
- Updated IPC handlers to include team management functionalities.
- Added new API channels for team listing and integrated them into the UI.
- Enhanced the renderer components to support team views and interactions.
- Implemented utility functions for team directory path management.
This commit is contained in:
iliya 2026-02-17 21:30:37 +02:00
parent e7d9e82ce8
commit 70915a152a
26 changed files with 444 additions and 47 deletions

View file

@ -66,6 +66,7 @@ import {
ServiceContext,
ServiceContextRegistry,
SshConnectionManager,
TeamDataService,
UpdaterService,
} from './services';
@ -80,6 +81,7 @@ let contextRegistry: ServiceContextRegistry;
let notificationManager: NotificationManager;
let updaterService: UpdaterService;
let sshConnectionManager: SshConnectionManager;
let teamDataService: TeamDataService;
let httpServer: HttpServer;
// File watcher event cleanup functions
@ -264,10 +266,11 @@ function initializeServices(): void {
// Initialize updater service
updaterService = new UpdaterService();
teamDataService = new TeamDataService();
httpServer = new HttpServer();
// Initialize IPC handlers with registry
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, {
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, teamDataService, {
rewire: rewireContextEvents,
full: onContextSwitched,
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {

View file

@ -43,6 +43,7 @@ import type { TriggerColor } from '@shared/constants/triggerColors';
import type {
ClaudeRootFolderSelection,
ClaudeRootInfo,
IpcResult,
WslClaudeRootCandidate,
} from '@shared/types';
@ -54,15 +55,6 @@ const configManager = ConfigManager.getInstance();
let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> | void) | null =
null;
/**
* Response type for config operations
*/
interface ConfigResult<T = void> {
success: boolean;
data?: T;
error?: string;
}
/**
* Initializes config handlers with callbacks that require app-level services.
*/
@ -133,7 +125,7 @@ export function registerConfigHandlers(ipcMain: IpcMain): void {
* Handler for 'config:get' IPC call.
* Returns the full app configuration.
*/
async function handleGetConfig(_event: IpcMainInvokeEvent): Promise<ConfigResult<AppConfig>> {
async function handleGetConfig(_event: IpcMainInvokeEvent): Promise<IpcResult<AppConfig>> {
try {
const config = configManager.getConfig();
return { success: true, data: config };
@ -152,7 +144,7 @@ async function handleUpdateConfig(
_event: IpcMainInvokeEvent,
section: unknown,
data: unknown
): Promise<ConfigResult<AppConfig>> {
): Promise<IpcResult<AppConfig>> {
try {
const validation = validateConfigUpdatePayload(section, data);
if (!validation.valid) {
@ -190,7 +182,7 @@ async function handleUpdateConfig(
async function handleAddIgnoreRegex(
_event: IpcMainInvokeEvent,
pattern: string
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!pattern || typeof pattern !== 'string') {
return { success: false, error: 'Pattern is required and must be a string' };
@ -218,7 +210,7 @@ async function handleAddIgnoreRegex(
async function handleRemoveIgnoreRegex(
_event: IpcMainInvokeEvent,
pattern: string
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!pattern || typeof pattern !== 'string') {
return { success: false, error: 'Pattern is required and must be a string' };
@ -239,7 +231,7 @@ async function handleRemoveIgnoreRegex(
async function handleAddIgnoreRepository(
_event: IpcMainInvokeEvent,
repositoryId: string
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!repositoryId || typeof repositoryId !== 'string') {
return { success: false, error: 'Repository ID is required and must be a string' };
@ -260,7 +252,7 @@ async function handleAddIgnoreRepository(
async function handleRemoveIgnoreRepository(
_event: IpcMainInvokeEvent,
repositoryId: string
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!repositoryId || typeof repositoryId !== 'string') {
return { success: false, error: 'Repository ID is required and must be a string' };
@ -278,7 +270,7 @@ async function handleRemoveIgnoreRepository(
* Handler for 'config:snooze' IPC call.
* Sets the snooze timer for notifications.
*/
async function handleSnooze(_event: IpcMainInvokeEvent, minutes: number): Promise<ConfigResult> {
async function handleSnooze(_event: IpcMainInvokeEvent, minutes: number): Promise<IpcResult> {
try {
if (typeof minutes !== 'number' || minutes <= 0 || minutes > 24 * 60) {
return { success: false, error: 'Minutes must be a positive number' };
@ -296,7 +288,7 @@ async function handleSnooze(_event: IpcMainInvokeEvent, minutes: number): Promis
* Handler for 'config:clearSnooze' IPC call.
* Clears the snooze timer.
*/
async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise<ConfigResult> {
async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise<IpcResult> {
try {
configManager.clearSnooze();
return { success: true };
@ -327,7 +319,7 @@ async function handleAddTrigger(
repositoryIds?: string[];
color?: string;
}
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!trigger.id || !trigger.name || !trigger.contentType) {
return {
@ -385,7 +377,7 @@ async function handleUpdateTrigger(
repositoryIds: string[];
color: string;
}>
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
const validatedTriggerId = validateTriggerId(triggerId);
if (!validatedTriggerId.valid) {
@ -413,7 +405,7 @@ async function handleUpdateTrigger(
async function handleRemoveTrigger(
_event: IpcMainInvokeEvent,
triggerId: string
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
const validatedTriggerId = validateTriggerId(triggerId);
if (!validatedTriggerId.valid) {
@ -440,7 +432,7 @@ async function handleRemoveTrigger(
*/
async function handleGetTriggers(
_event: IpcMainInvokeEvent
): Promise<ConfigResult<NotificationTrigger[]>> {
): Promise<IpcResult<NotificationTrigger[]>> {
try {
const triggers = configManager.getTriggers();
@ -467,7 +459,7 @@ async function handleTestTrigger(
_event: IpcMainInvokeEvent,
trigger: NotificationTrigger
): Promise<
ConfigResult<{
IpcResult<{
totalCount: number;
errors: {
id: string;
@ -524,7 +516,7 @@ async function handlePinSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
@ -548,7 +540,7 @@ async function handleUnpinSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
@ -569,7 +561,7 @@ async function handleUnpinSession(
* Handler for 'config:openInEditor' - Opens the config JSON file in an external editor.
* Tries editors in order: $VISUAL, $EDITOR, cursor, code, then falls back to system open.
*/
async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<ConfigResult> {
async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult> {
try {
const configPath = configManager.getConfigPath();
@ -615,7 +607,7 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<ConfigRes
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
* Allows users to select one or more folders for trigger project scope.
*/
async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise<ConfigResult<string[]>> {
async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise<IpcResult<string[]>> {
try {
// Get the focused window for proper dialog parenting
const focusedWindow = BrowserWindow.getFocusedWindow();
@ -650,7 +642,7 @@ async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise<ConfigRe
*/
async function handleSelectClaudeRootFolder(
_event: IpcMainInvokeEvent
): Promise<ConfigResult<ClaudeRootFolderSelection | null>> {
): Promise<IpcResult<ClaudeRootFolderSelection | null>> {
try {
const focusedWindow = BrowserWindow.getFocusedWindow();
const currentRootPath = getClaudeBasePath();
@ -702,7 +694,7 @@ async function handleSelectClaudeRootFolder(
*/
async function handleGetClaudeRootInfo(
_event: IpcMainInvokeEvent
): Promise<ConfigResult<ClaudeRootInfo>> {
): Promise<IpcResult<ClaudeRootInfo>> {
try {
const customPath = configManager.getConfig().general.claudeRootPath;
const defaultPath = getAutoDetectedClaudeBasePath();
@ -896,7 +888,7 @@ async function resolveWslHome(distro: string): Promise<string | null> {
*/
async function handleFindWslClaudeRoots(
_event: IpcMainInvokeEvent
): Promise<ConfigResult<WslClaudeRootCandidate[]>> {
): Promise<IpcResult<WslClaudeRootCandidate[]>> {
try {
if (process.platform !== 'win32') {
return { success: true, data: [] };
@ -961,7 +953,7 @@ async function handleHideSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
@ -985,7 +977,7 @@ async function handleUnhideSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
@ -1009,7 +1001,7 @@ async function handleHideSessions(
_event: IpcMainInvokeEvent,
projectId: string,
sessionIds: string[]
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
@ -1033,7 +1025,7 @@ async function handleUnhideSessions(
_event: IpcMainInvokeEvent,
projectId: string,
sessionIds: string[]
): Promise<ConfigResult> {
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };

View file

@ -42,6 +42,7 @@ import {
registerSubagentHandlers,
removeSubagentHandlers,
} from './subagents';
import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams';
import {
initializeUpdaterHandlers,
registerUpdaterHandlers,
@ -55,6 +56,7 @@ import type {
ServiceContext,
ServiceContextRegistry,
SshConnectionManager,
TeamDataService,
UpdaterService,
} from '../services';
@ -65,6 +67,7 @@ export function initializeIpcHandlers(
registry: ServiceContextRegistry,
updater: UpdaterService,
sshManager: SshConnectionManager,
teamDataService: TeamDataService,
contextCallbacks: {
rewire: (context: ServiceContext) => void;
full: (context: ServiceContext) => void;
@ -79,6 +82,7 @@ export function initializeIpcHandlers(
initializeUpdaterHandlers(updater);
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
initializeContextHandlers(registry, contextCallbacks.rewire);
initializeTeamHandlers(teamDataService);
initializeConfigHandlers({
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
});
@ -95,6 +99,7 @@ export function initializeIpcHandlers(
registerUpdaterHandlers(ipcMain);
registerSshHandlers(ipcMain);
registerContextHandlers(ipcMain);
registerTeamHandlers(ipcMain);
registerWindowHandlers(ipcMain);
logger.info('All handlers registered');
@ -116,6 +121,7 @@ export function removeIpcHandlers(): void {
removeUpdaterHandlers(ipcMain);
removeSshHandlers(ipcMain);
removeContextHandlers(ipcMain);
removeTeamHandlers(ipcMain);
removeWindowHandlers(ipcMain);
logger.info('All handlers removed');

48
src/main/ipc/teams.ts Normal file
View file

@ -0,0 +1,48 @@
import { TEAM_LIST } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import type { TeamDataService } from '../services';
import type { IpcResult, TeamSummary } from '@shared/types';
const logger = createLogger('IPC:teams');
let teamDataService: TeamDataService | null = null;
export function initializeTeamHandlers(service: TeamDataService): void {
teamDataService = service;
}
export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_LIST, handleListTeams);
logger.info('Team handlers registered');
}
export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_LIST);
}
function getTeamDataService(): TeamDataService {
if (!teamDataService) {
throw new Error('Team handlers are not initialized');
}
return teamDataService;
}
async function wrapTeamHandler<T>(
operation: string,
handler: () => Promise<T>
): Promise<IpcResult<T>> {
try {
const data = await handler();
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`[teams:${operation}] ${message}`);
return { success: false, error: message };
}
}
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
return wrapTeamHandler('list', () => getTeamDataService().listTeams());
}

View file

@ -14,3 +14,4 @@ export * from './discovery';
export * from './error';
export * from './infrastructure';
export * from './parsing';
export * from './team';

View file

@ -0,0 +1,52 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import type { TeamConfig, TeamSummary } from '@shared/types';
const logger = createLogger('Service:TeamConfigReader');
export class TeamConfigReader {
async listTeams(): Promise<TeamSummary[]> {
const teamsDir = getTeamsBasePath();
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(teamsDir, { withFileTypes: true });
} catch {
return [];
}
const summaries: TeamSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const configPath = path.join(teamsDir, entry.name, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as TeamConfig;
if (typeof config.name !== 'string' || config.name.trim() === '') {
logger.debug(`Skipping team dir with invalid config name: ${entry.name}`);
continue;
}
const memberCount = Array.isArray(config.members) ? config.members.length : 0;
summaries.push({
name: config.name,
description: typeof config.description === 'string' ? config.description : '',
memberCount,
taskCount: 0,
lastActivity: null,
});
} catch {
logger.debug(`Skipping team dir without valid config: ${entry.name}`);
}
}
return summaries;
}
}

View file

@ -0,0 +1,11 @@
import { TeamConfigReader } from './TeamConfigReader';
import type { TeamSummary } from '@shared/types';
export class TeamDataService {
constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {}
async listTeams(): Promise<TeamSummary[]> {
return this.configReader.listTeams();
}
}

View file

@ -0,0 +1,2 @@
export { TeamConfigReader } from './TeamConfigReader';
export { TeamDataService } from './TeamDataService';

View file

@ -307,3 +307,10 @@ export function getProjectsBasePath(): string {
export function getTodosBasePath(): string {
return path.join(getClaudeBasePath(), 'todos');
}
/**
* Get the teams directory path (~/.claude/teams).
*/
export function getTeamsBasePath(): string {
return path.join(getClaudeBasePath(), 'teams');
}

View file

@ -171,3 +171,10 @@ export const WINDOW_CLOSE = 'window:close';
/** Whether the window is currently maximized */
export const WINDOW_IS_MAXIMIZED = 'window:isMaximized';
// =============================================================================
// Team API Channels
// =============================================================================
/** List all teams */
export const TEAM_LIST = 'team:list';

View file

@ -18,6 +18,7 @@ import {
SSH_SAVE_LAST_CONNECTION,
SSH_STATUS,
SSH_TEST,
TEAM_LIST,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
@ -68,7 +69,9 @@ import type {
SshConnectionConfig,
SshConnectionStatus,
SshLastConnection,
TeamSummary,
TriggerTestResult,
IpcResult,
WslClaudeRootCandidate,
} from '@shared/types';
@ -76,16 +79,6 @@ import type {
// IPC Result Types and Helpers
// =============================================================================
/**
* Standard IPC result structure returned by main process handlers.
* All config-related IPC calls return this shape.
*/
interface IpcResult<T> {
success: boolean;
data?: T;
error?: string;
}
interface IpcFileChangePayload {
type: 'add' | 'change' | 'unlink';
path: string;
@ -458,6 +451,12 @@ const electronAPI: ElectronAPI = {
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_GET_STATUS);
},
},
teams: {
list: async () => {
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
},
},
};
// Use contextBridge to securely expose the API to the renderer process

View file

@ -36,6 +36,8 @@ import type {
SshConnectionStatus,
SshLastConnection,
SubagentDetail,
TeamSummary,
TeamsAPI,
TriggerTestResult,
UpdaterAPI,
WaterfallData,
@ -584,4 +586,11 @@ export class HttpAPIClient implements ElectronAPI {
getStatus: (): Promise<HttpServerStatus> =>
Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }),
};
teams: TeamsAPI = {
list: async (): Promise<TeamSummary[]> => {
console.warn('[HttpAPIClient] teams API is not available in browser mode');
return [];
},
};
}

View file

@ -8,6 +8,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext';
import { DashboardView } from '../dashboard/DashboardView';
import { NotificationsView } from '../notifications/NotificationsView';
import { SettingsView } from '../settings/SettingsView';
import { TeamListView } from '../team/TeamListView';
import { SessionTabContent } from './SessionTabContent';
@ -42,6 +43,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
{tab.type === 'dashboard' && <DashboardView />}
{tab.type === 'notifications' && <NotificationsView />}
{tab.type === 'settings' && <SettingsView />}
{tab.type === 'teams' && <TeamListView />}
{tab.type === 'session' && (
<TabUIProvider tabId={tab.id}>
<SessionTabContent tab={tab} isActive={isActive} />

View file

@ -8,7 +8,7 @@ import { useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useStore } from '@renderer/store';
import { Bell, FileText, LayoutDashboard, Pin, Search, Settings, X } from 'lucide-react';
import { Bell, FileText, LayoutDashboard, Pin, Search, Settings, Users, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { Tab } from '@renderer/types/tabs';
@ -30,6 +30,7 @@ const TAB_ICONS = {
notifications: Bell,
settings: Settings,
session: FileText,
teams: Users,
} as const;
export const SortableTab = ({

View file

@ -14,7 +14,7 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortabl
import { isElectronMode } from '@renderer/api';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { useStore } from '@renderer/store';
import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react';
import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SortableTab } from './SortableTab';
@ -42,6 +42,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
openCommandPalette,
unreadCount,
openNotificationsTab,
openTeamsTab,
openSettingsTab,
sidebarCollapsed,
toggleSidebar,
@ -68,6 +69,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
openCommandPalette: s.openCommandPalette,
unreadCount: s.unreadCount,
openNotificationsTab: s.openNotificationsTab,
openTeamsTab: s.openTeamsTab,
openSettingsTab: s.openSettingsTab,
sidebarCollapsed: s.sidebarCollapsed,
toggleSidebar: s.toggleSidebar,
@ -95,6 +97,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
const [newTabHover, setNewTabHover] = useState(false);
const [searchHover, setSearchHover] = useState(false);
const [notificationsHover, setNotificationsHover] = useState(false);
const [teamsHover, setTeamsHover] = useState(false);
const [settingsHover, setSettingsHover] = useState(false);
// Context menu state
@ -392,6 +395,21 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
)}
</button>
{/* Teams icon */}
<button
onClick={openTeamsTab}
onMouseEnter={() => setTeamsHover(true)}
onMouseLeave={() => setTeamsHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: teamsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: teamsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Teams"
>
<Users className="size-4" />
</button>
{/* Settings gear icon */}
<button
onClick={() => openSettingsTab()}

View file

@ -0,0 +1,12 @@
export const TeamEmptyState = (): React.JSX.Element => {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-medium text-[var(--color-text)]">Команды не найдены</p>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
Создайте команду в Claude Code, затем обновите список.
</p>
</div>
</div>
);
};

View file

@ -0,0 +1,106 @@
import { useEffect } from 'react';
import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { TeamEmptyState } from './TeamEmptyState';
export const TeamListView = (): React.JSX.Element => {
const electronMode = isElectronMode();
const { teams, teamsLoading, teamsError, fetchTeams } = useStore(
useShallow((s) => ({
teams: s.teams,
teamsLoading: s.teamsLoading,
teamsError: s.teamsError,
fetchTeams: s.fetchTeams,
}))
);
useEffect(() => {
if (!electronMode) {
return;
}
void fetchTeams();
}, [electronMode, fetchTeams]);
if (!electronMode) {
return (
<div className="flex size-full items-center justify-center p-6">
<div className="max-w-md text-center">
<p className="text-sm font-medium text-[var(--color-text)]">
Teams доступен только в Electron-режиме
</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
В browser mode доступ к локальным папкам `~/.claude/teams` недоступен.
</p>
</div>
</div>
);
}
if (teamsLoading) {
return (
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Загружаем команды...
</div>
);
}
if (teamsError) {
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">Не удалось загрузить команды</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{teamsError}</p>
<button
className="mt-4 rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => {
void fetchTeams();
}}
>
Повторить
</button>
</div>
</div>
);
}
if (teams.length === 0) {
return <TeamEmptyState />;
}
return (
<div className="size-full overflow-auto p-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-[var(--color-text)]">Teams</h2>
<button
className="rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => {
void fetchTeams();
}}
>
Обновить
</button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team) => (
<article
key={team.name}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
>
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">{team.name}</h3>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'Без описания'}
</p>
<div className="mt-3 flex items-center gap-3 text-xs text-[var(--color-text-muted)]">
<span>Участников: {team.memberCount}</span>
<span>Задач: {team.taskCount}</span>
</div>
</article>
))}
</div>
</div>
);
};

View file

@ -16,6 +16,7 @@ import { createRepositorySlice } from './slices/repositorySlice';
import { createSessionDetailSlice } from './slices/sessionDetailSlice';
import { createSessionSlice } from './slices/sessionSlice';
import { createSubagentSlice } from './slices/subagentSlice';
import { createTeamSlice } from './slices/teamSlice';
import { createTabSlice } from './slices/tabSlice';
import { createTabUISlice } from './slices/tabUISlice';
import { createUISlice } from './slices/uiSlice';
@ -35,6 +36,7 @@ export const useStore = create<AppState>()((...args) => ({
...createSessionSlice(...args),
...createSessionDetailSlice(...args),
...createSubagentSlice(...args),
...createTeamSlice(...args),
...createConversationSlice(...args),
...createTabSlice(...args),
...createTabUISlice(...args),

View file

@ -0,0 +1,53 @@
import { api } from '@renderer/api';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import type { AppState } from '../types';
import type { TeamSummary } from '@shared/types';
import type { StateCreator } from 'zustand';
export interface TeamSlice {
teams: TeamSummary[];
teamsLoading: boolean;
teamsError: string | null;
fetchTeams: () => Promise<void>;
openTeamsTab: () => void;
}
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
teams: [],
teamsLoading: false,
teamsError: null,
fetchTeams: async () => {
set({ teamsLoading: true, teamsError: null });
try {
const teams = await unwrapIpc('team:list', () => api.teams.list());
set({ teams, teamsLoading: false, teamsError: null });
} catch (error) {
set({
teamsLoading: false,
teamsError:
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to fetch teams',
});
}
},
openTeamsTab: () => {
const state = get();
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
const teamsTab = focusedPane?.tabs.find((tab) => tab.type === 'teams');
if (teamsTab) {
state.setActiveTab(teamsTab.id);
return;
}
state.openTab({
type: 'teams',
label: 'Teams',
});
},
});

View file

@ -14,6 +14,7 @@ import type { RepositorySlice } from './slices/repositorySlice';
import type { SessionDetailSlice } from './slices/sessionDetailSlice';
import type { SessionSlice } from './slices/sessionSlice';
import type { SubagentSlice } from './slices/subagentSlice';
import type { TeamSlice } from './slices/teamSlice';
import type { TabSlice } from './slices/tabSlice';
import type { TabUISlice } from './slices/tabUISlice';
import type { UISlice } from './slices/uiSlice';
@ -81,6 +82,7 @@ export type AppState = ProjectSlice &
SessionSlice &
SessionDetailSlice &
SubagentSlice &
TeamSlice &
ConversationSlice &
TabSlice &
TabUISlice &

View file

@ -76,7 +76,7 @@ export interface Tab {
id: string;
/** Type of content displayed in this tab */
type: 'session' | 'dashboard' | 'notifications' | 'settings';
type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'teams';
/** Session ID (required when type === 'session') */
sessionId?: string;

View file

@ -0,0 +1,24 @@
import { createLogger } from '@shared/utils/logger';
const logger = createLogger('Renderer:unwrapIpc');
export class IpcError extends Error {
constructor(
public readonly operation: string,
message: string,
public readonly causeError?: unknown
) {
super(message);
this.name = 'IpcError';
}
}
export async function unwrapIpc<T>(operation: string, fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`[${operation}] ${message}`);
throw new IpcError(operation, message, error);
}
}

View file

@ -13,6 +13,7 @@ import type {
NotificationTrigger,
TriggerTestResult,
} from './notifications';
import type { TeamSummary } from './team';
import type { WaterfallData } from './visualization';
import type {
ConversationGroup,
@ -305,6 +306,14 @@ export interface HttpServerAPI {
getStatus: () => Promise<HttpServerStatus>;
}
// =============================================================================
// Teams API
// =============================================================================
export interface TeamsAPI {
list: () => Promise<TeamSummary[]>;
}
// =============================================================================
// Main Electron API
// =============================================================================
@ -413,6 +422,9 @@ export interface ElectronAPI {
// HTTP Server API
httpServer: HttpServerAPI;
// Team management API
teams: TeamsAPI;
}
// =============================================================================

View file

@ -20,3 +20,9 @@ export type * from './visualization';
// Re-export API types (ElectronAPI, ConfigAPI, etc.)
export type * from './api';
// Re-export shared IPC result shape
export type * from './ipc';
// Re-export Team Management types
export type * from './team';

5
src/shared/types/ipc.ts Normal file
View file

@ -0,0 +1,5 @@
export interface IpcResult<T = void> {
success: boolean;
data?: T;
error?: string;
}

17
src/shared/types/team.ts Normal file
View file

@ -0,0 +1,17 @@
export interface TeamMember {
name: string;
}
export interface TeamConfig {
name: string;
description?: string;
members?: TeamMember[];
}
export interface TeamSummary {
name: string;
description: string;
memberCount: number;
taskCount: number;
lastActivity: string | null;
}