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,
|
ServiceContext,
|
||||||
ServiceContextRegistry,
|
ServiceContextRegistry,
|
||||||
SshConnectionManager,
|
SshConnectionManager,
|
||||||
|
TeamDataService,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
||||||
|
|
@ -80,6 +81,7 @@ let contextRegistry: ServiceContextRegistry;
|
||||||
let notificationManager: NotificationManager;
|
let notificationManager: NotificationManager;
|
||||||
let updaterService: UpdaterService;
|
let updaterService: UpdaterService;
|
||||||
let sshConnectionManager: SshConnectionManager;
|
let sshConnectionManager: SshConnectionManager;
|
||||||
|
let teamDataService: TeamDataService;
|
||||||
let httpServer: HttpServer;
|
let httpServer: HttpServer;
|
||||||
|
|
||||||
// File watcher event cleanup functions
|
// File watcher event cleanup functions
|
||||||
|
|
@ -264,10 +266,11 @@ function initializeServices(): void {
|
||||||
|
|
||||||
// Initialize updater service
|
// Initialize updater service
|
||||||
updaterService = new UpdaterService();
|
updaterService = new UpdaterService();
|
||||||
|
teamDataService = new TeamDataService();
|
||||||
httpServer = new HttpServer();
|
httpServer = new HttpServer();
|
||||||
|
|
||||||
// Initialize IPC handlers with registry
|
// Initialize IPC handlers with registry
|
||||||
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, {
|
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, teamDataService, {
|
||||||
rewire: rewireContextEvents,
|
rewire: rewireContextEvents,
|
||||||
full: onContextSwitched,
|
full: onContextSwitched,
|
||||||
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||||
import type {
|
import type {
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
|
IpcResult,
|
||||||
WslClaudeRootCandidate,
|
WslClaudeRootCandidate,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -54,15 +55,6 @@ const configManager = ConfigManager.getInstance();
|
||||||
let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> | void) | null =
|
let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> | void) | null =
|
||||||
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.
|
* 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.
|
* Handler for 'config:get' IPC call.
|
||||||
* Returns the full app configuration.
|
* Returns the full app configuration.
|
||||||
*/
|
*/
|
||||||
async function handleGetConfig(_event: IpcMainInvokeEvent): Promise<ConfigResult<AppConfig>> {
|
async function handleGetConfig(_event: IpcMainInvokeEvent): Promise<IpcResult<AppConfig>> {
|
||||||
try {
|
try {
|
||||||
const config = configManager.getConfig();
|
const config = configManager.getConfig();
|
||||||
return { success: true, data: config };
|
return { success: true, data: config };
|
||||||
|
|
@ -152,7 +144,7 @@ async function handleUpdateConfig(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
section: unknown,
|
section: unknown,
|
||||||
data: unknown
|
data: unknown
|
||||||
): Promise<ConfigResult<AppConfig>> {
|
): Promise<IpcResult<AppConfig>> {
|
||||||
try {
|
try {
|
||||||
const validation = validateConfigUpdatePayload(section, data);
|
const validation = validateConfigUpdatePayload(section, data);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
|
|
@ -190,7 +182,7 @@ async function handleUpdateConfig(
|
||||||
async function handleAddIgnoreRegex(
|
async function handleAddIgnoreRegex(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
pattern: string
|
pattern: string
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!pattern || typeof pattern !== 'string') {
|
if (!pattern || typeof pattern !== 'string') {
|
||||||
return { success: false, error: 'Pattern is required and must be a string' };
|
return { success: false, error: 'Pattern is required and must be a string' };
|
||||||
|
|
@ -218,7 +210,7 @@ async function handleAddIgnoreRegex(
|
||||||
async function handleRemoveIgnoreRegex(
|
async function handleRemoveIgnoreRegex(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
pattern: string
|
pattern: string
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!pattern || typeof pattern !== 'string') {
|
if (!pattern || typeof pattern !== 'string') {
|
||||||
return { success: false, error: 'Pattern is required and must be a string' };
|
return { success: false, error: 'Pattern is required and must be a string' };
|
||||||
|
|
@ -239,7 +231,7 @@ async function handleRemoveIgnoreRegex(
|
||||||
async function handleAddIgnoreRepository(
|
async function handleAddIgnoreRepository(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
repositoryId: string
|
repositoryId: string
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!repositoryId || typeof repositoryId !== 'string') {
|
if (!repositoryId || typeof repositoryId !== 'string') {
|
||||||
return { success: false, error: 'Repository ID is required and must be a string' };
|
return { success: false, error: 'Repository ID is required and must be a string' };
|
||||||
|
|
@ -260,7 +252,7 @@ async function handleAddIgnoreRepository(
|
||||||
async function handleRemoveIgnoreRepository(
|
async function handleRemoveIgnoreRepository(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
repositoryId: string
|
repositoryId: string
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!repositoryId || typeof repositoryId !== 'string') {
|
if (!repositoryId || typeof repositoryId !== 'string') {
|
||||||
return { success: false, error: 'Repository ID is required and must be a 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.
|
* Handler for 'config:snooze' IPC call.
|
||||||
* Sets the snooze timer for notifications.
|
* 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 {
|
try {
|
||||||
if (typeof minutes !== 'number' || minutes <= 0 || minutes > 24 * 60) {
|
if (typeof minutes !== 'number' || minutes <= 0 || minutes > 24 * 60) {
|
||||||
return { success: false, error: 'Minutes must be a positive number' };
|
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.
|
* Handler for 'config:clearSnooze' IPC call.
|
||||||
* Clears the snooze timer.
|
* Clears the snooze timer.
|
||||||
*/
|
*/
|
||||||
async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise<ConfigResult> {
|
async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
configManager.clearSnooze();
|
configManager.clearSnooze();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
@ -327,7 +319,7 @@ async function handleAddTrigger(
|
||||||
repositoryIds?: string[];
|
repositoryIds?: string[];
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!trigger.id || !trigger.name || !trigger.contentType) {
|
if (!trigger.id || !trigger.name || !trigger.contentType) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -385,7 +377,7 @@ async function handleUpdateTrigger(
|
||||||
repositoryIds: string[];
|
repositoryIds: string[];
|
||||||
color: string;
|
color: string;
|
||||||
}>
|
}>
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
const validatedTriggerId = validateTriggerId(triggerId);
|
const validatedTriggerId = validateTriggerId(triggerId);
|
||||||
if (!validatedTriggerId.valid) {
|
if (!validatedTriggerId.valid) {
|
||||||
|
|
@ -413,7 +405,7 @@ async function handleUpdateTrigger(
|
||||||
async function handleRemoveTrigger(
|
async function handleRemoveTrigger(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
triggerId: string
|
triggerId: string
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
const validatedTriggerId = validateTriggerId(triggerId);
|
const validatedTriggerId = validateTriggerId(triggerId);
|
||||||
if (!validatedTriggerId.valid) {
|
if (!validatedTriggerId.valid) {
|
||||||
|
|
@ -440,7 +432,7 @@ async function handleRemoveTrigger(
|
||||||
*/
|
*/
|
||||||
async function handleGetTriggers(
|
async function handleGetTriggers(
|
||||||
_event: IpcMainInvokeEvent
|
_event: IpcMainInvokeEvent
|
||||||
): Promise<ConfigResult<NotificationTrigger[]>> {
|
): Promise<IpcResult<NotificationTrigger[]>> {
|
||||||
try {
|
try {
|
||||||
const triggers = configManager.getTriggers();
|
const triggers = configManager.getTriggers();
|
||||||
|
|
||||||
|
|
@ -467,7 +459,7 @@ async function handleTestTrigger(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
trigger: NotificationTrigger
|
trigger: NotificationTrigger
|
||||||
): Promise<
|
): Promise<
|
||||||
ConfigResult<{
|
IpcResult<{
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
errors: {
|
errors: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -524,7 +516,7 @@ async function handlePinSession(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionId: string
|
sessionId: string
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!projectId || typeof projectId !== 'string') {
|
if (!projectId || typeof projectId !== 'string') {
|
||||||
return { success: false, error: 'Project ID is required and must be a string' };
|
return { success: false, error: 'Project ID is required and must be a string' };
|
||||||
|
|
@ -548,7 +540,7 @@ async function handleUnpinSession(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionId: string
|
sessionId: string
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!projectId || typeof projectId !== 'string') {
|
if (!projectId || typeof projectId !== 'string') {
|
||||||
return { success: false, error: 'Project ID is required and must be a 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.
|
* 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.
|
* 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 {
|
try {
|
||||||
const configPath = configManager.getConfigPath();
|
const configPath = configManager.getConfigPath();
|
||||||
|
|
||||||
|
|
@ -615,7 +607,7 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<ConfigRes
|
||||||
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
|
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
|
||||||
* Allows users to select one or more folders for trigger project scope.
|
* 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 {
|
try {
|
||||||
// Get the focused window for proper dialog parenting
|
// Get the focused window for proper dialog parenting
|
||||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||||
|
|
@ -650,7 +642,7 @@ async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise<ConfigRe
|
||||||
*/
|
*/
|
||||||
async function handleSelectClaudeRootFolder(
|
async function handleSelectClaudeRootFolder(
|
||||||
_event: IpcMainInvokeEvent
|
_event: IpcMainInvokeEvent
|
||||||
): Promise<ConfigResult<ClaudeRootFolderSelection | null>> {
|
): Promise<IpcResult<ClaudeRootFolderSelection | null>> {
|
||||||
try {
|
try {
|
||||||
const focusedWindow = BrowserWindow.getFocusedWindow();
|
const focusedWindow = BrowserWindow.getFocusedWindow();
|
||||||
const currentRootPath = getClaudeBasePath();
|
const currentRootPath = getClaudeBasePath();
|
||||||
|
|
@ -702,7 +694,7 @@ async function handleSelectClaudeRootFolder(
|
||||||
*/
|
*/
|
||||||
async function handleGetClaudeRootInfo(
|
async function handleGetClaudeRootInfo(
|
||||||
_event: IpcMainInvokeEvent
|
_event: IpcMainInvokeEvent
|
||||||
): Promise<ConfigResult<ClaudeRootInfo>> {
|
): Promise<IpcResult<ClaudeRootInfo>> {
|
||||||
try {
|
try {
|
||||||
const customPath = configManager.getConfig().general.claudeRootPath;
|
const customPath = configManager.getConfig().general.claudeRootPath;
|
||||||
const defaultPath = getAutoDetectedClaudeBasePath();
|
const defaultPath = getAutoDetectedClaudeBasePath();
|
||||||
|
|
@ -896,7 +888,7 @@ async function resolveWslHome(distro: string): Promise<string | null> {
|
||||||
*/
|
*/
|
||||||
async function handleFindWslClaudeRoots(
|
async function handleFindWslClaudeRoots(
|
||||||
_event: IpcMainInvokeEvent
|
_event: IpcMainInvokeEvent
|
||||||
): Promise<ConfigResult<WslClaudeRootCandidate[]>> {
|
): Promise<IpcResult<WslClaudeRootCandidate[]>> {
|
||||||
try {
|
try {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
return { success: true, data: [] };
|
return { success: true, data: [] };
|
||||||
|
|
@ -961,7 +953,7 @@ async function handleHideSession(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionId: string
|
sessionId: string
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!projectId || typeof projectId !== 'string') {
|
if (!projectId || typeof projectId !== 'string') {
|
||||||
return { success: false, error: 'Project ID is required and must be a string' };
|
return { success: false, error: 'Project ID is required and must be a string' };
|
||||||
|
|
@ -985,7 +977,7 @@ async function handleUnhideSession(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionId: string
|
sessionId: string
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!projectId || typeof projectId !== 'string') {
|
if (!projectId || typeof projectId !== 'string') {
|
||||||
return { success: false, error: 'Project ID is required and must be a string' };
|
return { success: false, error: 'Project ID is required and must be a string' };
|
||||||
|
|
@ -1009,7 +1001,7 @@ async function handleHideSessions(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!projectId || typeof projectId !== 'string') {
|
if (!projectId || typeof projectId !== 'string') {
|
||||||
return { success: false, error: 'Project ID is required and must be a string' };
|
return { success: false, error: 'Project ID is required and must be a string' };
|
||||||
|
|
@ -1033,7 +1025,7 @@ async function handleUnhideSessions(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
): Promise<ConfigResult> {
|
): Promise<IpcResult> {
|
||||||
try {
|
try {
|
||||||
if (!projectId || typeof projectId !== 'string') {
|
if (!projectId || typeof projectId !== 'string') {
|
||||||
return { success: false, error: 'Project ID is required and must be a string' };
|
return { success: false, error: 'Project ID is required and must be a string' };
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import {
|
||||||
registerSubagentHandlers,
|
registerSubagentHandlers,
|
||||||
removeSubagentHandlers,
|
removeSubagentHandlers,
|
||||||
} from './subagents';
|
} from './subagents';
|
||||||
|
import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams';
|
||||||
import {
|
import {
|
||||||
initializeUpdaterHandlers,
|
initializeUpdaterHandlers,
|
||||||
registerUpdaterHandlers,
|
registerUpdaterHandlers,
|
||||||
|
|
@ -55,6 +56,7 @@ import type {
|
||||||
ServiceContext,
|
ServiceContext,
|
||||||
ServiceContextRegistry,
|
ServiceContextRegistry,
|
||||||
SshConnectionManager,
|
SshConnectionManager,
|
||||||
|
TeamDataService,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
|
|
||||||
|
|
@ -65,6 +67,7 @@ export function initializeIpcHandlers(
|
||||||
registry: ServiceContextRegistry,
|
registry: ServiceContextRegistry,
|
||||||
updater: UpdaterService,
|
updater: UpdaterService,
|
||||||
sshManager: SshConnectionManager,
|
sshManager: SshConnectionManager,
|
||||||
|
teamDataService: TeamDataService,
|
||||||
contextCallbacks: {
|
contextCallbacks: {
|
||||||
rewire: (context: ServiceContext) => void;
|
rewire: (context: ServiceContext) => void;
|
||||||
full: (context: ServiceContext) => void;
|
full: (context: ServiceContext) => void;
|
||||||
|
|
@ -79,6 +82,7 @@ export function initializeIpcHandlers(
|
||||||
initializeUpdaterHandlers(updater);
|
initializeUpdaterHandlers(updater);
|
||||||
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
|
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
|
||||||
initializeContextHandlers(registry, contextCallbacks.rewire);
|
initializeContextHandlers(registry, contextCallbacks.rewire);
|
||||||
|
initializeTeamHandlers(teamDataService);
|
||||||
initializeConfigHandlers({
|
initializeConfigHandlers({
|
||||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||||
});
|
});
|
||||||
|
|
@ -95,6 +99,7 @@ export function initializeIpcHandlers(
|
||||||
registerUpdaterHandlers(ipcMain);
|
registerUpdaterHandlers(ipcMain);
|
||||||
registerSshHandlers(ipcMain);
|
registerSshHandlers(ipcMain);
|
||||||
registerContextHandlers(ipcMain);
|
registerContextHandlers(ipcMain);
|
||||||
|
registerTeamHandlers(ipcMain);
|
||||||
registerWindowHandlers(ipcMain);
|
registerWindowHandlers(ipcMain);
|
||||||
|
|
||||||
logger.info('All handlers registered');
|
logger.info('All handlers registered');
|
||||||
|
|
@ -116,6 +121,7 @@ export function removeIpcHandlers(): void {
|
||||||
removeUpdaterHandlers(ipcMain);
|
removeUpdaterHandlers(ipcMain);
|
||||||
removeSshHandlers(ipcMain);
|
removeSshHandlers(ipcMain);
|
||||||
removeContextHandlers(ipcMain);
|
removeContextHandlers(ipcMain);
|
||||||
|
removeTeamHandlers(ipcMain);
|
||||||
removeWindowHandlers(ipcMain);
|
removeWindowHandlers(ipcMain);
|
||||||
|
|
||||||
logger.info('All handlers removed');
|
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 './error';
|
||||||
export * from './infrastructure';
|
export * from './infrastructure';
|
||||||
export * from './parsing';
|
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 {
|
export function getTodosBasePath(): string {
|
||||||
return path.join(getClaudeBasePath(), 'todos');
|
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 */
|
/** Whether the window is currently maximized */
|
||||||
export const WINDOW_IS_MAXIMIZED = 'window:isMaximized';
|
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_SAVE_LAST_CONNECTION,
|
||||||
SSH_STATUS,
|
SSH_STATUS,
|
||||||
SSH_TEST,
|
SSH_TEST,
|
||||||
|
TEAM_LIST,
|
||||||
UPDATER_CHECK,
|
UPDATER_CHECK,
|
||||||
UPDATER_DOWNLOAD,
|
UPDATER_DOWNLOAD,
|
||||||
UPDATER_INSTALL,
|
UPDATER_INSTALL,
|
||||||
|
|
@ -68,7 +69,9 @@ import type {
|
||||||
SshConnectionConfig,
|
SshConnectionConfig,
|
||||||
SshConnectionStatus,
|
SshConnectionStatus,
|
||||||
SshLastConnection,
|
SshLastConnection,
|
||||||
|
TeamSummary,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
|
IpcResult,
|
||||||
WslClaudeRootCandidate,
|
WslClaudeRootCandidate,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -76,16 +79,6 @@ import type {
|
||||||
// IPC Result Types and Helpers
|
// 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 {
|
interface IpcFileChangePayload {
|
||||||
type: 'add' | 'change' | 'unlink';
|
type: 'add' | 'change' | 'unlink';
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -458,6 +451,12 @@ const electronAPI: ElectronAPI = {
|
||||||
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_GET_STATUS);
|
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
|
// Use contextBridge to securely expose the API to the renderer process
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import type {
|
||||||
SshConnectionStatus,
|
SshConnectionStatus,
|
||||||
SshLastConnection,
|
SshLastConnection,
|
||||||
SubagentDetail,
|
SubagentDetail,
|
||||||
|
TeamSummary,
|
||||||
|
TeamsAPI,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
UpdaterAPI,
|
UpdaterAPI,
|
||||||
WaterfallData,
|
WaterfallData,
|
||||||
|
|
@ -584,4 +586,11 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getStatus: (): Promise<HttpServerStatus> =>
|
getStatus: (): Promise<HttpServerStatus> =>
|
||||||
Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }),
|
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 { DashboardView } from '../dashboard/DashboardView';
|
||||||
import { NotificationsView } from '../notifications/NotificationsView';
|
import { NotificationsView } from '../notifications/NotificationsView';
|
||||||
import { SettingsView } from '../settings/SettingsView';
|
import { SettingsView } from '../settings/SettingsView';
|
||||||
|
import { TeamListView } from '../team/TeamListView';
|
||||||
|
|
||||||
import { SessionTabContent } from './SessionTabContent';
|
import { SessionTabContent } from './SessionTabContent';
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
|
||||||
{tab.type === 'dashboard' && <DashboardView />}
|
{tab.type === 'dashboard' && <DashboardView />}
|
||||||
{tab.type === 'notifications' && <NotificationsView />}
|
{tab.type === 'notifications' && <NotificationsView />}
|
||||||
{tab.type === 'settings' && <SettingsView />}
|
{tab.type === 'settings' && <SettingsView />}
|
||||||
|
{tab.type === 'teams' && <TeamListView />}
|
||||||
{tab.type === 'session' && (
|
{tab.type === 'session' && (
|
||||||
<TabUIProvider tabId={tab.id}>
|
<TabUIProvider tabId={tab.id}>
|
||||||
<SessionTabContent tab={tab} isActive={isActive} />
|
<SessionTabContent tab={tab} isActive={isActive} />
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useCallback, useState } from 'react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { useStore } from '@renderer/store';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { Tab } from '@renderer/types/tabs';
|
import type { Tab } from '@renderer/types/tabs';
|
||||||
|
|
@ -30,6 +30,7 @@ const TAB_ICONS = {
|
||||||
notifications: Bell,
|
notifications: Bell,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
session: FileText,
|
session: FileText,
|
||||||
|
teams: Users,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SortableTab = ({
|
export const SortableTab = ({
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortabl
|
||||||
import { isElectronMode } from '@renderer/api';
|
import { isElectronMode } from '@renderer/api';
|
||||||
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
|
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
|
||||||
import { useStore } from '@renderer/store';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { SortableTab } from './SortableTab';
|
import { SortableTab } from './SortableTab';
|
||||||
|
|
@ -42,6 +42,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
openCommandPalette,
|
openCommandPalette,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
openNotificationsTab,
|
openNotificationsTab,
|
||||||
|
openTeamsTab,
|
||||||
openSettingsTab,
|
openSettingsTab,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
|
|
@ -68,6 +69,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
openCommandPalette: s.openCommandPalette,
|
openCommandPalette: s.openCommandPalette,
|
||||||
unreadCount: s.unreadCount,
|
unreadCount: s.unreadCount,
|
||||||
openNotificationsTab: s.openNotificationsTab,
|
openNotificationsTab: s.openNotificationsTab,
|
||||||
|
openTeamsTab: s.openTeamsTab,
|
||||||
openSettingsTab: s.openSettingsTab,
|
openSettingsTab: s.openSettingsTab,
|
||||||
sidebarCollapsed: s.sidebarCollapsed,
|
sidebarCollapsed: s.sidebarCollapsed,
|
||||||
toggleSidebar: s.toggleSidebar,
|
toggleSidebar: s.toggleSidebar,
|
||||||
|
|
@ -95,6 +97,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
const [newTabHover, setNewTabHover] = useState(false);
|
const [newTabHover, setNewTabHover] = useState(false);
|
||||||
const [searchHover, setSearchHover] = useState(false);
|
const [searchHover, setSearchHover] = useState(false);
|
||||||
const [notificationsHover, setNotificationsHover] = useState(false);
|
const [notificationsHover, setNotificationsHover] = useState(false);
|
||||||
|
const [teamsHover, setTeamsHover] = useState(false);
|
||||||
const [settingsHover, setSettingsHover] = useState(false);
|
const [settingsHover, setSettingsHover] = useState(false);
|
||||||
|
|
||||||
// Context menu state
|
// Context menu state
|
||||||
|
|
@ -392,6 +395,21 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
)}
|
)}
|
||||||
</button>
|
</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 */}
|
{/* Settings gear icon */}
|
||||||
<button
|
<button
|
||||||
onClick={() => openSettingsTab()}
|
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 { createSessionDetailSlice } from './slices/sessionDetailSlice';
|
||||||
import { createSessionSlice } from './slices/sessionSlice';
|
import { createSessionSlice } from './slices/sessionSlice';
|
||||||
import { createSubagentSlice } from './slices/subagentSlice';
|
import { createSubagentSlice } from './slices/subagentSlice';
|
||||||
|
import { createTeamSlice } from './slices/teamSlice';
|
||||||
import { createTabSlice } from './slices/tabSlice';
|
import { createTabSlice } from './slices/tabSlice';
|
||||||
import { createTabUISlice } from './slices/tabUISlice';
|
import { createTabUISlice } from './slices/tabUISlice';
|
||||||
import { createUISlice } from './slices/uiSlice';
|
import { createUISlice } from './slices/uiSlice';
|
||||||
|
|
@ -35,6 +36,7 @@ export const useStore = create<AppState>()((...args) => ({
|
||||||
...createSessionSlice(...args),
|
...createSessionSlice(...args),
|
||||||
...createSessionDetailSlice(...args),
|
...createSessionDetailSlice(...args),
|
||||||
...createSubagentSlice(...args),
|
...createSubagentSlice(...args),
|
||||||
|
...createTeamSlice(...args),
|
||||||
...createConversationSlice(...args),
|
...createConversationSlice(...args),
|
||||||
...createTabSlice(...args),
|
...createTabSlice(...args),
|
||||||
...createTabUISlice(...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 { SessionDetailSlice } from './slices/sessionDetailSlice';
|
||||||
import type { SessionSlice } from './slices/sessionSlice';
|
import type { SessionSlice } from './slices/sessionSlice';
|
||||||
import type { SubagentSlice } from './slices/subagentSlice';
|
import type { SubagentSlice } from './slices/subagentSlice';
|
||||||
|
import type { TeamSlice } from './slices/teamSlice';
|
||||||
import type { TabSlice } from './slices/tabSlice';
|
import type { TabSlice } from './slices/tabSlice';
|
||||||
import type { TabUISlice } from './slices/tabUISlice';
|
import type { TabUISlice } from './slices/tabUISlice';
|
||||||
import type { UISlice } from './slices/uiSlice';
|
import type { UISlice } from './slices/uiSlice';
|
||||||
|
|
@ -81,6 +82,7 @@ export type AppState = ProjectSlice &
|
||||||
SessionSlice &
|
SessionSlice &
|
||||||
SessionDetailSlice &
|
SessionDetailSlice &
|
||||||
SubagentSlice &
|
SubagentSlice &
|
||||||
|
TeamSlice &
|
||||||
ConversationSlice &
|
ConversationSlice &
|
||||||
TabSlice &
|
TabSlice &
|
||||||
TabUISlice &
|
TabUISlice &
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
/** Type of content displayed in this tab */
|
/** Type of content displayed in this tab */
|
||||||
type: 'session' | 'dashboard' | 'notifications' | 'settings';
|
type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'teams';
|
||||||
|
|
||||||
/** Session ID (required when type === 'session') */
|
/** Session ID (required when type === 'session') */
|
||||||
sessionId?: string;
|
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,
|
NotificationTrigger,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
} from './notifications';
|
} from './notifications';
|
||||||
|
import type { TeamSummary } from './team';
|
||||||
import type { WaterfallData } from './visualization';
|
import type { WaterfallData } from './visualization';
|
||||||
import type {
|
import type {
|
||||||
ConversationGroup,
|
ConversationGroup,
|
||||||
|
|
@ -305,6 +306,14 @@ export interface HttpServerAPI {
|
||||||
getStatus: () => Promise<HttpServerStatus>;
|
getStatus: () => Promise<HttpServerStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Teams API
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface TeamsAPI {
|
||||||
|
list: () => Promise<TeamSummary[]>;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Main Electron API
|
// Main Electron API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -413,6 +422,9 @@ export interface ElectronAPI {
|
||||||
|
|
||||||
// HTTP Server API
|
// HTTP Server API
|
||||||
httpServer: HttpServerAPI;
|
httpServer: HttpServerAPI;
|
||||||
|
|
||||||
|
// Team management API
|
||||||
|
teams: TeamsAPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,9 @@ export type * from './visualization';
|
||||||
|
|
||||||
// Re-export API types (ElectronAPI, ConfigAPI, etc.)
|
// Re-export API types (ElectronAPI, ConfigAPI, etc.)
|
||||||
export type * from './api';
|
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