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:
parent
e7d9e82ce8
commit
70915a152a
26 changed files with 444 additions and 47 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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
48
src/main/ipc/teams.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -14,3 +14,4 @@ export * from './discovery';
|
|||
export * from './error';
|
||||
export * from './infrastructure';
|
||||
export * from './parsing';
|
||||
export * from './team';
|
||||
|
|
|
|||
52
src/main/services/team/TeamConfigReader.ts
Normal file
52
src/main/services/team/TeamConfigReader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
src/main/services/team/TeamDataService.ts
Normal file
11
src/main/services/team/TeamDataService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
2
src/main/services/team/index.ts
Normal file
2
src/main/services/team/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { TeamConfigReader } from './TeamConfigReader';
|
||||
export { TeamDataService } from './TeamDataService';
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
12
src/renderer/components/team/TeamEmptyState.tsx
Normal file
12
src/renderer/components/team/TeamEmptyState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
106
src/renderer/components/team/TeamListView.tsx
Normal file
106
src/renderer/components/team/TeamListView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
53
src/renderer/store/slices/teamSlice.ts
Normal file
53
src/renderer/store/slices/teamSlice.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -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 &
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
24
src/renderer/utils/unwrapIpc.ts
Normal file
24
src/renderer/utils/unwrapIpc.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
5
src/shared/types/ipc.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface IpcResult<T = void> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
17
src/shared/types/team.ts
Normal file
17
src/shared/types/team.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue