diff --git a/src/main/index.ts b/src/main/index.ts index 6484e149..b6c990a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) => { diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index f5d7a402..fa57d46d 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -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) | null = null; -/** - * Response type for config operations - */ -interface ConfigResult { - 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> { +async function handleGetConfig(_event: IpcMainInvokeEvent): Promise> { try { const config = configManager.getConfig(); return { success: true, data: config }; @@ -152,7 +144,7 @@ async function handleUpdateConfig( _event: IpcMainInvokeEvent, section: unknown, data: unknown -): Promise> { +): Promise> { try { const validation = validateConfigUpdatePayload(section, data); if (!validation.valid) { @@ -190,7 +182,7 @@ async function handleUpdateConfig( async function handleAddIgnoreRegex( _event: IpcMainInvokeEvent, pattern: string -): Promise { +): Promise { 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 { +): Promise { 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 { +): Promise { 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 { +): Promise { 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 { +async function handleSnooze(_event: IpcMainInvokeEvent, minutes: number): Promise { 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 { +async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise { try { configManager.clearSnooze(); return { success: true }; @@ -327,7 +319,7 @@ async function handleAddTrigger( repositoryIds?: string[]; color?: string; } -): Promise { +): Promise { try { if (!trigger.id || !trigger.name || !trigger.contentType) { return { @@ -385,7 +377,7 @@ async function handleUpdateTrigger( repositoryIds: string[]; color: string; }> -): Promise { +): Promise { try { const validatedTriggerId = validateTriggerId(triggerId); if (!validatedTriggerId.valid) { @@ -413,7 +405,7 @@ async function handleUpdateTrigger( async function handleRemoveTrigger( _event: IpcMainInvokeEvent, triggerId: string -): Promise { +): Promise { try { const validatedTriggerId = validateTriggerId(triggerId); if (!validatedTriggerId.valid) { @@ -440,7 +432,7 @@ async function handleRemoveTrigger( */ async function handleGetTriggers( _event: IpcMainInvokeEvent -): Promise> { +): Promise> { 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 { +): Promise { 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 { +): Promise { 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 { +async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise { try { const configPath = configManager.getConfigPath(); @@ -615,7 +607,7 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise> { +async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise> { try { // Get the focused window for proper dialog parenting const focusedWindow = BrowserWindow.getFocusedWindow(); @@ -650,7 +642,7 @@ async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise> { +): Promise> { try { const focusedWindow = BrowserWindow.getFocusedWindow(); const currentRootPath = getClaudeBasePath(); @@ -702,7 +694,7 @@ async function handleSelectClaudeRootFolder( */ async function handleGetClaudeRootInfo( _event: IpcMainInvokeEvent -): Promise> { +): Promise> { try { const customPath = configManager.getConfig().general.claudeRootPath; const defaultPath = getAutoDetectedClaudeBasePath(); @@ -896,7 +888,7 @@ async function resolveWslHome(distro: string): Promise { */ async function handleFindWslClaudeRoots( _event: IpcMainInvokeEvent -): Promise> { +): Promise> { try { if (process.platform !== 'win32') { return { success: true, data: [] }; @@ -961,7 +953,7 @@ async function handleHideSession( _event: IpcMainInvokeEvent, projectId: string, sessionId: string -): Promise { +): Promise { 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 { +): Promise { 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 { +): Promise { 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 { +): Promise { try { if (!projectId || typeof projectId !== 'string') { return { success: false, error: 'Project ID is required and must be a string' }; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 13c70df9..b2763336 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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'); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts new file mode 100644 index 00000000..78799b26 --- /dev/null +++ b/src/main/ipc/teams.ts @@ -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( + operation: string, + handler: () => Promise +): Promise> { + 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> { + return wrapTeamHandler('list', () => getTeamDataService().listTeams()); +} diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 417b8e82..08a5642a 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -14,3 +14,4 @@ export * from './discovery'; export * from './error'; export * from './infrastructure'; export * from './parsing'; +export * from './team'; diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts new file mode 100644 index 00000000..2110eaa1 --- /dev/null +++ b/src/main/services/team/TeamConfigReader.ts @@ -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 { + 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; + } +} diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts new file mode 100644 index 00000000..87e7833d --- /dev/null +++ b/src/main/services/team/TeamDataService.ts @@ -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 { + return this.configReader.listTeams(); + } +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts new file mode 100644 index 00000000..769f78a2 --- /dev/null +++ b/src/main/services/team/index.ts @@ -0,0 +1,2 @@ +export { TeamConfigReader } from './TeamConfigReader'; +export { TeamDataService } from './TeamDataService'; diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index e889a8b2..84f34da4 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -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'); +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index eec0bc05..5756a313 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 5f59e1c8..b461a391 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 { - success: boolean; - data?: T; - error?: string; -} - interface IpcFileChangePayload { type: 'add' | 'change' | 'unlink'; path: string; @@ -458,6 +451,12 @@ const electronAPI: ElectronAPI = { return invokeIpcWithResult(HTTP_SERVER_GET_STATUS); }, }, + + teams: { + list: async () => { + return invokeIpcWithResult(TEAM_LIST); + }, + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 26f904ac..666170fe 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }), }; + + teams: TeamsAPI = { + list: async (): Promise => { + console.warn('[HttpAPIClient] teams API is not available in browser mode'); + return []; + }, + }; } diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 8abf16d5..a77e6e9d 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -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' && } {tab.type === 'notifications' && } {tab.type === 'settings' && } + {tab.type === 'teams' && } {tab.type === 'session' && ( diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 1a9758c4..7f1de844 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -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 = ({ diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 6748a0a5..de052b3c 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -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 => { )} + {/* Teams icon */} + + {/* Settings gear icon */} + + + ); + } + + if (teams.length === 0) { + return ; + } + + return ( +
+
+

Teams

+ +
+ +
+ {teams.map((team) => ( +
+

{team.name}

+

+ {team.description || 'Без описания'} +

+
+ Участников: {team.memberCount} + Задач: {team.taskCount} +
+
+ ))} +
+
+ ); +}; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 01114285..16713578 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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()((...args) => ({ ...createSessionSlice(...args), ...createSessionDetailSlice(...args), ...createSubagentSlice(...args), + ...createTeamSlice(...args), ...createConversationSlice(...args), ...createTabSlice(...args), ...createTabUISlice(...args), diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts new file mode 100644 index 00000000..d52a8ca0 --- /dev/null +++ b/src/renderer/store/slices/teamSlice.ts @@ -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; + openTeamsTab: () => void; +} + +export const createTeamSlice: StateCreator = (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', + }); + }, +}); diff --git a/src/renderer/store/types.ts b/src/renderer/store/types.ts index 8ef28754..536a218b 100644 --- a/src/renderer/store/types.ts +++ b/src/renderer/store/types.ts @@ -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 & diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index 4e60603e..e4977636 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -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; diff --git a/src/renderer/utils/unwrapIpc.ts b/src/renderer/utils/unwrapIpc.ts new file mode 100644 index 00000000..a86e6817 --- /dev/null +++ b/src/renderer/utils/unwrapIpc.ts @@ -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(operation: string, fn: () => Promise): Promise { + 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); + } +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 4d405b02..35fda495 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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; } +// ============================================================================= +// Teams API +// ============================================================================= + +export interface TeamsAPI { + list: () => Promise; +} + // ============================================================================= // Main Electron API // ============================================================================= @@ -413,6 +422,9 @@ export interface ElectronAPI { // HTTP Server API httpServer: HttpServerAPI; + + // Team management API + teams: TeamsAPI; } // ============================================================================= diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 41e53ef1..da4c59ec 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -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'; diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts new file mode 100644 index 00000000..d2feae10 --- /dev/null +++ b/src/shared/types/ipc.ts @@ -0,0 +1,5 @@ +export interface IpcResult { + success: boolean; + data?: T; + error?: string; +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts new file mode 100644 index 00000000..51a24521 --- /dev/null +++ b/src/shared/types/team.ts @@ -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; +}