From c99a9cfc480382a2b201c6a64314828769894a50 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 26 Feb 2026 17:58:51 +0200 Subject: [PATCH] feat: integrate CLI installer service and UI components - Introduced the CliInstallerService to manage CLI installation, including status checks and installation processes. - Added IPC handlers for CLI installer operations, enabling communication between main and renderer processes. - Implemented the CliStatusBanner and CliStatusSection components to display CLI installation status and controls in the dashboard and settings. - Enhanced the dashboard and settings UI to provide users with real-time feedback on CLI installation progress and errors. - Updated the TeamDataService to support task clarification features, improving task management capabilities. --- src/main/index.ts | 14 +- src/main/ipc/cliInstaller.ts | 79 ++ src/main/ipc/handlers.ts | 16 +- src/main/ipc/review.ts | 7 +- src/main/ipc/teams.ts | 40 + src/main/ipc/utility.ts | 12 + .../infrastructure/CliInstallerService.ts | 479 +++++++++ src/main/services/infrastructure/index.ts | 1 + .../services/team/ClaudeBinaryResolver.ts | 10 + src/main/services/team/FileContentResolver.ts | 9 + .../services/team/TeamAgentToolsInstaller.ts | 31 +- src/main/services/team/TeamDataService.ts | 15 + .../services/team/TeamProvisioningService.ts | 21 +- src/main/services/team/TeamTaskWriter.ts | 28 + src/preload/constants/ipcChannels.ts | 16 + src/preload/index.ts | 36 + src/renderer/api/httpClient.ts | 32 + .../components/dashboard/CliStatusBanner.tsx | 434 ++++++++ .../components/dashboard/DashboardView.tsx | 5 + .../settings/sections/AdvancedSection.tsx | 4 + .../settings/sections/CliStatusSection.tsx | 246 +++++ .../components/team/TeamDetailView.tsx | 17 + .../team/activity/ActivityTimeline.tsx | 15 +- .../team/dialogs/GlobalTaskDetailDialog.tsx | 30 +- .../team/dialogs/TaskCommentsSection.tsx | 2 +- .../team/dialogs/TaskDetailDialog.tsx | 81 +- .../components/team/kanban/KanbanTaskCard.tsx | 13 + .../team/members/MemberDetailDialog.tsx | 16 +- .../team/members/MemberDetailStats.tsx | 27 +- .../team/members/MemberStatsTab.tsx | 68 +- .../team/review/ChangeReviewDialog.tsx | 83 +- .../team/review/ContinuousScrollView.tsx | 30 +- .../components/team/review/ReviewFileTree.tsx | 27 +- .../components/team/review/ReviewToolbar.tsx | 78 +- src/renderer/constants/teamColors.ts | 1 + src/renderer/hooks/useCliInstaller.ts | 67 ++ src/renderer/hooks/useMemberStats.ts | 45 + src/renderer/store/index.ts | 85 +- .../store/slices/changeReviewSlice.ts | 130 ++- .../store/slices/cliInstallerSlice.ts | 96 ++ src/renderer/store/slices/teamSlice.ts | 87 +- src/renderer/store/types.ts | 4 +- src/renderer/utils/formatters.ts | 25 + src/shared/types/api.ts | 10 + src/shared/types/cliInstaller.ts | 82 ++ src/shared/types/index.ts | 3 + src/shared/types/team.ts | 2 + test/main/ipc/teams.test.ts | 5 + .../CliInstallerService.test.ts | 249 +++++ test/main/services/team/teamctl.test.ts | 984 ++++++++++++++++++ test/renderer/store/cliInstallerSlice.test.ts | 185 ++++ test/renderer/store/tabSlice.test.ts | 9 +- 52 files changed, 3940 insertions(+), 151 deletions(-) create mode 100644 src/main/ipc/cliInstaller.ts create mode 100644 src/main/services/infrastructure/CliInstallerService.ts create mode 100644 src/renderer/components/dashboard/CliStatusBanner.tsx create mode 100644 src/renderer/components/settings/sections/CliStatusSection.tsx create mode 100644 src/renderer/hooks/useCliInstaller.ts create mode 100644 src/renderer/hooks/useMemberStats.ts create mode 100644 src/renderer/store/slices/cliInstallerSlice.ts create mode 100644 src/shared/types/cliInstaller.ts create mode 100644 test/main/services/infrastructure/CliInstallerService.test.ts create mode 100644 test/main/services/team/teamctl.test.ts create mode 100644 test/renderer/store/cliInstallerSlice.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 99a1116e..2a61a7e2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -36,6 +36,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { HttpServer } from './services/infrastructure/HttpServer'; import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder'; import { + CliInstallerService, configManager, LocalFileSystemProvider, MemberStatsComputer, @@ -94,6 +95,7 @@ let updaterService: UpdaterService; let sshConnectionManager: SshConnectionManager; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; +let cliInstallerService: CliInstallerService; let httpServer: HttpServer; // File watcher event cleanup functions @@ -304,8 +306,9 @@ function initializeServices(): void { // Wire file watcher events for local context wireFileWatcherEvents(localContext); - // Initialize updater service + // Initialize updater and CLI installer services updaterService = new UpdaterService(); + cliInstallerService = new CliInstallerService(); teamDataService = new TeamDataService(); teamProvisioningService = new TeamProvisioningService(); const teamMemberLogsFinder = new TeamMemberLogsFinder(); @@ -357,7 +360,8 @@ function initializeServices(): void { changeExtractor, fileContentResolver, reviewApplier, - gitDiffFallback + gitDiffFallback, + cliInstallerService ); // Forward SSH state changes to renderer and HTTP SSE clients @@ -599,6 +603,9 @@ function createWindow(): void { if (updaterService) { updaterService.setMainWindow(null); } + if (cliInstallerService) { + cliInstallerService.setMainWindow(null); + } }); // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) @@ -614,6 +621,9 @@ function createWindow(): void { if (updaterService) { updaterService.setMainWindow(mainWindow); } + if (cliInstallerService) { + cliInstallerService.setMainWindow(mainWindow); + } logger.info('Main window created'); } diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts new file mode 100644 index 00000000..5db69900 --- /dev/null +++ b/src/main/ipc/cliInstaller.ts @@ -0,0 +1,79 @@ +/** + * IPC Handlers for CLI Installer Operations. + * + * Handlers: + * - cliInstaller:getStatus: Get current CLI installation status + * - cliInstaller:install: Start CLI install/update flow + * - cliInstaller:progress: Progress events (main → renderer, not a handler) + */ + +import { + CLI_INSTALLER_GET_STATUS, + CLI_INSTALLER_INSTALL, + // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload +} from '@preload/constants/ipcChannels'; +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; + +import type { CliInstallerService } from '../services'; +import type { CliInstallationStatus, IpcResult } from '@shared/types'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +const logger = createLogger('IPC:cliInstaller'); + +let service: CliInstallerService; + +/** + * Initializes CLI installer handlers with the service instance. + */ +export function initializeCliInstallerHandlers(installerService: CliInstallerService): void { + service = installerService; +} + +/** + * Registers all CLI installer IPC handlers. + */ +export function registerCliInstallerHandlers(ipcMain: IpcMain): void { + ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus); + ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall); + + logger.info('CLI installer handlers registered'); +} + +/** + * Removes all CLI installer IPC handlers. + */ +export function removeCliInstallerHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS); + ipcMain.removeHandler(CLI_INSTALLER_INSTALL); + + logger.info('CLI installer handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +async function handleGetStatus( + _event: IpcMainInvokeEvent +): Promise> { + try { + const status = await service.getStatus(); + return { success: true, data: status }; + } catch (error) { + const msg = getErrorMessage(error); + logger.error('Error in cliInstaller:getStatus:', msg); + return { success: false, error: msg }; + } +} + +async function handleInstall(_event: IpcMainInvokeEvent): Promise> { + try { + await service.install(); + return { success: true, data: undefined }; + } catch (error) { + const msg = getErrorMessage(error); + logger.error('Error in cliInstaller:install:', msg); + return { success: false, error: msg }; + } +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 6f4f4e9c..108d0855 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -17,6 +17,11 @@ import { createLogger } from '@shared/utils/logger'; import { ipcMain } from 'electron'; +import { + initializeCliInstallerHandlers, + registerCliInstallerHandlers, + removeCliInstallerHandlers, +} from './cliInstaller'; import { initializeConfigHandlers, registerConfigHandlers, removeConfigHandlers } from './config'; import { initializeContextHandlers, @@ -61,6 +66,7 @@ import { registerWindowHandlers, removeWindowHandlers } from './window'; import type { ChangeExtractorService, + CliInstallerService, FileContentResolver, GitDiffFallback, MemberStatsComputer, @@ -98,7 +104,8 @@ export function initializeIpcHandlers( changeExtractor?: ChangeExtractorService, fileContentResolver?: FileContentResolver, reviewApplier?: ReviewApplierService, - gitDiffFallback?: GitDiffFallback + gitDiffFallback?: GitDiffFallback, + cliInstaller?: CliInstallerService ): void { // Initialize domain handlers with registry initializeProjectHandlers(registry); @@ -123,6 +130,9 @@ export function initializeIpcHandlers( if (httpServerDeps) { initializeHttpServerHandlers(httpServerDeps.httpServer, httpServerDeps.startHttpServer); } + if (cliInstaller) { + initializeCliInstallerHandlers(cliInstaller); + } if (changeExtractor) { initializeReviewHandlers({ extractor: changeExtractor, @@ -147,6 +157,9 @@ export function initializeIpcHandlers( registerTeamHandlers(ipcMain); registerReviewHandlers(ipcMain); registerWindowHandlers(ipcMain); + if (cliInstaller) { + registerCliInstallerHandlers(ipcMain); + } if (httpServerDeps) { registerHttpServerHandlers(ipcMain); } @@ -173,6 +186,7 @@ export function removeIpcHandlers(): void { removeTeamHandlers(ipcMain); removeReviewHandlers(ipcMain); removeWindowHandlers(ipcMain); + removeCliInstallerHandlers(ipcMain); removeHttpServerHandlers(ipcMain); logger.info('All handlers removed'); diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 3d5369d6..20c56752 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -259,7 +259,12 @@ async function handleSaveEditedFile( if (!filePath || typeof content !== 'string') { return { success: false, error: 'Invalid parameters' }; } - return wrapReviewHandler('saveEditedFile', () => getApplier().saveEditedFile(filePath, content)); + return wrapReviewHandler('saveEditedFile', async () => { + const result = await getApplier().saveEditedFile(filePath, content); + // Invalidate cached content so next fetch reads the saved version from disk + getContentResolver().invalidateFile(filePath); + return result; + }); } // --- Phase 4 Handlers --- diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index cff7242f..49a8b352 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -32,6 +32,7 @@ import { TEAM_RESTORE, TEAM_RESTORE_TASK, TEAM_SEND_MESSAGE, + TEAM_SET_TASK_CLARIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_STOP, @@ -207,6 +208,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask); ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask); ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks); + ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification); logger.info('Team handlers registered'); } @@ -250,6 +252,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK); ipcMain.removeHandler(TEAM_RESTORE_TASK); ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); + ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION); } function getTeamDataService(): TeamDataService { @@ -1162,6 +1165,43 @@ async function handleGetDeletedTasks( ); } +const VALID_CLARIFICATION_VALUES = ['lead', 'user'] as const; + +async function handleSetTaskClarification( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + value: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + + const validatedTaskId = validateTaskId(taskId); + if (!validatedTaskId.valid) { + return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; + } + + if ( + value !== null && + (typeof value !== 'string' || !VALID_CLARIFICATION_VALUES.includes(value as 'lead' | 'user')) + ) { + return { + success: false, + error: `value must be "lead", "user", or null`, + }; + } + + return wrapTeamHandler('setTaskClarification', () => + getTeamDataService().setTaskNeedsClarification( + validatedTeamName.value!, + validatedTaskId.value!, + value as 'lead' | 'user' | null + ) + ); +} + async function handleUpdateTaskOwner( _event: IpcMainInvokeEvent, teamName: unknown, diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index 05f08390..2746fe35 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -35,6 +35,7 @@ import { countTokens } from '../utils/tokenizer'; export function registerUtilityHandlers(ipcMain: IpcMain): void { ipcMain.handle('get-app-version', handleGetAppVersion); ipcMain.handle('shell:openPath', handleShellOpenPath); + ipcMain.handle('shell:showInFolder', handleShellShowInFolder); ipcMain.handle('shell:openExternal', handleShellOpenExternal); ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles); ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd); @@ -50,6 +51,7 @@ export function registerUtilityHandlers(ipcMain: IpcMain): void { export function removeUtilityHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('get-app-version'); ipcMain.removeHandler('shell:openPath'); + ipcMain.removeHandler('shell:showInFolder'); ipcMain.removeHandler('shell:openExternal'); ipcMain.removeHandler('read-claude-md-files'); ipcMain.removeHandler('read-directory-claude-md'); @@ -71,6 +73,16 @@ function handleGetAppVersion(): string { return app.getVersion(); } +/** + * Handler for 'shell:showInFolder' IPC call. + * Reveals a file in the system file manager (Finder/Explorer). + */ +function handleShellShowInFolder(_event: IpcMainInvokeEvent, filePath: string): void { + if (typeof filePath === 'string' && filePath.length > 0 && fs.existsSync(filePath)) { + shell.showItemInFolder(filePath); + } +} + /** * Handler for 'shell:openExternal' IPC call. * Opens a URL in the system's default browser. diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts new file mode 100644 index 00000000..abfd749b --- /dev/null +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -0,0 +1,479 @@ +/** + * CliInstallerService — detects, downloads, verifies, and installs Claude Code CLI. + * + * Architecture mirrors UpdaterService: instance with setMainWindow(), progress events + * via webContents.send(). Downloads the native binary from GCS, verifies SHA256, + * then delegates `claude install` for shell integration (symlink, PATH setup). + * + * Edge cases handled: + * - HTTP redirects (GCS 302) — manual redirect following + * - Missing Content-Length — indeterminate progress + * - tmpfile cleanup on failure/abort (finally block) + * - SHA256 mismatch — clear error, file deleted + * - spawn timeouts (10s for --version, 120s for install) + * - manifest.json / latest response validation + * - Concurrent install mutex + * - `latest` version string trimming / 'v' prefix stripping + * - Human-readable error messages per phase + */ + +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; +import { execFile, spawn } from 'child_process'; +import { createHash } from 'crypto'; +import { createWriteStream, existsSync, promises as fsp } from 'fs'; +import http from 'http'; +import https from 'https'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { promisify } from 'util'; + +import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; + +import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types'; +import type { BrowserWindow } from 'electron'; +import type { IncomingMessage } from 'http'; + +const logger = createLogger('CliInstallerService'); + +// Note: execFile (not exec) is used intentionally — no shell injection risk. +// Arguments are passed as arrays, never interpolated into shell strings. +const execFileAsync = promisify(execFile); + +// ============================================================================= +// Constants +// ============================================================================= + +const GCS_BASE = + 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases'; + +const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress'; + +/** Timeout for `claude --version` (ms) */ +const VERSION_TIMEOUT_MS = 10_000; + +/** Timeout for `claude install` (ms) — can take a while on slow disks */ +const INSTALL_TIMEOUT_MS = 120_000; + +/** Max redirects to follow when fetching from GCS */ +const MAX_REDIRECTS = 5; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Follow redirects manually for https.get (Node https does NOT auto-follow). + */ +function httpsGetFollowRedirects( + url: string, + redirectsLeft = MAX_REDIRECTS +): Promise { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === 'http:' ? http : https; + + transport + .get(url, (res) => { + const status = res.statusCode ?? 0; + + if (status >= 300 && status < 400 && res.headers.location) { + if (redirectsLeft <= 0) { + res.destroy(); + reject(new Error('Too many redirects')); + return; + } + const redirectUrl = new URL(res.headers.location, url).toString(); + res.destroy(); + httpsGetFollowRedirects(redirectUrl, redirectsLeft - 1).then(resolve, reject); + return; + } + + if (status !== 200) { + res.destroy(); + reject(new Error(`HTTP ${status} fetching ${url}`)); + return; + } + + resolve(res); + }) + .on('error', reject); + }); +} + +/** + * Fetch text content from a URL with redirect support. + */ +async function fetchText(url: string): Promise { + const res = await httpsGetFollowRedirects(url); + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + res.on('error', reject); + }); +} + +/** + * Fetch JSON from a URL with redirect support and basic validation. + */ +async function fetchJson(url: string): Promise { + const text = await fetchText(url); + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`Invalid JSON response from ${url}`); + } +} + +/** + * Extract semver from a version string like "2.1.34 (Claude Code)" or "v2.1.34". + * Returns just the "X.Y.Z" portion, or the trimmed string if no match. + */ +export function normalizeVersion(raw: string): string { + const match = /\d{1,10}\.\d{1,10}\.\d{1,10}/.exec(raw); + return match ? match[0] : raw.trim(); +} + +/** + * Compare two semver strings numerically. + * Returns true if `installed` is strictly older than `latest`. + * Handles "2.10.0" > "2.9.0" correctly (numeric, not lexicographic). + */ +export function isVersionOlder(installed: string, latest: string): boolean { + const iParts = installed.split('.').map(Number); + const lParts = latest.split('.').map(Number); + + for (let i = 0; i < Math.max(iParts.length, lParts.length); i++) { + const a = iParts[i] ?? 0; + const b = lParts[i] ?? 0; + if (a < b) return true; + if (a > b) return false; + } + return false; +} + +// ============================================================================= +// Manifest types (internal) +// ============================================================================= + +interface GcsPlatformEntry { + binary?: string; + checksum?: string; + size?: number; +} + +interface GcsManifest { + version?: string; + platforms?: Record; +} + +// ============================================================================= +// Service +// ============================================================================= + +export class CliInstallerService { + private mainWindow: BrowserWindow | null = null; + private installing = false; + + setMainWindow(window: BrowserWindow | null): void { + this.mainWindow = window; + } + + // --------------------------------------------------------------------------- + // Public: getStatus + // --------------------------------------------------------------------------- + + async getStatus(): Promise { + const result: CliInstallationStatus = { + installed: false, + installedVersion: null, + binaryPath: null, + latestVersion: null, + updateAvailable: false, + }; + + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (binaryPath) { + result.installed = true; + result.binaryPath = binaryPath; + + try { + const { stdout } = await execFileAsync(binaryPath, ['--version'], { + timeout: VERSION_TIMEOUT_MS, + }); + result.installedVersion = normalizeVersion(stdout); + logger.info( + `Installed CLI version: "${stdout.trim()}" → normalized: "${result.installedVersion}"` + ); + } catch (err) { + logger.warn('Failed to get CLI version:', getErrorMessage(err)); + } + } + + try { + const latestRaw = await fetchText(`${GCS_BASE}/latest`); + result.latestVersion = normalizeVersion(latestRaw); + logger.info( + `Latest CLI version: "${latestRaw.trim()}" → normalized: "${result.latestVersion}"` + ); + + if (result.installedVersion && result.latestVersion) { + result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion); + logger.info( + `Update available: ${result.updateAvailable} (${result.installedVersion} → ${result.latestVersion})` + ); + } + } catch (err) { + logger.warn('Failed to fetch latest CLI version:', getErrorMessage(err)); + } + + return result; + } + + // --------------------------------------------------------------------------- + // Public: install + // --------------------------------------------------------------------------- + + async install(): Promise { + if (this.installing) { + this.sendProgress({ type: 'error', error: 'Installation already in progress' }); + return; + } + + this.installing = true; + let tmpFilePath: string | null = null; + + try { + // --- Phase 1: Check --- + this.sendProgress({ type: 'checking', detail: 'Detecting platform...' }); + const platform = this.detectPlatform(); + logger.info(`Detected platform: ${platform}`); + + this.sendProgress({ type: 'checking', detail: 'Fetching latest version...' }); + let version: string; + try { + const latestRaw = await fetchText(`${GCS_BASE}/latest`); + version = normalizeVersion(latestRaw); + if (!version) throw new Error('Server returned empty version'); + } catch (err) { + throw new Error(`Failed to check latest version: ${getErrorMessage(err)}`); + } + logger.info(`Latest CLI version: ${version}`); + + this.sendProgress({ type: 'checking', detail: `Fetching manifest for v${version}...` }); + let manifest: GcsManifest; + try { + manifest = await fetchJson(`${GCS_BASE}/${version}/manifest.json`); + } catch (err) { + throw new Error(`Failed to fetch release manifest: ${getErrorMessage(err)}`); + } + + const platformEntry = manifest.platforms?.[platform]; + if (!platformEntry?.checksum) { + const available = Object.keys(manifest.platforms ?? {}).join(', '); + throw new Error( + `Platform "${platform}" not found in release manifest.\nAvailable: ${available || 'none'}` + ); + } + + const expectedSha256 = platformEntry.checksum; + const expectedSize = platformEntry.size; + const binaryName = platformEntry.binary ?? 'claude'; + + // --- Phase 2: Download --- + const downloadUrl = `${GCS_BASE}/${version}/${platform}/${binaryName}`; + tmpFilePath = join(tmpdir(), `claude-cli-${version}-${Date.now()}`); + logger.info(`Downloading ${downloadUrl} → ${tmpFilePath}`); + this.sendProgress({ type: 'downloading', percent: 0, transferred: 0, total: expectedSize }); + + let actualSha256: string; + try { + actualSha256 = await this.downloadWithProgress(downloadUrl, tmpFilePath, expectedSize); + } catch (err) { + throw new Error(`Download failed: ${getErrorMessage(err)}`); + } + + // --- Phase 3: Verify --- + this.sendProgress({ type: 'verifying', detail: 'Comparing SHA256 checksums...' }); + logger.info(`Expected SHA256: ${expectedSha256}`); + logger.info(`Actual SHA256: ${actualSha256}`); + + if (actualSha256 !== expectedSha256) { + throw new Error( + `Checksum verification failed — the downloaded file is corrupted.\n` + + `Expected: ${expectedSha256}\n` + + `Got: ${actualSha256}` + ); + } + + // --- Phase 4: Make executable + install --- + if (process.platform !== 'win32') { + // eslint-disable-next-line sonarjs/file-permissions -- 0o755 is standard for executables (rwxr-xr-x) + await fsp.chmod(tmpFilePath, 0o755); + } + + this.sendProgress({ type: 'installing', detail: 'Starting shell integration...' }); + logger.info('Running claude install...'); + + try { + await this.runInstallWithStreaming(tmpFilePath); + } catch (err) { + throw new Error(`Shell integration failed: ${getErrorMessage(err)}`); + } + + // --- Phase 5: Done --- + ClaudeBinaryResolver.clearCache(); + logger.info(`CLI v${version} installed successfully`); + this.sendProgress({ type: 'completed', version }); + + await this.removeTmpFile(tmpFilePath); + tmpFilePath = null; + } catch (err) { + const error = getErrorMessage(err); + logger.error('CLI install failed:', error); + this.sendProgress({ type: 'error', error }); + } finally { + this.installing = false; + if (tmpFilePath) { + await this.removeTmpFile(tmpFilePath); + } + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private sendProgress(progress: CliInstallerProgress): void { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(CLI_INSTALLER_PROGRESS_CHANNEL, progress); + } + } + + private detectPlatform(): CliPlatform { + const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; + + if (process.platform === 'darwin') return `darwin-${arch}` as CliPlatform; + if (process.platform === 'win32') return `win32-${arch}` as CliPlatform; + + const isMusl = + existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1'); + + return (isMusl ? `linux-${arch}-musl` : `linux-${arch}`) as CliPlatform; + } + + private async downloadWithProgress( + url: string, + destPath: string, + expectedSize?: number + ): Promise { + const res = await httpsGetFollowRedirects(url); + + const contentLength = res.headers['content-length'] + ? parseInt(res.headers['content-length'], 10) + : expectedSize; + + const hash = createHash('sha256'); + const fileStream = createWriteStream(destPath); + let transferred = 0; + + return new Promise((resolve, reject) => { + res.on('data', (chunk: Buffer) => { + transferred += chunk.length; + hash.update(chunk); + fileStream.write(chunk); + + const percent = contentLength ? Math.round((transferred / contentLength) * 100) : undefined; + this.sendProgress({ type: 'downloading', percent, transferred, total: contentLength }); + }); + + res.on('end', () => { + fileStream.end(() => resolve(hash.digest('hex'))); + }); + + res.on('error', (err) => { + fileStream.destroy(); + reject(err); + }); + + fileStream.on('error', (err) => { + res.destroy(); + reject(err); + }); + }); + } + + /** + * Run `claude install` via spawn with streaming output. + * Collects all output for error context. Non-zero exit tolerated if binary resolves. + */ + private async runInstallWithStreaming(binaryPath: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(binaryPath, ['install'], { + env: { ...process.env, CLAUDE_SKIP_ANALYTICS: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const timeout = setTimeout(() => { + child.kill(); + reject( + new Error( + `Timed out after ${INSTALL_TIMEOUT_MS / 1000}s. ` + + `The install process may still be running in the background.` + ) + ); + }, INSTALL_TIMEOUT_MS); + + const outputLines: string[] = []; + + const handleOutput = (chunk: Buffer): void => { + const text = chunk.toString('utf-8').trim(); + if (!text) return; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed) { + outputLines.push(trimmed); + logger.info(`[claude install] ${trimmed}`); + this.sendProgress({ type: 'installing', detail: trimmed }); + } + } + }; + + child.stdout?.on('data', handleOutput); + child.stderr?.on('data', handleOutput); + + child.on('close', (code) => { + clearTimeout(timeout); + if (code === 0) { + resolve(); + return; + } + logger.warn(`claude install exited with code ${code ?? 'unknown'}`); + ClaudeBinaryResolver.clearCache(); + ClaudeBinaryResolver.resolve().then((check) => { + if (check) { + resolve(); + } else { + const context = + outputLines.length > 0 ? `\n\nOutput:\n${outputLines.slice(-10).join('\n')}` : ''; + reject(new Error(`Exit code ${code ?? 'unknown'}${context}`)); + } + }, reject); + }); + + child.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + } + + private async removeTmpFile(filePath: string): Promise { + try { + await fsp.unlink(filePath); + } catch { + // Ignore — file may already be cleaned up + } + } +} diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts index 52a8840b..04ae52e2 100644 --- a/src/main/services/infrastructure/index.ts +++ b/src/main/services/infrastructure/index.ts @@ -16,6 +16,7 @@ * - HttpServer: Fastify-based HTTP server for API and static file serving */ +export * from './CliInstallerService'; export * from './ConfigManager'; export * from './DataCache'; export type * from './FileSystemProvider'; diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index d8f6f580..a897975c 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -135,6 +135,14 @@ async function resolveFromExplicitPath(inputPath: string): Promise { if (cachedPath !== undefined) return cachedPath; @@ -163,6 +171,8 @@ export class ClaudeBinaryResolver { process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName]; const candidateDirs: string[] = [ + // Native binary installation path (claude install) + path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.npm', 'bin'), process.platform === 'win32' diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index 5aad565b..a525a3fb 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -36,6 +36,15 @@ export class FileContentResolver { private readonly gitFallback?: GitDiffFallback ) {} + /** Invalidate cached content for a file (e.g. after user saves edits) */ + invalidateFile(filePath: string): void { + for (const key of this.cache.keys()) { + if (key.endsWith(`:${filePath}`)) { + this.cache.delete(key); + } + } + } + /** * Resolve full file contents for a single file. * Returns original (before changes) and modified (after changes) content. diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 608f0942..210a8dfa 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; const TOOL_FILE_NAME = 'teamctl.js'; -const TOOL_VERSION = 7; +const TOOL_VERSION = 8; function buildTeamCtlScript(): string { const script = String.raw`#!/usr/bin/env node @@ -210,6 +210,11 @@ function addTaskComment(paths, taskId, flags) { var task = ref.task; var taskPath = ref.taskPath; + // Auto-clear needsClarification: "lead" when someone other than the task owner comments + if (task.needsClarification === 'lead' && from !== task.owner) { + delete task.needsClarification; + } + var existing = Array.isArray(task.comments) ? task.comments : []; var commentId = crypto.randomUUID ? crypto.randomUUID() @@ -226,6 +231,18 @@ function addTaskComment(paths, taskId, flags) { return { commentId: commentId, taskId: String(taskId), subject: task.subject, owner: task.owner }; } +function setNeedsClarification(paths, taskId, value) { + var allowed = { lead: true, user: true, clear: true }; + if (!allowed[value]) die('Invalid value: ' + value + '. Use: lead, user, clear'); + var ref = readTask(paths, taskId); + if (value === 'clear') { + delete ref.task.needsClarification; + } else { + ref.task.needsClarification = value; + } + writeTask(ref.taskPath, ref.task); +} + function listTaskIds(tasksDir) { let entries = []; try { @@ -578,6 +595,9 @@ function taskBriefing(paths, teamName, flags) { if (t.related && t.related.length > 0) { parts.push(' Related: ' + t.related.map(function(id) { return '#' + id; }).join(', ')); } + if (t.needsClarification) { + parts.push(' *** NEEDS CLARIFICATION: from ' + t.needsClarification.toUpperCase() + ' ***'); + } if (Array.isArray(t.comments) && t.comments.length > 0) { parts.push(' Comments (' + t.comments.length + '):'); for (var c = 0; c < t.comments.length; c++) { @@ -636,6 +656,7 @@ function printHelp() { ' node teamctl.js task start [--team ]', ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team ]', ' node teamctl.js task comment --text "..." [--from "member"] [--team ]', + ' node teamctl.js task set-clarification [--from "member"] [--team ]', ' node teamctl.js task briefing --for [--team ]', ' node teamctl.js kanban set-column [--team ]', ' node teamctl.js kanban clear [--team ]', @@ -762,6 +783,14 @@ async function main() { process.stdout.write('OK comment added to task #' + String(id) + '\n'); return; } + if (action === 'set-clarification') { + const id = rest[0] || args.flags.id; + const val = rest[1] || args.flags.value; + if (!id || !val) die('Usage: task set-clarification '); + setNeedsClarification(paths, String(id), String(val)); + process.stdout.write('OK task #' + String(id) + ' needsClarification=' + (val === 'clear' ? 'cleared' : String(val)) + '\n'); + return; + } if (action === 'briefing') { taskBriefing(paths, teamName, args.flags); return; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ea7cf946..8cea6a4c 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -707,6 +707,14 @@ export class TeamDataService { await this.taskWriter.updateOwner(teamName, taskId, owner); } + async setTaskNeedsClarification( + teamName: string, + taskId: string, + value: 'lead' | 'user' | null + ): Promise { + await this.taskWriter.setNeedsClarification(teamName, taskId, value); + } + async addTaskComment(teamName: string, taskId: string, text: string): Promise { const comment = await this.taskWriter.addComment(teamName, taskId, text); @@ -716,6 +724,13 @@ export class TeamDataService { this.toolsInstaller.ensureInstalled(), ]); const task = tasks.find((t) => t.id === taskId); + + // Auto-clear needsClarification: "user" on UI comment + // UI comments always have author "user" (TeamTaskWriter default) + if (task?.needsClarification === 'user') { + await this.taskWriter.setNeedsClarification(teamName, taskId, null); + } + if (task?.owner) { const parts = [ `Comment on task #${taskId} "${task.subject}":\n\n${text}`, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0cdf0c49..d7db106b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -14,6 +14,7 @@ import { AGENT_BLOCK_OPEN, stripAgentBlocks, } from '@shared/constants/agentBlocks'; +import { getMemberColor } from '@shared/constants/memberColors'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { createLogger } from '@shared/utils/logger'; import { execFile, spawn } from 'child_process'; @@ -305,6 +306,16 @@ function buildTaskStatusProtocol(teamName: string): string { - Typical flow: a) Owner finishes work on #X → task complete #X b) Reviewer accepts → review approve #X +10. CLARIFICATION PROTOCOL (IMPORTANT): + When you are blocked and need information to continue a task: + a) Do BOTH of these steps: + 1) Send a message to your team lead via SendMessage explaining what you need. + 2) Set the clarification flag on the task: + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification lead --from "" + b) The flag is auto-cleared when the lead adds a task comment on your task. + If the lead replies via SendMessage instead, clear the flag yourself once you have the answer: + node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification clear --from "" + c) Do NOT set clarification to "user" yourself — only the team lead escalates to the user. Failure to follow this protocol means the task board will show incorrect status.`); } @@ -333,6 +344,13 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string ``, `Notification policy:`, `- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`, + ``, + `Clarification handling:`, + `- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag) or SendMessage.`, + `- If you reply via SendMessage instead of task comment, also clear the flag manually:`, + ` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification clear --from "${leadName}"`, + `- If you cannot answer and the user needs to decide, escalate to "user":`, + ` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification user --from "${leadName}"`, ].join('\n') ); } @@ -2900,7 +2918,6 @@ export class TeamProvisioningService { return; } - const memberColors = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red'] as const; const joinedAt = Date.now(); try { @@ -2910,7 +2927,7 @@ export class TeamProvisioningService { name: member.name, role: member.role?.trim() || undefined, agentType: 'general-purpose', - color: memberColors[index % memberColors.length], + color: getMemberColor(index), joinedAt, })) ); diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index b1b79dfc..9f1eb4e8 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -190,6 +190,34 @@ export class TeamTaskWriter { }); } + async setNeedsClarification( + teamName: string, + taskId: string, + value: 'lead' | 'user' | null + ): Promise { + const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); + + await withTaskLock(taskPath, async () => { + let raw: string; + try { + raw = await fs.promises.readFile(taskPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Task not found: ${taskId}`); + } + throw error; + } + + const task = JSON.parse(raw) as Record; + if (value) { + task.needsClarification = value; + } else { + delete task.needsClarification; + } + await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); + }); + } + async addComment( teamName: string, taskId: string, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 0ce9b88e..322b6e3d 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -306,6 +306,22 @@ export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask'; /** Get all soft-deleted tasks for a team */ export const TEAM_GET_DELETED_TASKS = 'team:getDeletedTasks'; +/** Set needsClarification flag on a task */ +export const TEAM_SET_TASK_CLARIFICATION = 'team:setTaskClarification'; + +// ============================================================================= +// CLI Installer API Channels +// ============================================================================= + +/** Get CLI installation status */ +export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus'; + +/** Start CLI install/update */ +export const CLI_INSTALLER_INSTALL = 'cliInstaller:install'; + +/** CLI installer progress events (main -> renderer) */ +export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress'; + // ============================================================================= // Review API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 7dba7904..7c14a7bb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,6 +3,9 @@ import { contextBridge, ipcRenderer } from 'electron'; import { APP_RELAUNCH, + CLI_INSTALLER_GET_STATUS, + CLI_INSTALLER_INSTALL, + CLI_INSTALLER_PROGRESS, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, @@ -65,6 +68,7 @@ import { TEAM_RESTORE, TEAM_RESTORE_TASK, TEAM_SEND_MESSAGE, + TEAM_SET_TASK_CLARIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_STOP, @@ -122,6 +126,8 @@ import type { ChangeStats, ClaudeRootFolderSelection, ClaudeRootInfo, + CliInstallationStatus, + CliInstallerProgress, ConflictCheckResult, ContextInfo, CreateTaskRequest, @@ -432,6 +438,7 @@ const electronAPI: ElectronAPI = { // Shell operations openPath: (targetPath: string, projectRoot?: string, userSelectedFromDialog?: boolean) => ipcRenderer.invoke('shell:openPath', targetPath, projectRoot, userSelectedFromDialog), + showInFolder: (filePath: string) => ipcRenderer.invoke('shell:showInFolder', filePath), openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), // Window controls (when title bar is hidden, e.g. Windows / Linux) @@ -696,6 +703,13 @@ const electronAPI: ElectronAPI = { getDeletedTasks: async (teamName: string) => { return invokeIpcWithResult(TEAM_GET_DELETED_TASKS, teamName); }, + setTaskClarification: async ( + teamName: string, + taskId: string, + value: 'lead' | 'user' | null + ) => { + return invokeIpcWithResult(TEAM_SET_TASK_CLARIFICATION, teamName, taskId, value); + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { ipcRenderer.on( TEAM_CHANGE, @@ -839,6 +853,28 @@ const electronAPI: ElectronAPI = { ); }, }, + + // ===== CLI Installer API ===== + cliInstaller: { + getStatus: async (): Promise => { + return invokeIpcWithResult(CLI_INSTALLER_GET_STATUS); + }, + install: async (): Promise => { + return invokeIpcWithResult(CLI_INSTALLER_INSTALL); + }, + onProgress: (callback: (event: unknown, data: CliInstallerProgress) => void): (() => void) => { + ipcRenderer.on( + CLI_INSTALLER_PROGRESS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + CLI_INSTALLER_PROGRESS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + }, }; // 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 69ef71e3..a0b19594 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -12,6 +12,7 @@ import type { ClaudeMdFileInfo, ClaudeRootFolderSelection, ClaudeRootInfo, + CliInstallerAPI, ConfigAPI, ContextInfo, ConversationGroup, @@ -522,6 +523,10 @@ export class HttpAPIClient implements ElectronAPI { return { success: false, error: 'Not available in browser mode' }; }; + showInFolder = async (_filePath: string): Promise => { + console.warn('[HttpAPIClient] showInFolder is not available in browser mode'); + }; + openExternal = async (url: string): Promise<{ success: boolean; error?: string }> => { window.open(url, '_blank'); return { success: true }; @@ -784,6 +789,13 @@ export class HttpAPIClient implements ElectronAPI { getDeletedTasks: async (_teamName: string): Promise => { return []; }, + setTaskClarification: async ( + _teamName: string, + _taskId: string, + _value: 'lead' | 'user' | null + ): Promise => { + // Not available via HTTP client — no-op + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { return this.addEventListener('team-change', (data: unknown) => callback(null, data as TeamChangeEvent) @@ -850,4 +862,24 @@ export class HttpAPIClient implements ElectronAPI { throw new Error('Review is not available in browser mode'); }, }; + + // --------------------------------------------------------------------------- + // CLI Installer (not available in browser mode) + // --------------------------------------------------------------------------- + + cliInstaller: CliInstallerAPI = { + getStatus: async () => ({ + installed: false, + installedVersion: null, + binaryPath: null, + latestVersion: null, + updateAvailable: false, + }), + install: async (): Promise => { + console.warn('[HttpAPIClient] CLI installer not available in browser mode'); + }, + onProgress: (): (() => void) => { + return () => {}; + }, + }; } diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx new file mode 100644 index 00000000..606be95d --- /dev/null +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -0,0 +1,434 @@ +/** + * CliStatusBanner — CLI installation status banner for the Dashboard. + * + * Shown on the main screen before project search. + * Displays CLI version/path when installed, or a red error with install button when not. + * Shows live detail text for every phase and a mini log panel during installation. + * Only rendered in Electron mode. + */ + +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { api, isElectronMode } from '@renderer/api'; +import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; +import { formatBytes } from '@renderer/utils/formatters'; +import { AlertTriangle, CheckCircle, Download, Loader2, RefreshCw, Terminal } from 'lucide-react'; + +// ============================================================================= +// Border color by state +// ============================================================================= + +type BannerVariant = 'loading' | 'error' | 'success' | 'info'; + +const VARIANT_STYLES: Record = { + loading: { border: 'var(--color-border)', bg: 'transparent' }, + error: { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.06)' }, + success: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.04)' }, + info: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.04)' }, +}; + +// ============================================================================= +// Sub-components +// ============================================================================= + +/** Detail text shown under the main status line */ +const DetailLine = ({ text }: { text: string | null }): React.JSX.Element | null => { + if (!text) return null; + return ( +

+ {text} +

+ ); +}; + +/** Mini log panel shown during the installing phase */ +const LogPanel = ({ logs }: { logs: string[] }): React.JSX.Element | null => { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs]); + + if (logs.length === 0) return null; + + return ( +
+ {logs.map((line, i) => ( +
+ {line} +
+ ))} +
+ ); +}; + +/** Error display with multi-line support */ +const ErrorDisplay = ({ + error, + onRetry, +}: { + error: string; + onRetry: () => void; +}): React.JSX.Element => { + const lines = error.split('\n'); + const title = lines[0]; + const details = lines.slice(1).filter(Boolean); + + return ( +
+
+
+ +
+

+ {title} +

+ {details.length > 0 && ( +
+ {details.map((line, i) => ( +
+ {line} +
+ ))} +
+ )} +
+
+ +
+
+ ); +}; + +// ============================================================================= +// Main Component +// ============================================================================= + +export const CliStatusBanner = (): React.JSX.Element | null => { + const isElectron = useMemo(() => isElectronMode(), []); + const { + cliStatus, + cliStatusLoading, + cliStatusError, + installerState, + downloadProgress, + downloadTransferred, + downloadTotal, + installerError, + installerDetail, + installerLogs, + completedVersion, + fetchCliStatus, + installCli, + isBusy, + } = useCliInstaller(); + + useEffect(() => { + if (isElectron) { + void fetchCliStatus(); + } + }, [isElectron, fetchCliStatus]); + + const handleInstall = useCallback(() => { + installCli(); + }, [installCli]); + + const handleRefresh = useCallback(() => { + void fetchCliStatus(); + }, [fetchCliStatus]); + + if (!isElectron) return null; + + // Determine variant for styling + const getVariant = (): BannerVariant => { + if (installerState === 'error') return 'error'; + if (installerState === 'completed') return 'success'; + if (installerState !== 'idle') return 'info'; + if (!cliStatus) return 'loading'; + if (!cliStatus.installed) return 'error'; + if (cliStatus.updateAvailable) return 'info'; + return 'success'; + }; + + const variant = getVariant(); + const styles = VARIANT_STYLES[variant]; + + // ── Loading / fetch error state ──────────────────────────────────────── + if (!cliStatus && installerState === 'idle') { + // Fetch failed — show error with retry + if (cliStatusError && !cliStatusLoading) { + return ( +
+
+
+ + + Failed to check CLI status + +
+ +
+
+ ); + } + // Still loading or initial render + return ( +
+ + + Checking Claude CLI... + +
+ ); + } + + // ── Downloading ──────────────────────────────────────────────────────── + if (installerState === 'downloading') { + return ( +
+
+
+ + + Downloading Claude CLI... + +
+ + {downloadTotal > 0 + ? `${formatBytes(downloadTransferred)} / ${formatBytes(downloadTotal)} (${downloadProgress}%)` + : formatBytes(downloadTransferred)} + +
+
+ {downloadTotal > 0 ? ( +
+ ) : ( +
+ )} +
+
+ ); + } + + // ── Checking / Verifying ─────────────────────────────────────────────── + if (installerState === 'checking' || installerState === 'verifying') { + const label = + installerState === 'checking' ? 'Checking latest version...' : 'Verifying checksum...'; + return ( +
+
+ + + {label} + +
+ +
+ ); + } + + // ── Installing (with log panel) ──────────────────────────────────────── + if (installerState === 'installing') { + return ( +
+
+ + + Installing Claude CLI... + +
+ +
+ ); + } + + // ── Completed ────────────────────────────────────────────────────────── + if (installerState === 'completed') { + return ( +
+ + + Successfully installed Claude CLI v{completedVersion ?? 'latest'} + +
+ ); + } + + // ── Error ────────────────────────────────────────────────────────────── + if (installerState === 'error') { + return ( +
+ +
+ ); + } + + // ── Idle state with status ───────────────────────────────────────────── + if (!cliStatus) return null; + + // Not installed — red error banner + if (!cliStatus.installed) { + return ( +
+
+
+ +
+

+ Claude CLI is required +

+

+ Claude CLI is required for team provisioning and session management. Install it to + get started. +

+
+
+ +
+
+ ); + } + + // Installed — show version, path, update info + return ( +
+
+
+ +
+
+ + Claude CLI v{cliStatus.installedVersion ?? 'unknown'} + + {cliStatus.updateAvailable && cliStatus.latestVersion && ( + + → v{cliStatus.latestVersion} + + )} +
+ {cliStatus.binaryPath && ( + + )} +
+
+ + {/* Action button */} + {cliStatus.updateAvailable ? ( + + ) : ( + + )} +
+ {cliStatusError && !cliStatusLoading && ( +

+ Failed to check for updates. Check your network connection and try again. +

+ )} +
+ ); +}; diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index e7e5f1fe..93c412d0 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -27,6 +27,8 @@ const logger = createLogger('Component:DashboardView'); import { formatDistanceToNow } from 'date-fns'; import { Command, FolderGit2, FolderOpen, GitBranch, Search, Users } from 'lucide-react'; +import { CliStatusBanner } from './CliStatusBanner'; + import type { RepositoryGroup } from '@renderer/types/data'; // ============================================================================= @@ -599,6 +601,9 @@ export const DashboardView = (): React.JSX.Element => { {/* Content */}
+ {/* CLI Status Banner */} + + {/* Team select + Search */}
+ +
App Icon diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx new file mode 100644 index 00000000..2e7647ca --- /dev/null +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -0,0 +1,246 @@ +/** + * CliStatusSection — CLI installation status and install/update controls. + * + * Displayed in Settings → Advanced, only in Electron mode. + * Shows detection status, version info, download progress, and error states. + */ + +import { useCallback, useEffect, useMemo } from 'react'; + +import { isElectronMode } from '@renderer/api'; +import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; +import { formatBytes } from '@renderer/utils/formatters'; +import { AlertTriangle, CheckCircle, Download, Loader2, RefreshCw, Terminal } from 'lucide-react'; + +import { SettingsSectionHeader } from '../components'; + +export const CliStatusSection = (): React.JSX.Element | null => { + const isElectron = useMemo(() => isElectronMode(), []); + const { + cliStatus, + installerState, + downloadProgress, + downloadTransferred, + downloadTotal, + installerError, + completedVersion, + fetchCliStatus, + installCli, + isBusy, + } = useCliInstaller(); + + useEffect(() => { + if (isElectron) { + void fetchCliStatus(); + } + }, [isElectron, fetchCliStatus]); + + const handleInstall = useCallback(() => { + installCli(); + }, [installCli]); + + const handleRefresh = useCallback(() => { + void fetchCliStatus(); + }, [fetchCliStatus]); + + if (!isElectron) return null; + + return ( +
+ +
+ {/* Loading status */} + {!cliStatus && installerState === 'idle' && ( +
+ + Checking CLI... +
+ )} + + {/* Status display */} + {cliStatus && installerState === 'idle' && ( +
+ {cliStatus.installed ? ( +
+
+ + Claude CLI v{cliStatus.installedVersion ?? 'unknown'} +
+ {cliStatus.binaryPath && ( +

+ {cliStatus.binaryPath} +

+ )} + {cliStatus.updateAvailable && cliStatus.latestVersion && ( +
+ + v{cliStatus.installedVersion} → v{cliStatus.latestVersion} + +
+ )} +
+ ) : ( +
+ + Claude CLI not installed +
+ )} + + {/* Action buttons */} +
+ {!cliStatus.installed && ( + + )} + {cliStatus.installed && cliStatus.updateAvailable && ( + + )} + {cliStatus.installed && !cliStatus.updateAvailable && ( + + )} +
+
+ )} + + {/* Downloading */} + {installerState === 'downloading' && ( +
+
+ Downloading... + + {downloadTotal > 0 + ? `${formatBytes(downloadTransferred)} / ${formatBytes(downloadTotal)} (${downloadProgress}%)` + : `${formatBytes(downloadTransferred)}`} + +
+
+ {downloadTotal > 0 ? ( +
+ ) : ( +
+ )} +
+
+ )} + + {/* Checking */} + {installerState === 'checking' && ( +
+ + Checking latest version... +
+ )} + + {/* Verifying */} + {installerState === 'verifying' && ( +
+ + Verifying checksum... +
+ )} + + {/* Installing */} + {installerState === 'installing' && ( +
+ + Installing... +
+ )} + + {/* Completed */} + {installerState === 'completed' && ( +
+ + Installed v{completedVersion ?? 'latest'} +
+ )} + + {/* Error */} + {installerState === 'error' && ( +
+
+ + {installerError ?? 'Installation failed'} +
+ +
+ )} +
+
+ ); +}; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 6912eb75..734af2f6 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -519,6 +519,23 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }, [teamName, refreshTeamData]); const selectReviewFile = useStore((s) => s.selectReviewFile); + const pendingReviewRequest = useStore((s) => s.pendingReviewRequest); + const setPendingReviewRequest = useStore((s) => s.setPendingReviewRequest); + + // Pick up pending review request from GlobalTaskDetailDialog + useEffect(() => { + if (!pendingReviewRequest) return; + setReviewDialogState({ + open: true, + mode: 'task', + taskId: pendingReviewRequest.taskId, + initialFilePath: pendingReviewRequest.filePath, + }); + if (pendingReviewRequest.filePath) { + selectReviewFile(pendingReviewRequest.filePath); + } + setPendingReviewRequest(null); + }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); const handleDeleteTask = useCallback( (taskId: string) => { diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 70490675..a460024d 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -150,12 +150,15 @@ export const ActivityTimeline = ({ const hiddenCount = Math.max(0, messages.length - visibleCount); - useEffect(() => { - if (wasShowingAllRef.current && hiddenCount > 0) { - queueMicrotask(() => setVisibleCount(messages.length)); - } - wasShowingAllRef.current = hiddenCount === 0; - }, [hiddenCount, messages.length]); + // Auto-expand when user was seeing all and new messages arrive — derived state sync. + // Reading/updating ref during render is intentional (React docs: derived state sync). + /* eslint-disable react-hooks/refs -- ref stores previous frame's "showing all" for derived state sync */ + const wasShowingAll = wasShowingAllRef.current; + if (wasShowingAll && hiddenCount > 0) { + setVisibleCount(messages.length); + } + wasShowingAllRef.current = hiddenCount === 0; + /* eslint-enable react-hooks/refs -- end of intentional ref access during render */ const visibleMessages = useMemo( () => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages), diff --git a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx index 51c4f4ea..7a3f6201 100644 --- a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useStore } from '@renderer/store'; import { ExternalLink } from 'lucide-react'; @@ -20,6 +20,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { selectedTeamData, selectedTeamLoading, openTeamTab, + setPendingReviewRequest, } = useStore( useShallow((s) => ({ globalTaskDetail: s.globalTaskDetail, @@ -27,6 +28,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { selectedTeamData: s.selectedTeamData, selectedTeamLoading: s.selectedTeamLoading, openTeamTab: s.openTeamTab, + setPendingReviewRequest: s.setPendingReviewRequest, })) ); @@ -42,16 +44,27 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { [selectedTeamData] ); - if (!globalTaskDetail) return null; + const teamName = globalTaskDetail?.teamName ?? ''; + const taskId = globalTaskDetail?.taskId ?? ''; - const { teamName, taskId } = globalTaskDetail; - const task = taskMap.get(taskId) ?? null; - const kanbanTaskState = selectedTeamData?.kanbanState.tasks[taskId]; - - const handleOpenTeam = (): void => { + const handleOpenTeam = useCallback((): void => { closeGlobalTaskDetail(); openTeamTab(teamName, undefined, taskId); - }; + }, [closeGlobalTaskDetail, openTeamTab, teamName, taskId]); + + const handleViewChanges = useCallback( + (viewTaskId: string, filePath?: string) => { + setPendingReviewRequest({ taskId: viewTaskId, filePath }); + closeGlobalTaskDetail(); + openTeamTab(teamName); + }, + [closeGlobalTaskDetail, openTeamTab, setPendingReviewRequest, teamName] + ); + + if (!globalTaskDetail) return null; + + const task = taskMap.get(taskId) ?? null; + const kanbanTaskState = selectedTeamData?.kanbanState.tasks[taskId]; return ( { members={activeMembers} onClose={closeGlobalTaskDetail} onOwnerChange={undefined} + onViewChanges={handleViewChanges} headerExtra={ +
+ ) : null} + {/* Description */} } defaultOpen> {currentTask.description ? ( @@ -291,27 +326,15 @@ export const TaskDetailDialog = ({ )} - {/* Execution Logs — sessions that reference this task */} - } defaultOpen> -
- -
-
- {/* Changes */} {isTaskCompleted && onViewChanges ? ( } badge={taskChangesFiles ? taskChangesFiles.length : undefined} - defaultOpen={!!taskChangesFiles && taskChangesFiles.length > 0} + defaultOpen={taskKnownHasChanges} > - {changeSetLoading && !taskChangesFiles ? ( + {changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
Loading changes... @@ -329,7 +352,7 @@ export const TaskDetailDialog = ({ }} > - + {file.relativePath} @@ -349,6 +372,18 @@ export const TaskDetailDialog = ({ ) : null} + {/* Execution Logs — sessions that reference this task */} + } defaultOpen> +
+ +
+
+
{/* Dependencies */} {blockedByIds.length > 0 ? ( @@ -483,6 +518,13 @@ export const TaskDetailDialog = ({ } defaultOpen > + - {/* Comment input — always visible outside the collapsible section */} - - {onDeleteTask && currentTask ? (
@@ -148,6 +159,9 @@ export const MemberDetailDialog = ({ onViewMemberChanges?.(member.name, filePath)} onShowAllFiles={() => onViewMemberChanges?.(member.name)} /> diff --git a/src/renderer/components/team/members/MemberDetailStats.tsx b/src/renderer/components/team/members/MemberDetailStats.tsx index 5d2e1cf3..fb358847 100644 --- a/src/renderer/components/team/members/MemberDetailStats.tsx +++ b/src/renderer/components/team/members/MemberDetailStats.tsx @@ -1,4 +1,4 @@ -import { formatDistanceToNow } from 'date-fns'; +import { formatRelativeTime, formatTokensCompact } from '@renderer/utils/formatters'; export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs'; @@ -7,7 +7,9 @@ interface MemberDetailStatsProps { inProgressTasks: number; completedTasks: number; messageCount: number; - lastActiveAt: string | null; + totalTokens: number | null; + statsLoading?: boolean; + statsComputedAt?: string; onTabChange?: (tab: MemberDetailTab) => void; } @@ -50,12 +52,18 @@ export const MemberDetailStats = ({ inProgressTasks, completedTasks, messageCount, - lastActiveAt, + totalTokens, + statsLoading, + statsComputedAt, onTabChange, }: MemberDetailStatsProps): React.JSX.Element => { - const lastActive = lastActiveAt - ? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true }) - : '—'; + const tokensValue = statsLoading + ? '...' + : totalTokens != null + ? formatTokensCompact(totalTokens) + : '—'; + const tokensSub = + !statsLoading && statsComputedAt ? `updated ${formatRelativeTime(statsComputedAt)}` : undefined; return (
@@ -76,9 +84,10 @@ export const MemberDetailStats = ({ onClick={onTabChange ? () => onTabChange('messages') : undefined} /> onTabChange('logs') : undefined} + label="Tokens" + value={tokensValue} + sub={tokensSub} + onClick={onTabChange ? () => onTabChange('stats') : undefined} />
); diff --git a/src/renderer/components/team/members/MemberStatsTab.tsx b/src/renderer/components/team/members/MemberStatsTab.tsx index 16163d1b..14a207c4 100644 --- a/src/renderer/components/team/members/MemberStatsTab.tsx +++ b/src/renderer/components/team/members/MemberStatsTab.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { api } from '@renderer/api'; import { cn } from '@renderer/lib/utils'; +import { formatRelativeTime } from '@renderer/utils/formatters'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { AlertCircle, @@ -18,6 +19,9 @@ import type { FileLineStats, MemberFullStats } from '@shared/types'; interface MemberStatsTabProps { teamName: string; memberName: string; + prefetchedStats?: MemberFullStats | null; + prefetchedLoading?: boolean; + prefetchedError?: string | null; onFileClick?: (filePath: string) => void; onShowAllFiles?: () => void; } @@ -25,39 +29,44 @@ interface MemberStatsTabProps { export const MemberStatsTab = ({ teamName, memberName, + prefetchedStats, + prefetchedLoading, + prefetchedError, onFileClick, onShowAllFiles, }: MemberStatsTabProps): React.JSX.Element => { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const usePrefetched = prefetchedStats !== undefined; + + const [localStats, setLocalStats] = useState(null); + const [localLoading, setLocalLoading] = useState(!usePrefetched); + const [localError, setLocalError] = useState(null); useEffect(() => { + if (usePrefetched) return; + let cancelled = false; - setLoading(true); - setError(null); + setLocalLoading(true); + setLocalError(null); void (async () => { try { const result = await api.teams.getMemberStats(teamName, memberName); - if (!cancelled) { - setStats(result); - } + if (!cancelled) setLocalStats(result); } catch (e) { - if (!cancelled) { - setError(e instanceof Error ? e.message : 'Unknown error'); - } + if (!cancelled) setLocalError(e instanceof Error ? e.message : 'Unknown error'); } finally { - if (!cancelled) { - setLoading(false); - } + if (!cancelled) setLocalLoading(false); } })(); return () => { cancelled = true; }; - }, [teamName, memberName]); + }, [teamName, memberName, usePrefetched]); + + const stats = usePrefetched ? (prefetchedStats ?? null) : localStats; + const loading = usePrefetched ? (prefetchedLoading ?? false) : localLoading; + const error = usePrefetched ? (prefetchedError ?? null) : localError; if (loading) { return ( @@ -192,7 +201,17 @@ const ToolUsageBars = ({ ); }; -const INVALID_PATHS = new Set(['null', 'undefined', 'None', '']); +const TRAILING_PUNCT = ';.,'; + +function isInvalidPath(path: string): boolean { + let trimmed = path.trim(); + let end = trimmed.length; + while (end > 0 && TRAILING_PUNCT.includes(trimmed[end - 1])) { + end--; + } + trimmed = trimmed.slice(0, end); + return !trimmed || trimmed === 'null' || trimmed === 'undefined' || trimmed === 'None'; +} const FilesTouchedSection = ({ files, @@ -207,7 +226,7 @@ const FilesTouchedSection = ({ }): React.JSX.Element | null => { const [expanded, setExpanded] = useState(false); - const validFiles = files.filter((f) => !INVALID_PATHS.has(f)); + const validFiles = files.filter((f) => !isInvalidPath(f)); if (validFiles.length === 0) return null; const visibleFiles = expanded ? validFiles : validFiles.slice(0, 5); @@ -246,7 +265,7 @@ const FilesTouchedSection = ({ {basename} {fStats && (fStats.added > 0 || fStats.removed > 0) && ( - + {fStats.added > 0 && +{fStats.added}} {fStats.removed > 0 && -{fStats.removed}} @@ -277,16 +296,3 @@ const StatsFooter = ({ stats }: { stats: MemberFullStats }): React.JSX.Element =
); }; - -function formatRelativeTime(isoString: string): string { - const date = new Date(isoString); - const now = Date.now(); - const diffMs = now - date.getTime(); - const diffMin = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMin / 60); - - if (diffMin < 1) return 'just now'; - if (diffMin < 60) return `${diffMin}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - return date.toLocaleDateString(); -} diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 2c3755da..bd5dda87 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -7,7 +7,7 @@ import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNav import { useViewedFiles } from '@renderer/hooks/useViewedFiles'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice'; +import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice'; import { ChevronDown, Clock, X } from 'lucide-react'; import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils'; @@ -74,6 +74,10 @@ export const ChangeReviewDialog = ({ persistDecisions, clearDecisionsFromDisk, resetAllReviewState, + fileChunkCounts, + pushReviewUndoSnapshot, + undoBulkReview, + reviewUndoStack, } = useStore(); // Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll) @@ -147,6 +151,7 @@ export const ChangeReviewDialog = ({ // Accept/Reject all across all files const handleAcceptAll = useCallback(() => { if (!activeChangeSet) return; + pushReviewUndoSnapshot(); for (const file of activeChangeSet.files) { acceptAllFile(file.filePath); } @@ -155,10 +160,11 @@ export const ChangeReviewDialog = ({ acceptAllChunks(view); } }); - }, [activeChangeSet, acceptAllFile]); + }, [activeChangeSet, acceptAllFile, pushReviewUndoSnapshot]); const handleRejectAll = useCallback(() => { if (!activeChangeSet) return; + pushReviewUndoSnapshot(); for (const file of activeChangeSet.files) { rejectAllFile(file.filePath); } @@ -167,7 +173,7 @@ export const ChangeReviewDialog = ({ rejectAllChunks(view); } }); - }, [activeChangeSet, rejectAllFile]); + }, [activeChangeSet, rejectAllFile, pushReviewUndoSnapshot]); // Per-file callbacks for ContinuousScrollView const handleHunkAccepted = useCallback( @@ -218,6 +224,21 @@ export const ChangeReviewDialog = ({ [discardFileEdits] ); + // Undo last bulk review operation (Accept All / Reject All) + const handleUndoBulk = useCallback(() => { + const restored = undoBulkReview(); + if (restored && activeChangeSet) { + // Nuclear reset: increment discard counters for all files to force CM remount + setDiscardCounters((prev) => { + const next = { ...prev }; + for (const file of activeChangeSet.files) { + next[file.filePath] = (next[file.filePath] ?? 0) + 1; + } + return next; + }); + } + }, [undoBulkReview, activeChangeSet]); + // Save active file (for Cmd+Enter keyboard shortcut) const handleSaveActiveFile = useCallback(() => { if (activeFilePath) void saveEditedFile(activeFilePath); @@ -276,12 +297,21 @@ export const ChangeReviewDialog = ({ loadDecisionsFromDisk, ]); - // Persist decisions to disk on change (debounced via store action) + // Persist decisions to disk on change (debounced via store action). + // When decisions go from non-empty to empty (e.g. undo to clean state), + // clear the persisted file so stale decisions don't reload on reopen. const hasDecisions = Object.keys(hunkDecisions).length > 0 || Object.keys(fileDecisions).length > 0; + const hadDecisionsRef = useRef(false); useEffect(() => { - if (!open || !hasDecisions) return; - persistDecisions(teamName, decisionScopeKey); + if (!open) return; + if (hasDecisions) { + hadDecisionsRef.current = true; + persistDecisions(teamName, decisionScopeKey); + } else if (hadDecisionsRef.current) { + hadDecisionsRef.current = false; + void clearDecisionsFromDisk(teamName, decisionScopeKey); + } }, [ open, hasDecisions, @@ -290,6 +320,7 @@ export const ChangeReviewDialog = ({ teamName, decisionScopeKey, persistDecisions, + clearDecisionsFromDisk, ]); // Reset initial scroll flag when initialFilePath changes @@ -318,6 +349,25 @@ export const ChangeReviewDialog = ({ return () => document.removeEventListener('keydown', handler); }, [open, onOpenChange]); + // Cmd+Z for undo bulk review (when not inside a CM editor) + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent): void => { + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { + // Don't intercept if focus is inside a CM editor — let CM handle its own undo + if (document.activeElement?.closest('.cm-editor')) return; + + if (useStore.getState().reviewUndoStack.length > 0) { + e.preventDefault(); + e.stopPropagation(); + handleUndoBulk(); + } + } + }; + document.addEventListener('keydown', handler, true); + return () => document.removeEventListener('keydown', handler, true); + }, [open, handleUndoBulk]); + // Cmd+N IPC listener (forwarded from main process) useEffect(() => { if (!open) return; @@ -338,7 +388,7 @@ export const ChangeReviewDialog = ({ return cleanup ?? undefined; }, [open, diffNav]); - // Compute toolbar stats + // Compute toolbar stats using actual CM chunk count (not snippet count) const reviewStats = useMemo(() => { if (!activeChangeSet) return { pending: 0, accepted: 0, rejected: 0 }; @@ -347,7 +397,20 @@ export const ChangeReviewDialog = ({ let rejected = 0; for (const file of activeChangeSet.files) { - for (let i = 0; i < file.snippets.length; i++) { + // File-level decision takes priority (set by Accept All / Reject All) + const fileDec = fileDecisions[file.filePath]; + const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); + + if (fileDec === 'accepted') { + accepted += count; + continue; + } + if (fileDec === 'rejected') { + rejected += count; + continue; + } + + for (let i = 0; i < count; i++) { const key = `${file.filePath}:${i}`; const decision: HunkDecision = hunkDecisions[key] ?? 'pending'; if (decision === 'pending') pending++; @@ -357,7 +420,7 @@ export const ChangeReviewDialog = ({ } return { pending, accepted, rejected }; - }, [activeChangeSet, hunkDecisions]); + }, [activeChangeSet, hunkDecisions, fileDecisions, fileChunkCounts]); const changeStats = useMemo(() => { if (!activeChangeSet) return { linesAdded: 0, linesRemoved: 0, filesChanged: 0 }; @@ -459,6 +522,8 @@ export const ChangeReviewDialog = ({ onCollapseUnchangedChange={setCollapseUnchanged} instantApply={REVIEW_INSTANT_APPLY} editedCount={editedCount} + canUndo={reviewUndoStack.length > 0} + onUndo={handleUndoBulk} /> )} diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index 0c611ddb..770d9f19 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -2,8 +2,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent'; import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection'; +import { useStore } from '@renderer/store'; -import { acceptAllChunks, rejectAllChunks, replayHunkDecisions } from './CodeMirrorDiffUtils'; +import { + acceptAllChunks, + getChunks, + rejectAllChunks, + replayHunkDecisions, +} from './CodeMirrorDiffUtils'; import { FileSectionDiff } from './FileSectionDiff'; import { FileSectionHeader } from './FileSectionHeader'; import { FileSectionPlaceholder } from './FileSectionPlaceholder'; @@ -69,6 +75,7 @@ export const ContinuousScrollView = ({ memberName, fetchFileContent, }: ContinuousScrollViewProps): React.ReactElement => { + const setFileChunkCount = useStore((s) => s.setFileChunkCount); const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); const handleToggleCollapse = useCallback((filePath: string) => { @@ -124,10 +131,27 @@ export const ContinuousScrollView = ({ hunkDecisionsRef.current = hunkDecisions; }); + // Track which views have already had decisions replayed to prevent + // cascading re-replays on every render (useEffect in FileSectionDiff has no deps). + // When a view is destroyed/recreated (discard, lazy remount), the identity changes + // and replay runs once for the new instance. + const replayedViewsRef = useRef(new Set()); + const handleEditorViewReady = useCallback( (filePath: string, view: EditorView | null) => { if (view) { + // Skip if this exact view instance was already processed + if (editorViewMapRef.current.get(filePath) === view && replayedViewsRef.current.has(view)) { + return; + } editorViewMapRef.current.set(filePath, view); + replayedViewsRef.current.add(view); + + // Store the actual CM chunk count (may differ from snippet count) + const chunks = getChunks(view.state); + if (chunks) { + setFileChunkCount(filePath, chunks.chunks.length); + } const fileDecision = fileDecisionsRef.current[filePath]; if (fileDecision === 'accepted' || fileDecision === 'rejected') { @@ -147,9 +171,11 @@ export const ContinuousScrollView = ({ } } else { editorViewMapRef.current.delete(filePath); + // Don't clean replayedViewsRef — stale entries are harmless (WeakSet-like behavior + // is not needed since view instances are unique and old ones get GC'd) } }, - [editorViewMapRef] + [editorViewMapRef, setFileChunkCount] ); if (files.length === 0) { diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/renderer/components/team/review/ReviewFileTree.tsx index dfb7a831..6076dae2 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/renderer/components/team/review/ReviewFileTree.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; +import { getFileHunkCount } from '@renderer/store/slices/changeReviewSlice'; import { Check, ChevronRight, @@ -83,12 +84,20 @@ function buildTree(files: FileChangeSummary[]): TreeNode[] { function getFileStatus( file: FileChangeSummary, - hunkDecisions: Record + hunkDecisions: Record, + fileDecisions: Record, + fileChunkCounts: Record ): FileStatus { - if (file.snippets.length === 0) return 'pending'; + // File-level decision takes priority (set by Accept All / Reject All) + const fileDec = fileDecisions[file.filePath]; + if (fileDec === 'accepted') return 'accepted'; + if (fileDec === 'rejected') return 'rejected'; + + const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); + if (count === 0) return 'pending'; const decisions: HunkDecision[] = []; - for (let i = 0; i < file.snippets.length; i++) { + for (let i = 0; i < count; i++) { const key = `${file.filePath}:${i}`; decisions.push(hunkDecisions[key] ?? 'pending'); } @@ -142,6 +151,8 @@ const TreeItem = ({ onSelectFile, depth, hunkDecisions, + fileDecisions, + fileChunkCounts, viewedSet, collapsedFolders, onToggleFolder, @@ -152,6 +163,8 @@ const TreeItem = ({ onSelectFile: (filePath: string) => void; depth: number; hunkDecisions: Record; + fileDecisions: Record; + fileChunkCounts: Record; viewedSet?: Set; collapsedFolders: Set; onToggleFolder: (fullPath: string) => void; @@ -159,7 +172,7 @@ const TreeItem = ({ if (node.isFile && node.file) { const isSelected = node.file.filePath === selectedFilePath; const isActive = node.file.filePath === activeFilePath && !isSelected; - const status = getFileStatus(node.file, hunkDecisions); + const status = getFileStatus(node.file, hunkDecisions, fileDecisions, fileChunkCounts); return ( - - Accept all changes across all files - + {canUndo && onUndo && ( + + + + + + Undo last bulk operation ( + {/Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘Z' : 'Ctrl+Z'}) + + + )} - - - - - Reject all changes across all files - + {/* Actions — hidden when all hunks are already decided */} + {stats.pending > 0 && ( + <> + + + + + Accept all changes across all files + + + + + + + Reject all changes across all files + + + )} {!instantApply && ( diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts index 0773652a..3a4e8e6b 100644 --- a/src/renderer/constants/teamColors.ts +++ b/src/renderer/constants/teamColors.ts @@ -23,6 +23,7 @@ const TEAMMATE_COLORS: Record = { cyan: { border: '#06b6d4', badge: 'rgba(6, 182, 212, 0.15)', text: '#22d3ee' }, orange: { border: '#f97316', badge: 'rgba(249, 115, 22, 0.15)', text: '#fb923c' }, pink: { border: '#ec4899', badge: 'rgba(236, 72, 153, 0.15)', text: '#f472b6' }, + magenta: { border: '#d946ef', badge: 'rgba(217, 70, 239, 0.15)', text: '#e879f9' }, /** Reserved for the human user — never assigned to team members. */ user: { border: '#f5f5f4', badge: 'rgba(245, 245, 244, 0.12)', text: '#d6d3d1' }, }; diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts new file mode 100644 index 00000000..c3a0dd5a --- /dev/null +++ b/src/renderer/hooks/useCliInstaller.ts @@ -0,0 +1,67 @@ +/** + * useCliInstaller — shared hook for CLI installer state. + * + * Centralizes all store selectors and computed state for CLI installation. + * Used by both CliStatusBanner (Dashboard) and CliStatusSection (Settings). + */ + +import { useStore } from '@renderer/store'; + +import type { CliInstallationStatus } from '@shared/types'; + +export function useCliInstaller(): { + cliStatus: CliInstallationStatus | null; + cliStatusLoading: boolean; + cliStatusError: string | null; + installerState: + | 'idle' + | 'checking' + | 'downloading' + | 'verifying' + | 'installing' + | 'completed' + | 'error'; + downloadProgress: number; + downloadTransferred: number; + downloadTotal: number; + installerError: string | null; + installerDetail: string | null; + installerLogs: string[]; + completedVersion: string | null; + fetchCliStatus: () => Promise; + installCli: () => void; + isBusy: boolean; +} { + const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); + const cliStatusError = useStore((s) => s.cliStatusError); + const installerState = useStore((s) => s.cliInstallerState); + const downloadProgress = useStore((s) => s.cliDownloadProgress); + const downloadTransferred = useStore((s) => s.cliDownloadTransferred); + const downloadTotal = useStore((s) => s.cliDownloadTotal); + const installerError = useStore((s) => s.cliInstallerError); + const installerDetail = useStore((s) => s.cliInstallerDetail); + const installerLogs = useStore((s) => s.cliInstallerLogs); + const completedVersion = useStore((s) => s.cliCompletedVersion); + const fetchCliStatus = useStore((s) => s.fetchCliStatus); + const installCli = useStore((s) => s.installCli); + + const isBusy = installerState !== 'idle' && installerState !== 'error'; + + return { + cliStatus, + cliStatusLoading, + cliStatusError, + installerState, + downloadProgress, + downloadTransferred, + downloadTotal, + installerError, + installerDetail, + installerLogs, + completedVersion, + fetchCliStatus, + installCli, + isBusy, + }; +} diff --git a/src/renderer/hooks/useMemberStats.ts b/src/renderer/hooks/useMemberStats.ts new file mode 100644 index 00000000..f4359e61 --- /dev/null +++ b/src/renderer/hooks/useMemberStats.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; + +import { api } from '@renderer/api'; + +import type { MemberFullStats } from '@shared/types'; + +export function useMemberStats( + teamName: string, + memberName: string | null +): { stats: MemberFullStats | null; loading: boolean; error: string | null } { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(memberName !== null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!memberName) { + setStats(null); + setLoading(false); + setError(null); + return; + } + + let cancelled = false; + setStats(null); + setLoading(true); + setError(null); + + void (async () => { + try { + const result = await api.teams.getMemberStats(teamName, memberName); + if (!cancelled) setStats(result); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [teamName, memberName]); + + return { stats, loading, error }; +} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 9dfc5214..a97e396d 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -7,6 +7,7 @@ import { cleanupStale as cleanupCommentReadState } from '@renderer/services/comm import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; +import { createCliInstallerSlice } from './slices/cliInstallerSlice'; import { createConfigSlice } from './slices/configSlice'; import { createConnectionSlice } from './slices/connectionSlice'; import { createContextSlice } from './slices/contextSlice'; @@ -26,7 +27,7 @@ import { createUpdateSlice } from './slices/updateSlice'; import type { DetectedError } from '../types/data'; import type { AppState } from './types'; -import type { TeamChangeEvent, UpdaterStatus } from '@shared/types'; +import type { CliInstallerProgress, TeamChangeEvent, UpdaterStatus } from '@shared/types'; // ============================================================================= // Store Creation @@ -50,6 +51,7 @@ export const useStore = create()((...args) => ({ ...createContextSlice(...args), ...createUpdateSlice(...args), ...createChangeReviewSlice(...args), + ...createCliInstallerSlice(...args), })); // ============================================================================= @@ -361,6 +363,87 @@ export function initializeNotificationListeners(): () => void { } } + // Auto-check CLI status on startup + if (api.cliInstaller) { + void useStore.getState().fetchCliStatus(); + } + + // Listen for CLI installer progress events from main process + let cliCompletedRevertTimer: ReturnType | null = null; + if (api.cliInstaller?.onProgress) { + const cleanup = api.cliInstaller.onProgress((_event: unknown, data: unknown) => { + const progress = data as CliInstallerProgress; + + // Clear any pending auto-revert timer on new events + if (progress.type !== 'completed' && cliCompletedRevertTimer) { + clearTimeout(cliCompletedRevertTimer); + cliCompletedRevertTimer = null; + } + + const detail = progress.detail ?? null; + + switch (progress.type) { + case 'checking': + useStore.setState({ cliInstallerState: 'checking', cliInstallerDetail: detail }); + break; + case 'downloading': + useStore.setState({ + cliInstallerState: 'downloading', + cliDownloadProgress: progress.percent ?? 0, + cliDownloadTransferred: progress.transferred ?? 0, + cliDownloadTotal: progress.total ?? 0, + cliInstallerDetail: detail, + }); + break; + case 'verifying': + useStore.setState({ cliInstallerState: 'verifying', cliInstallerDetail: detail }); + break; + case 'installing': { + // Accumulate log lines for the mini-terminal + const prevLogs = useStore.getState().cliInstallerLogs; + const newLogs = detail ? [...prevLogs, detail].slice(-50) : prevLogs; + useStore.setState({ + cliInstallerState: 'installing', + cliInstallerDetail: detail, + cliInstallerLogs: newLogs, + }); + break; + } + case 'completed': + useStore.setState({ + cliInstallerState: 'completed', + cliCompletedVersion: progress.version ?? null, + cliInstallerDetail: null, + }); + // Re-fetch status after install and auto-revert to idle after 3s + void useStore.getState().fetchCliStatus(); + cliCompletedRevertTimer = setTimeout(() => { + cliCompletedRevertTimer = null; + // Only revert if still in 'completed' state (not overwritten by a new install) + if (useStore.getState().cliInstallerState === 'completed') { + useStore.setState({ cliInstallerState: 'idle' }); + } + }, 3000); + break; + case 'error': + useStore.setState({ + cliInstallerState: 'error', + cliInstallerError: progress.error ?? 'Unknown error', + }); + break; + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(() => { + cleanup(); + if (cliCompletedRevertTimer) { + clearTimeout(cliCompletedRevertTimer); + cliCompletedRevertTimer = null; + } + }); + } + } + // Listen for updater status events from main process if (api.updater?.onStatus) { const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => { diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 958955e9..ede5a2b8 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -26,6 +26,14 @@ import type { StateCreator } from 'zustand'; const logger = createLogger('changeReviewSlice'); +/** Snapshot of review decisions for undo support */ +interface DecisionSnapshot { + hunkDecisions: Record; + fileDecisions: Record; +} + +const MAX_REVIEW_UNDO_DEPTH = 10; + /** * When true, rejected hunks are immediately applied to disk (no need for "Apply All Changes"). * When false, decisions are batched and applied manually via "Apply All Changes" button. @@ -51,6 +59,10 @@ export interface ChangeReviewSlice { // Phase 2 state hunkDecisions: Record; fileDecisions: Record; + /** Actual CodeMirror chunk count per file (may differ from snippets.length) */ + fileChunkCounts: Record; + /** Undo stack for bulk review operations (Accept All / Reject All) */ + reviewUndoStack: DecisionSnapshot[]; fileContents: Record; fileContentsLoading: Record; collapseUnchanged: boolean; @@ -80,6 +92,9 @@ export interface ChangeReviewSlice { // Phase 2 actions setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => void; setFileDecision: (filePath: string, decision: HunkDecision) => void; + setFileChunkCount: (filePath: string, count: number) => void; + pushReviewUndoSnapshot: () => void; + undoBulkReview: () => boolean; acceptAllFile: (filePath: string) => void; rejectAllFile: (filePath: string) => void; acceptAll: () => void; @@ -109,6 +124,46 @@ export interface ChangeReviewSlice { checkTaskHasChanges: (teamName: string, taskId: string) => Promise; } +/** + * Map a current CM chunk index to its original index, accounting for chunks + * that have been accepted/rejected (removed from CM view, causing index shifts). + * + * When chunk 0 is accepted, CM removes it — old chunk 1 becomes new chunk 0. + * This function reverses that shift so decisions are stored with stable indices. + */ +function mapCurrentToOriginalIndex( + filePath: string, + currentIdx: number, + hunkDecisions: Record, + totalChunks: number +): number { + const decided = new Set(); + for (let i = 0; i < totalChunks; i++) { + if (`${filePath}:${i}` in hunkDecisions) { + decided.add(i); + } + } + + // Walk original indices, skip already-decided, count undecided until currentIdx + let undecidedSeen = 0; + for (let orig = 0; orig < totalChunks; orig++) { + if (decided.has(orig)) continue; + if (undecidedSeen === currentIdx) return orig; + undecidedSeen++; + } + + return currentIdx; +} + +/** Get the hunk count for a file: prefer actual CM chunk count, fallback to snippet count */ +export function getFileHunkCount( + filePath: string, + snippetsLength: number, + fileChunkCounts: Record +): number { + return fileChunkCounts[filePath] ?? snippetsLength; +} + export const createChangeReviewSlice: StateCreator = ( set, get @@ -123,6 +178,8 @@ export const createChangeReviewSlice: StateCreator { - const key = `${filePath}:${hunkIndex}`; - set((state) => ({ - hunkDecisions: { ...state.hunkDecisions, [key]: decision }, + const state = get(); + const totalChunks = state.fileChunkCounts[filePath] ?? 0; + // Map current chunk index to original: after accept/reject, chunks shift in CM. + // We need the original index to keep decisions stable across shifts. + const originalIndex = + totalChunks > 0 + ? mapCurrentToOriginalIndex(filePath, hunkIndex, state.hunkDecisions, totalChunks) + : hunkIndex; + const key = `${filePath}:${originalIndex}`; + set((s) => ({ + hunkDecisions: { ...s.hunkDecisions, [key]: decision }, })); }, @@ -279,13 +350,46 @@ export const createChangeReviewSlice: StateCreator { + set((s) => ({ + fileChunkCounts: { ...s.fileChunkCounts, [filePath]: count }, + })); + }, + + pushReviewUndoSnapshot: () => { + const state = get(); + const snapshot: DecisionSnapshot = { + hunkDecisions: { ...state.hunkDecisions }, + fileDecisions: { ...state.fileDecisions }, + }; + const stack = [...state.reviewUndoStack, snapshot]; + if (stack.length > MAX_REVIEW_UNDO_DEPTH) { + stack.shift(); + } + set({ reviewUndoStack: stack }); + }, + + undoBulkReview: () => { + const state = get(); + if (state.reviewUndoStack.length === 0) return false; + const stack = [...state.reviewUndoStack]; + const snapshot = stack.pop()!; + set({ + hunkDecisions: snapshot.hunkDecisions, + fileDecisions: snapshot.fileDecisions, + reviewUndoStack: stack, + }); + return true; + }, + acceptAllFile: (filePath: string) => { const state = get(); const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath); if (!file) return; + const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); const newHunkDecisions = { ...state.hunkDecisions }; - for (let i = 0; i < file.snippets.length; i++) { + for (let i = 0; i < count; i++) { newHunkDecisions[`${filePath}:${i}`] = 'accepted'; } set({ @@ -299,8 +403,9 @@ export const createChangeReviewSlice: StateCreator f.filePath === filePath); if (!file) return; + const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); const newHunkDecisions = { ...state.hunkDecisions }; - for (let i = 0; i < file.snippets.length; i++) { + for (let i = 0; i < count; i++) { newHunkDecisions[`${filePath}:${i}`] = 'rejected'; } set({ @@ -318,7 +423,8 @@ export const createChangeReviewSlice: StateCreator { - const next = { ...s.editedContents }; - delete next[filePath]; - return { editedContents: next, applying: false }; + const nextEdited = { ...s.editedContents }; + delete nextEdited[filePath]; + // Remove cached file content so next fetchFileContent re-reads from disk + const nextContents = { ...s.fileContents }; + delete nextContents[filePath]; + return { editedContents: nextEdited, fileContents: nextContents, applying: false }; }); } catch (error) { set({ applying: false, applyError: mapReviewError(error) }); diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts new file mode 100644 index 00000000..0c7073ba --- /dev/null +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -0,0 +1,96 @@ +/** + * CLI Installer slice — manages CLI installation status and install/update progress. + */ + +import { api } from '@renderer/api'; +import { createLogger } from '@shared/utils/logger'; + +import type { AppState } from '../types'; +import type { CliInstallationStatus } from '@shared/types'; +import type { StateCreator } from 'zustand'; + +const logger = createLogger('Store:cliInstaller'); + +/** Max log lines to keep in UI (reserved for future use) */ +const _MAX_LOG_LINES = 50; + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface CliInstallerSlice { + // State + cliStatus: CliInstallationStatus | null; + cliStatusLoading: boolean; + cliStatusError: string | null; + cliInstallerState: + | 'idle' + | 'checking' + | 'downloading' + | 'verifying' + | 'installing' + | 'completed' + | 'error'; + cliDownloadProgress: number; + cliDownloadTransferred: number; + cliDownloadTotal: number; + cliInstallerError: string | null; + cliInstallerDetail: string | null; + cliInstallerLogs: string[]; + cliCompletedVersion: string | null; + + // Actions + fetchCliStatus: () => Promise; + installCli: () => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createCliInstallerSlice: StateCreator = ( + set +) => ({ + // Initial state + cliStatus: null, + cliStatusLoading: false, + cliStatusError: null, + cliInstallerState: 'idle', + cliDownloadProgress: 0, + cliDownloadTransferred: 0, + cliDownloadTotal: 0, + cliInstallerError: null, + cliInstallerDetail: null, + cliInstallerLogs: [], + cliCompletedVersion: null, + + fetchCliStatus: async () => { + set({ cliStatusLoading: true, cliStatusError: null }); + try { + const status = await api.cliInstaller.getStatus(); + set({ cliStatus: status }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to check CLI status'; + logger.error('Failed to fetch CLI status:', error); + set({ cliStatusError: message }); + } finally { + set({ cliStatusLoading: false }); + } + }, + + installCli: () => { + set({ + cliInstallerState: 'checking', + cliInstallerError: null, + cliInstallerDetail: null, + cliInstallerLogs: [], + cliDownloadProgress: 0, + cliDownloadTransferred: 0, + cliDownloadTotal: 0, + cliCompletedVersion: null, + }); + api.cliInstaller.install().catch((error) => { + logger.error('Failed to install CLI:', error); + }); + }, +}); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 909c7f1d..81d90d43 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -28,6 +28,34 @@ import type { } from '@shared/types'; import type { StateCreator } from 'zustand'; +// --- Clarification notification tracking --- +const notifiedClarificationTaskKeys = new Set(); + +let isFirstFetchAllTasks = true; + +function detectClarificationNotifications(oldTasks: GlobalTask[], newTasks: GlobalTask[]): void { + for (const task of newTasks) { + const key = `${task.teamName}:${task.id}`; + if (task.needsClarification === 'user') { + const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id); + if (oldTask?.needsClarification !== 'user' && !notifiedClarificationTaskKeys.has(key)) { + notifiedClarificationTaskKeys.add(key); + fireClarificationNotification(task); + } + } else { + notifiedClarificationTaskKeys.delete(key); + } + } +} + +function fireClarificationNotification(task: GlobalTask): void { + if (typeof Notification === 'undefined') return; + if (Notification.permission !== 'granted') return; + new Notification('Clarification needed', { + body: `[${task.teamDisplayName}] Task #${task.id}: ${task.subject}`, + }); +} + function mapSendMessageError(error: unknown): string { const message = error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; @@ -61,6 +89,9 @@ export interface TeamSlice { globalTaskDetail: GlobalTaskDetailState | null; openGlobalTaskDetail: (teamName: string, taskId: string) => void; closeGlobalTaskDetail: () => void; + /** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */ + pendingReviewRequest: { taskId: string; filePath?: string } | null; + setPendingReviewRequest: (req: { taskId: string; filePath?: string } | null) => void; selectedTeamName: string | null; selectedTeamData: TeamData | null; selectedTeamLoading: boolean; @@ -104,6 +135,11 @@ export interface TeamSlice { memberName: string, role: string | undefined ) => Promise; + setTaskNeedsClarification: ( + teamName: string, + taskId: string, + value: 'lead' | 'user' | null + ) => Promise; deletedTasks: TeamTask[]; deletedTasksLoading: boolean; softDeleteTask: (teamName: string, taskId: string) => Promise; @@ -142,6 +178,8 @@ export const createTeamSlice: StateCreator = (set, provisioningError: null, kanbanFilterQuery: null, globalTaskDetail: null, + pendingReviewRequest: null, + setPendingReviewRequest: (req) => set({ pendingReviewRequest: req }), openGlobalTaskDetail: (teamName: string, taskId: string) => { set({ globalTaskDetail: { teamName, taskId } }); // Ensure team data is loaded for the dialog @@ -158,37 +196,62 @@ export const createTeamSlice: StateCreator = (set, deletedTasksLoading: false, fetchTeams: async () => { - set({ teamsLoading: true, teamsError: null }); + // Only show loading spinner on initial load — avoids flickering when refreshing + const isInitialLoad = get().teams.length === 0; + if (isInitialLoad) { + set({ teamsLoading: true, teamsError: null }); + } try { const teams = await unwrapIpc('team:list', () => api.teams.list()); set({ teams, teamsLoading: false, teamsError: null }); } catch (error) { + // On refresh failure, keep existing teams visible set({ teamsLoading: false, - teamsError: - error instanceof IpcError + teamsError: isInitialLoad + ? error instanceof IpcError ? error.message : error instanceof Error ? error.message - : 'Failed to fetch teams', + : 'Failed to fetch teams' + : null, }); } }, fetchAllTasks: async () => { - set({ globalTasksLoading: true, globalTasksError: null }); + const isInitialLoad = get().globalTasks.length === 0; + if (isInitialLoad) { + set({ globalTasksLoading: true, globalTasksError: null }); + } + const oldTasks = get().globalTasks; + const wasFirst = isFirstFetchAllTasks; + isFirstFetchAllTasks = false; try { const tasks = await unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()); + + if (!wasFirst) { + detectClarificationNotifications(oldTasks, tasks); + } else { + // Initial load — seed the Set to prevent false notifications on next update + for (const task of tasks) { + if (task.needsClarification === 'user') { + notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`); + } + } + } + set({ globalTasks: tasks, globalTasksLoading: false, globalTasksError: null }); } catch (error) { set({ globalTasksLoading: false, - globalTasksError: - error instanceof IpcError + globalTasksError: isInitialLoad + ? error instanceof IpcError ? error.message : error instanceof Error ? error.message - : 'Failed to fetch tasks', + : 'Failed to fetch tasks' + : null, }); } }, @@ -480,6 +543,14 @@ export const createTeamSlice: StateCreator = (set, await get().refreshTeamData(teamName); }, + setTaskNeedsClarification: async (teamName, taskId, value) => { + await unwrapIpc('team:setTaskClarification', () => + api.teams.setTaskClarification(teamName, taskId, value) + ); + await get().refreshTeamData(teamName); + await get().fetchAllTasks(); + }, + addTaskComment: async (teamName, taskId, text) => { set({ addingComment: true, addCommentError: null }); try { diff --git a/src/renderer/store/types.ts b/src/renderer/store/types.ts index c6e1c86a..e6db4a6c 100644 --- a/src/renderer/store/types.ts +++ b/src/renderer/store/types.ts @@ -4,6 +4,7 @@ */ import type { ChangeReviewSlice } from './slices/changeReviewSlice'; +import type { CliInstallerSlice } from './slices/cliInstallerSlice'; import type { ConfigSlice } from './slices/configSlice'; import type { ConnectionSlice } from './slices/connectionSlice'; import type { ContextSlice } from './slices/contextSlice'; @@ -94,4 +95,5 @@ export type AppState = ProjectSlice & ConnectionSlice & ContextSlice & UpdateSlice & - ChangeReviewSlice; + ChangeReviewSlice & + CliInstallerSlice; diff --git a/src/renderer/utils/formatters.ts b/src/renderer/utils/formatters.ts index 1ebc9f16..c914b799 100644 --- a/src/renderer/utils/formatters.ts +++ b/src/renderer/utils/formatters.ts @@ -5,6 +5,31 @@ // Re-export token formatting from shared module export { formatTokensCompact } from '@shared/utils/tokenFormatting'; +/** + * Formats a byte count to a human-readable string (e.g. "1.2 MB"). + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Formats an ISO timestamp into a relative time string (e.g. "just now", "5m ago", "2h ago"). + */ +export function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMin / 60); + + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return date.toLocaleDateString(); +} + /** * Formats duration in milliseconds to a human-readable string. */ diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 15d04fd5..fbc67a7b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -7,6 +7,7 @@ * Shared between preload and renderer processes. */ +import type { CliInstallerAPI } from './cliInstaller'; import type { AppConfig, DetectedError, @@ -427,6 +428,11 @@ export interface TeamsAPI { role: string | undefined ) => Promise; addTaskComment: (teamName: string, taskId: string, text: string) => Promise; + setTaskClarification: ( + teamName: string, + taskId: string, + value: 'lead' | 'user' | null + ) => Promise; getProjectBranch: (projectPath: string) => Promise; getAttachments: (teamName: string, messageId: string) => Promise; killProcess: (teamName: string, pid: number) => Promise; @@ -585,6 +591,7 @@ export interface ElectronAPI { projectRoot?: string, userSelectedFromDialog?: boolean ) => Promise<{ success: boolean; error?: string }>; + showInFolder: (filePath: string) => Promise; openExternal: (url: string) => Promise<{ success: boolean; error?: string }>; // Window controls (when title bar is hidden, e.g. Windows / Linux) @@ -622,6 +629,9 @@ export interface ElectronAPI { // Review API review: ReviewAPI; + + // CLI Installer API + cliInstaller: CliInstallerAPI; } // ============================================================================= diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts new file mode 100644 index 00000000..63fe171d --- /dev/null +++ b/src/shared/types/cliInstaller.ts @@ -0,0 +1,82 @@ +/** + * CLI Installer types — shared between main, preload, and renderer processes. + * + * Used for detecting, downloading, verifying, and installing Claude Code CLI binary. + */ + +// ============================================================================= +// Platform Detection +// ============================================================================= + +/** + * Supported platform/architecture combinations for Claude CLI binary distribution. + */ +export type CliPlatform = + | 'darwin-arm64' + | 'darwin-x64' + | 'linux-x64' + | 'linux-arm64' + | 'linux-arm64-musl' + | 'linux-x64-musl' + | 'win32-x64' + | 'win32-arm64'; + +// ============================================================================= +// Installation Status +// ============================================================================= + +/** + * Current CLI installation status returned by getStatus(). + */ +export interface CliInstallationStatus { + /** Whether CLI binary is found on the system */ + installed: boolean; + /** Installed version string (e.g. "2.1.59"), null if not installed */ + installedVersion: string | null; + /** Absolute path to the resolved binary, null if not found */ + binaryPath: string | null; + /** Latest available version from GCS, null if check failed */ + latestVersion: string | null; + /** True when installed version < latest version */ + updateAvailable: boolean; +} + +// ============================================================================= +// Installer Progress Events +// ============================================================================= + +/** + * Progress event sent from main→renderer during CLI install/update. + */ +export interface CliInstallerProgress { + /** Current phase of the installation process */ + type: 'checking' | 'downloading' | 'verifying' | 'installing' | 'completed' | 'error'; + /** Download progress 0-100, only present for 'downloading' */ + percent?: number; + /** Bytes downloaded so far */ + transferred?: number; + /** Total bytes to download (may be undefined if Content-Length absent) */ + total?: number; + /** Installed version string, only present for 'completed' */ + version?: string; + /** Error message, only present for 'error' */ + error?: string; + /** Status detail text (e.g. stdout lines from `claude install`) */ + detail?: string; +} + +// ============================================================================= +// Preload API +// ============================================================================= + +/** + * CLI Installer API exposed via preload bridge. + */ +export interface CliInstallerAPI { + /** Get current CLI installation status */ + getStatus: () => Promise; + /** Start install/update flow. Progress sent via onProgress events. */ + install: () => Promise; + /** Subscribe to progress events. Returns cleanup function. */ + onProgress: (cb: (event: unknown, data: CliInstallerProgress) => void) => () => void; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 62ad345c..bca36c2d 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -29,3 +29,6 @@ export type * from './team'; // Re-export Review types (Phase 1) export type * from './review'; + +// Re-export CLI Installer types +export type * from './cliInstaller'; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 9f3ca74c..09eeaab0 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -82,6 +82,8 @@ export interface TeamTask { updatedAt?: string; projectPath?: string; comments?: TaskComment[]; + /** Signals that the agent is blocked and needs clarification. "lead" = ask team lead, "user" = escalated to human. */ + needsClarification?: 'lead' | 'user'; /** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */ deletedAt?: string; } diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 029faabe..499c5f2c 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -44,6 +44,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({ TEAM_LEAD_ACTIVITY: 'team:leadActivity', TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask', TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks', + TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification', TEAM_RESTORE: 'team:restoreTeam', TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam', TEAM_RESTORE_TASK: 'team:restoreTask', @@ -85,6 +86,7 @@ import { TEAM_PERMANENTLY_DELETE, TEAM_REMOVE_MEMBER, TEAM_RESTORE, + TEAM_SET_TASK_CLARIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_UPDATE_MEMBER_ROLE, } from '../../../src/preload/constants/ipcChannels'; @@ -127,6 +129,7 @@ describe('ipc teams handlers', () => { updateMemberRole: vi.fn(async () => ({ oldRole: undefined, changed: true })), softDeleteTask: vi.fn(async () => undefined), getDeletedTasks: vi.fn(async () => []), + setTaskNeedsClarification: vi.fn(async () => undefined), }; const provisioningService = { prepareForProvisioning: vi.fn(async () => ({ @@ -194,6 +197,7 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(true); expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(true); expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(true); + expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(true); expect(handlers.has(TEAM_RESTORE)).toBe(true); expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true); }); @@ -508,6 +512,7 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false); expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(false); expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(false); + expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(false); expect(handlers.has(TEAM_RESTORE)).toBe(false); expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false); }); diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts new file mode 100644 index 00000000..c31a1d8b --- /dev/null +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies before importing service +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFile: vi.fn(), + }; +}); + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => false), + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + end: vi.fn((cb: () => void) => cb()), + destroy: vi.fn(), + on: vi.fn(), + })), + promises: { + ...actual.promises, + chmod: vi.fn(), + unlink: vi.fn(), + }, + }; +}); + +vi.mock('https', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + get: vi.fn(), + }, + }; +}); + +vi.mock('http', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + get: vi.fn(), + }, + }; +}); + +vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ + ClaudeBinaryResolver: { + resolve: vi.fn(), + clearCache: vi.fn(), + }, +})); + +import { + CliInstallerService, + isVersionOlder, + normalizeVersion, +} from '@main/services/infrastructure/CliInstallerService'; +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; + +/** + * Helper: allow expected console.error/warn calls in tests where service logs errors. + * The test setup asserts no unexpected console.error/warn, so we re-spy to capture them. + */ +function allowConsoleLogs(): void { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +} + +describe('CliInstallerService', () => { + let service: CliInstallerService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new CliInstallerService(); + }); + + describe('getStatus', () => { + it('returns not installed when binary is not found', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(null); + + const status = await service.getStatus(); + + expect(status.installed).toBe(false); + expect(status.installedVersion).toBeNull(); + expect(status.binaryPath).toBeNull(); + expect(status.updateAvailable).toBe(false); + }); + + it('returns installed when binary exists', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); + + const status = await service.getStatus(); + + expect(status.installed).toBe(true); + expect(status.binaryPath).toBe('/usr/local/bin/claude'); + // Version will be null because execFile is mocked to no-op + // and latestVersion will be null because fetch is mocked + }); + }); + + describe('install mutex', () => { + it('prevents concurrent installations', async () => { + allowConsoleLogs(); + + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() }, + }; + service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); + + // Start first install (will fail on fetch — that's fine for mutex test) + const promise1 = service.install(); + // Start second install immediately — should get "already in progress" + const promise2 = service.install(); + + await Promise.allSettled([promise1, promise2]); + + // Second call should send "already in progress" error + const progressCalls = mockWindow.webContents.send.mock.calls; + const errorCalls = progressCalls.filter( + ([channel, data]: [string, { type: string; error?: string }]) => + channel === 'cliInstaller:progress' && + data.type === 'error' && + data.error?.includes('already in progress') + ); + expect(errorCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('resets mutex after install completes (even on failure)', async () => { + allowConsoleLogs(); + + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() }, + }; + service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); + + // First install will fail (no network mock) + await service.install(); + + // After failure, mutex should be released — second install should start checking + mockWindow.webContents.send.mockClear(); + await service.install(); + + const checkingCalls = mockWindow.webContents.send.mock.calls.filter( + ([channel, data]: [string, { type: string }]) => + channel === 'cliInstaller:progress' && data.type === 'checking' + ); + expect(checkingCalls.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('setMainWindow', () => { + it('accepts null to clear window reference', () => { + service.setMainWindow(null); + expect(true).toBe(true); + }); + + it('accepts a BrowserWindow instance', () => { + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() }, + }; + service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); + expect(true).toBe(true); + }); + }); + + describe('normalizeVersion', () => { + it('extracts semver from "claude --version" output', () => { + expect(normalizeVersion('2.1.34 (Claude Code)\n')).toBe('2.1.34'); + expect(normalizeVersion('2.1.59 (Claude Code)')).toBe('2.1.59'); + }); + + it('handles plain version strings', () => { + expect(normalizeVersion('2.1.59')).toBe('2.1.59'); + expect(normalizeVersion(' 2.1.59 ')).toBe('2.1.59'); + }); + + it('strips v prefix', () => { + expect(normalizeVersion('v2.1.59')).toBe('2.1.59'); + expect(normalizeVersion('v2.1.59\n')).toBe('2.1.59'); + }); + + it('returns trimmed input when no semver found', () => { + expect(normalizeVersion('unknown')).toBe('unknown'); + expect(normalizeVersion(' beta ')).toBe('beta'); + }); + }); + + describe('isVersionOlder', () => { + it('returns true when installed is older', () => { + expect(isVersionOlder('2.1.34', '2.1.59')).toBe(true); + expect(isVersionOlder('1.0.0', '2.0.0')).toBe(true); + expect(isVersionOlder('2.0.0', '2.1.0')).toBe(true); + expect(isVersionOlder('2.1.0', '2.1.1')).toBe(true); + }); + + it('returns false when versions are equal', () => { + expect(isVersionOlder('2.1.59', '2.1.59')).toBe(false); + expect(isVersionOlder('1.0.0', '1.0.0')).toBe(false); + }); + + it('returns false when installed is newer', () => { + expect(isVersionOlder('2.1.59', '2.1.34')).toBe(false); + expect(isVersionOlder('3.0.0', '2.9.99')).toBe(false); + expect(isVersionOlder('2.2.0', '2.1.59')).toBe(false); + }); + + it('handles numeric comparison correctly (not lexicographic)', () => { + // "2.10.0" > "2.9.0" numerically (but "10" < "9" lexicographically) + expect(isVersionOlder('2.9.0', '2.10.0')).toBe(true); + expect(isVersionOlder('2.10.0', '2.9.0')).toBe(false); + }); + + it('handles different segment counts', () => { + expect(isVersionOlder('2.1', '2.1.1')).toBe(true); + expect(isVersionOlder('2.1.1', '2.1')).toBe(false); + expect(isVersionOlder('2.1', '2.1.0')).toBe(false); // 2.1 == 2.1.0 + }); + }); + + describe('sendProgress with destroyed window', () => { + it('does not throw when window is destroyed', async () => { + allowConsoleLogs(); + + const mockWindow = { + isDestroyed: () => true, + webContents: { send: vi.fn() }, + }; + service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); + + // install() triggers sendProgress — should not throw even with destroyed window + await service.install(); + + // send should NOT have been called because window is destroyed + expect(mockWindow.webContents.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/main/services/team/teamctl.test.ts b/test/main/services/team/teamctl.test.ts new file mode 100644 index 00000000..775a8e9a --- /dev/null +++ b/test/main/services/team/teamctl.test.ts @@ -0,0 +1,984 @@ +/** + * Integration tests for teamctl.js — the CLI tool agents use to manage tasks, + * kanban state, messages, reviews, and processes. + * + * Strategy: + * 1. Use TeamAgentToolsInstaller.ensureInstalled() to write the real script. + * 2. Create a temp directory with --claude-dir for full isolation. + * 3. Use child_process.execFileSync (no shell) to run commands. + * 4. Assert on stdout, stderr, exit codes, and written JSON files. + */ + +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Temp root for all tests. Cleaned up in afterAll. */ +let tmpRoot: string; + +/** Path to the installed teamctl.js script. */ +let scriptPath: string; + +const TEAM = 'test-team'; + +/** Create a fresh claude-dir structure for a single test. */ +function makeFreshClaudeDir(): string { + const dir = fs.mkdtempSync(path.join(tmpRoot, 'claude-')); + const teamsDir = path.join(dir, 'teams', TEAM); + const tasksDir = path.join(dir, 'tasks', TEAM); + fs.mkdirSync(teamsDir, { recursive: true }); + fs.mkdirSync(tasksDir, { recursive: true }); + + // Minimal config.json + const config = { + name: TEAM, + description: 'Test team', + members: [ + { name: 'alice', role: 'team-lead' }, + { name: 'bob', role: 'developer' }, + ], + }; + fs.writeFileSync(path.join(teamsDir, 'config.json'), JSON.stringify(config, null, 2)); + return dir; +} + +/** Write a task fixture into the tasks dir. */ +function writeTask(claudeDir: string, id: string, task: Record): void { + const tasksDir = path.join(claudeDir, 'tasks', TEAM); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(tasksDir, `${id}.json`), JSON.stringify(task, null, 2)); +} + +/** Read a task from disk. */ +function readTask(claudeDir: string, id: string): Record { + const filePath = path.join(claudeDir, 'tasks', TEAM, `${id}.json`); + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +/** Read kanban state from disk. */ +function readKanban(claudeDir: string): Record { + const filePath = path.join(claudeDir, 'teams', TEAM, 'kanban-state.json'); + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return {}; + } +} + +/** Read inbox messages for a member. */ +function readInbox(claudeDir: string, member: string): unknown[] { + const filePath = path.join(claudeDir, 'teams', TEAM, 'inboxes', `${member}.json`); + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return []; + } +} + +interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** Run teamctl.js and return stdout, stderr, exitCode. */ +function run(claudeDir: string, args: string[]): RunResult { + try { + const stdout = execFileSync( + process.execPath, // node binary + [scriptPath, '--claude-dir', claudeDir, '--team', TEAM, ...args], + { encoding: 'utf8', timeout: 10_000 } + ); + return { stdout, stderr: '', exitCode: 0 }; + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; status?: number }; + return { + stdout: e.stdout ?? '', + stderr: e.stderr ?? '', + exitCode: e.status ?? 1, + }; + } +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // Suppress console.error/warn mocks from setup.ts — we use real child processes + vi.restoreAllMocks(); + + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'teamctl-test-')); + + // Mock getToolsBasePath to use our temp directory (setup.ts stubs HOME to /home/testuser) + const toolsDir = path.join(tmpRoot, 'tools'); + fs.mkdirSync(toolsDir, { recursive: true }); + vi.doMock('@main/utils/pathDecoder', async (importOriginal) => { + const orig = await importOriginal(); + return { ...orig, getToolsBasePath: () => toolsDir }; + }); + + // Install the real teamctl.js script using the installer class. + const { TeamAgentToolsInstaller } = await import('@main/services/team/TeamAgentToolsInstaller'); + const installer = new TeamAgentToolsInstaller(); + scriptPath = await installer.ensureInstalled(); +}); + +afterAll(() => { + if (tmpRoot) { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('teamctl.js', () => { + let claudeDir: string; + + beforeEach(() => { + // Suppress console spies from global setup + vi.restoreAllMocks(); + claudeDir = makeFreshClaudeDir(); + }); + + // ---- Help ---- + describe('help', () => { + it('prints help with --help flag', () => { + const { stdout, exitCode } = run(claudeDir, ['--help']); + expect(exitCode).toBe(0); + expect(stdout).toContain('teamctl.js v'); + expect(stdout).toContain('Usage:'); + expect(stdout).toContain('task set-status'); + expect(stdout).toContain('task set-clarification'); + }); + + it('prints help with no arguments', () => { + const { stdout, exitCode } = run(claudeDir, []); + expect(exitCode).toBe(0); + expect(stdout).toContain('Usage:'); + }); + }); + + // ---- Task Create ---- + describe('task create', () => { + it('creates a task with minimal fields', () => { + const { stdout, exitCode } = run(claudeDir, ['task', 'create', '--subject', 'My first task']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.id).toBe('1'); + expect(parsed.subject).toBe('My first task'); + expect(parsed.status).toBe('pending'); + expect(parsed.owner).toBeUndefined(); + + // Verify file on disk + const onDisk = readTask(claudeDir, '1'); + expect(onDisk.subject).toBe('My first task'); + }); + + it('creates a task with owner → status defaults to in_progress', () => { + const { stdout, exitCode } = run(claudeDir, [ + 'task', + 'create', + '--subject', + 'Owned task', + '--owner', + 'bob', + ]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.owner).toBe('bob'); + expect(parsed.status).toBe('in_progress'); + }); + + it('respects explicit status even with owner', () => { + const { stdout } = run(claudeDir, [ + 'task', + 'create', + '--subject', + 'Pending owned', + '--owner', + 'bob', + '--status', + 'pending', + ]); + const parsed = JSON.parse(stdout); + expect(parsed.status).toBe('pending'); + expect(parsed.owner).toBe('bob'); + }); + + it('increments task IDs', () => { + run(claudeDir, ['task', 'create', '--subject', 'Task 1']); + const { stdout } = run(claudeDir, ['task', 'create', '--subject', 'Task 2']); + const parsed = JSON.parse(stdout); + expect(parsed.id).toBe('2'); + }); + + it('creates task with description, activeForm, and from', () => { + const { stdout } = run(claudeDir, [ + 'task', + 'create', + '--subject', + 'Complex task', + '--description', + 'Do something important', + '--active-form', + 'Working on complex task', + '--from', + 'alice', + ]); + const parsed = JSON.parse(stdout); + expect(parsed.description).toBe('Do something important'); + expect(parsed.activeForm).toBe('Working on complex task'); + expect(parsed.createdBy).toBe('alice'); + }); + + it('fails without --subject', () => { + const { exitCode, stderr } = run(claudeDir, ['task', 'create']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Missing --subject'); + }); + + it('sends inbox notification with --notify and --owner', () => { + run(claudeDir, [ + 'task', + 'create', + '--subject', + 'Assigned task', + '--owner', + 'bob', + '--notify', + '--from', + 'alice', + ]); + const inbox = readInbox(claudeDir, 'bob'); + expect(inbox.length).toBe(1); + const msg = inbox[0] as Record; + expect(msg.from).toBe('alice'); + expect(String(msg.text)).toContain('New task assigned'); + }); + }); + + // ---- Task Set-Status ---- + describe('task set-status', () => { + beforeEach(() => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Test task', + status: 'pending', + blocks: [], + blockedBy: [], + }); + }); + + it('changes status to in_progress', () => { + const { stdout, exitCode } = run(claudeDir, ['task', 'set-status', '1', 'in_progress']); + expect(exitCode).toBe(0); + expect(stdout).toContain('status=in_progress'); + const task = readTask(claudeDir, '1'); + expect(task.status).toBe('in_progress'); + }); + + it('changes status to completed', () => { + const { stdout } = run(claudeDir, ['task', 'set-status', '1', 'completed']); + expect(stdout).toContain('status=completed'); + expect(readTask(claudeDir, '1').status).toBe('completed'); + }); + + it('changes status to deleted', () => { + run(claudeDir, ['task', 'set-status', '1', 'deleted']); + expect(readTask(claudeDir, '1').status).toBe('deleted'); + }); + + it('fails on invalid status', () => { + const { exitCode, stderr } = run(claudeDir, ['task', 'set-status', '1', 'invalid']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Invalid status'); + }); + + it('fails on missing task', () => { + const { exitCode, stderr } = run(claudeDir, ['task', 'set-status', '999', 'pending']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Task not found'); + }); + }); + + // ---- Task Start / Complete shortcuts ---- + describe('task start / complete', () => { + beforeEach(() => { + writeTask(claudeDir, '1', { id: '1', subject: 'Task', status: 'pending' }); + }); + + it('task start sets in_progress', () => { + const { stdout, exitCode } = run(claudeDir, ['task', 'start', '1']); + expect(exitCode).toBe(0); + expect(stdout).toContain('status=in_progress'); + expect(readTask(claudeDir, '1').status).toBe('in_progress'); + }); + + it('task complete sets completed', () => { + const { stdout, exitCode } = run(claudeDir, ['task', 'complete', '1']); + expect(exitCode).toBe(0); + expect(stdout).toContain('status=completed'); + expect(readTask(claudeDir, '1').status).toBe('completed'); + }); + + it('task done is alias for complete', () => { + // Override the team flag resolution — run with minimal args + const result = run(claudeDir, ['task', 'done', '1']); + expect(result.exitCode).toBe(0); + expect(readTask(claudeDir, '1').status).toBe('completed'); + }); + }); + + // ---- Task Get / List ---- + describe('task get / list', () => { + beforeEach(() => { + writeTask(claudeDir, '1', { id: '1', subject: 'First', status: 'pending' }); + writeTask(claudeDir, '2', { id: '2', subject: 'Second', status: 'in_progress' }); + }); + + it('gets a single task by ID', () => { + const { stdout, exitCode } = run(claudeDir, ['task', 'get', '1']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.subject).toBe('First'); + expect(parsed.id).toBe('1'); + }); + + it('lists all tasks', () => { + const { stdout, exitCode } = run(claudeDir, ['task', 'list']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as Record[]; + expect(parsed).toHaveLength(2); + expect(parsed.map((t) => t.id)).toEqual(['1', '2']); + }); + + it('fails on task get with missing ID', () => { + const { exitCode, stderr } = run(claudeDir, ['task', 'get']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Usage'); + }); + }); + + // ---- Task Comment ---- + describe('task comment', () => { + beforeEach(() => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Commentable task', + status: 'in_progress', + owner: 'bob', + comments: [], + }); + }); + + it('adds a comment to a task', () => { + const { stdout, exitCode } = run(claudeDir, [ + 'task', + 'comment', + '1', + '--text', + 'Hello world', + '--from', + 'alice', + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain('comment added'); + const task = readTask(claudeDir, '1'); + const comments = task.comments as Record[]; + expect(comments).toHaveLength(1); + expect(comments[0].text).toBe('Hello world'); + expect(comments[0].author).toBe('alice'); + }); + + it('defaults author to "agent" when --from is not specified', () => { + run(claudeDir, ['task', 'comment', '1', '--text', 'No author']); + const task = readTask(claudeDir, '1'); + const comments = task.comments as Record[]; + expect(comments[0].author).toBe('agent'); + }); + + it('sends inbox notification to owner (skip self-notification)', () => { + // alice comments on bob's task → bob gets notified + run(claudeDir, ['task', 'comment', '1', '--text', 'Review this', '--from', 'alice']); + const inbox = readInbox(claudeDir, 'bob'); + expect(inbox.length).toBe(1); + + // bob comments on own task → no notification + run(claudeDir, ['task', 'comment', '1', '--text', 'Self note', '--from', 'bob']); + const inbox2 = readInbox(claudeDir, 'bob'); + expect(inbox2.length).toBe(1); // still 1 + }); + + it('fails without --text', () => { + const { exitCode, stderr } = run(claudeDir, ['task', 'comment', '1']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Missing --text'); + }); + }); + + // ---- Comment Auto-Clear needsClarification ---- + describe('comment auto-clear needsClarification', () => { + it('clears "lead" when non-owner comments', () => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Blocked task', + status: 'in_progress', + owner: 'bob', + needsClarification: 'lead', + comments: [], + }); + + // alice (not owner) comments → auto-clear + run(claudeDir, ['task', 'comment', '1', '--text', 'Here is the answer', '--from', 'alice']); + const task = readTask(claudeDir, '1'); + expect(task.needsClarification).toBeUndefined(); + }); + + it('does NOT clear "lead" when owner comments', () => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Blocked task', + status: 'in_progress', + owner: 'bob', + needsClarification: 'lead', + comments: [], + }); + + // bob (owner) comments → no auto-clear + run(claudeDir, ['task', 'comment', '1', '--text', 'Still waiting', '--from', 'bob']); + const task = readTask(claudeDir, '1'); + expect(task.needsClarification).toBe('lead'); + }); + + it('does NOT clear "user" via comment (only UI clears "user")', () => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Escalated task', + status: 'in_progress', + owner: 'bob', + needsClarification: 'user', + comments: [], + }); + + // alice comments → "user" should stay (only "lead" is auto-cleared by teamctl) + run(claudeDir, ['task', 'comment', '1', '--text', 'Anything', '--from', 'alice']); + const task = readTask(claudeDir, '1'); + expect(task.needsClarification).toBe('user'); + }); + }); + + // ---- Task Set-Clarification ---- + describe('task set-clarification', () => { + beforeEach(() => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Task needing help', + status: 'in_progress', + owner: 'bob', + }); + }); + + it('sets needsClarification to "lead"', () => { + const { stdout, exitCode } = run(claudeDir, ['task', 'set-clarification', '1', 'lead']); + expect(exitCode).toBe(0); + expect(stdout).toContain('needsClarification=lead'); + const task = readTask(claudeDir, '1'); + expect(task.needsClarification).toBe('lead'); + }); + + it('sets needsClarification to "user"', () => { + const { exitCode } = run(claudeDir, ['task', 'set-clarification', '1', 'user']); + expect(exitCode).toBe(0); + const task = readTask(claudeDir, '1'); + expect(task.needsClarification).toBe('user'); + }); + + it('clears needsClarification with "clear"', () => { + // Set first, then clear + run(claudeDir, ['task', 'set-clarification', '1', 'lead']); + expect(readTask(claudeDir, '1').needsClarification).toBe('lead'); + + const { stdout, exitCode } = run(claudeDir, ['task', 'set-clarification', '1', 'clear']); + expect(exitCode).toBe(0); + expect(stdout).toContain('needsClarification=cleared'); + const task = readTask(claudeDir, '1'); + expect(task.needsClarification).toBeUndefined(); + }); + + it('fails on invalid value', () => { + const { exitCode, stderr } = run(claudeDir, ['task', 'set-clarification', '1', 'invalid']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Invalid value'); + }); + + it('fails on missing arguments', () => { + const { exitCode, stderr } = run(claudeDir, ['task', 'set-clarification']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Usage'); + }); + }); + + // ---- Task Briefing ---- + describe('task briefing', () => { + beforeEach(() => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Alice in-progress', + status: 'in_progress', + owner: 'alice', + }); + writeTask(claudeDir, '2', { + id: '2', + subject: 'Bob todo', + status: 'pending', + owner: 'bob', + }); + writeTask(claudeDir, '3', { + id: '3', + subject: 'Unassigned', + status: 'pending', + }); + writeTask(claudeDir, '4', { + id: '4', + subject: 'Blocked task', + status: 'in_progress', + owner: 'bob', + needsClarification: 'lead', + }); + }); + + it('shows briefing for a specific member', () => { + const { stdout, exitCode } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); + expect(exitCode).toBe(0); + expect(stdout).toContain('Task Briefing for bob'); + expect(stdout).toContain('YOUR TASKS'); + expect(stdout).toContain('Bob todo'); + expect(stdout).toContain('TEAM BOARD'); + expect(stdout).toContain('Alice in-progress'); + }); + + it('shows needsClarification indicator in briefing', () => { + const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'alice']); + expect(stdout).toContain('NEEDS CLARIFICATION'); + expect(stdout).toContain('LEAD'); + }); + + it('fails without --for', () => { + const { exitCode, stderr } = run(claudeDir, ['task', 'briefing']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Missing --for'); + }); + + it('filters out _internal tasks', () => { + writeTask(claudeDir, '_internal_1', { + id: '_internal_1', + subject: 'Internal', + status: 'pending', + metadata: { _internal: true }, + }); + const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'alice']); + expect(stdout).not.toContain('Internal'); + }); + }); + + // ---- Kanban ---- + describe('kanban', () => { + beforeEach(() => { + writeTask(claudeDir, '1', { id: '1', subject: 'Review me', status: 'completed' }); + }); + + it('sets kanban column to review', () => { + const { stdout, exitCode } = run(claudeDir, ['kanban', 'set-column', '1', 'review']); + expect(exitCode).toBe(0); + expect(stdout).toContain('column=review'); + const kanban = readKanban(claudeDir); + const tasks = kanban.tasks as Record>; + expect(tasks['1'].column).toBe('review'); + }); + + it('sets kanban column to approved', () => { + run(claudeDir, ['kanban', 'set-column', '1', 'approved']); + const kanban = readKanban(claudeDir); + const tasks = kanban.tasks as Record>; + expect(tasks['1'].column).toBe('approved'); + }); + + it('clears kanban entry', () => { + run(claudeDir, ['kanban', 'set-column', '1', 'review']); + const { stdout, exitCode } = run(claudeDir, ['kanban', 'clear', '1']); + expect(exitCode).toBe(0); + expect(stdout).toContain('cleared'); + const kanban = readKanban(claudeDir); + const tasks = kanban.tasks as Record>; + expect(tasks['1']).toBeUndefined(); + }); + + it('fails on invalid column', () => { + const { exitCode, stderr } = run(claudeDir, ['kanban', 'set-column', '1', 'invalid']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Invalid column'); + }); + }); + + // ---- Kanban Reviewers ---- + describe('kanban reviewers', () => { + it('lists empty reviewers', () => { + const { stdout, exitCode } = run(claudeDir, ['kanban', 'reviewers', 'list']); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual([]); + }); + + it('adds and removes reviewers', () => { + run(claudeDir, ['kanban', 'reviewers', 'add', 'alice']); + run(claudeDir, ['kanban', 'reviewers', 'add', 'bob']); + const { stdout } = run(claudeDir, ['kanban', 'reviewers', 'list']); + expect(JSON.parse(stdout)).toEqual(['alice', 'bob']); + + run(claudeDir, ['kanban', 'reviewers', 'remove', 'alice']); + const { stdout: stdout2 } = run(claudeDir, ['kanban', 'reviewers', 'list']); + expect(JSON.parse(stdout2)).toEqual(['bob']); + }); + }); + + // ---- Review ---- + describe('review', () => { + beforeEach(() => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Feature X', + status: 'completed', + owner: 'bob', + }); + // Put task in review column + run(claudeDir, ['kanban', 'set-column', '1', 'review']); + }); + + it('approves a task → moves to approved column', () => { + const { stdout, exitCode } = run(claudeDir, ['review', 'approve', '1']); + expect(exitCode).toBe(0); + expect(stdout).toContain('approved'); + const kanban = readKanban(claudeDir); + const tasks = kanban.tasks as Record>; + expect(tasks['1'].column).toBe('approved'); + }); + + it('approve with --notify-owner sends inbox message', () => { + run(claudeDir, [ + 'review', + 'approve', + '1', + '--notify-owner', + '--from', + 'alice', + '--note', + 'Looks great!', + ]); + const inbox = readInbox(claudeDir, 'bob'); + expect(inbox.length).toBe(1); + const msg = inbox[0] as Record; + expect(String(msg.text)).toContain('approved'); + expect(String(msg.text)).toContain('Looks great!'); + }); + + it('request-changes → clears kanban, sets in_progress, sends inbox', () => { + const { exitCode } = run(claudeDir, [ + 'review', + 'request-changes', + '1', + '--comment', + 'Fix the edge case', + '--from', + 'alice', + ]); + expect(exitCode).toBe(0); + + // Kanban cleared + const kanban = readKanban(claudeDir); + const tasks = kanban.tasks as Record>; + expect(tasks['1']).toBeUndefined(); + + // Status back to in_progress + const task = readTask(claudeDir, '1'); + expect(task.status).toBe('in_progress'); + + // Inbox notification + const inbox = readInbox(claudeDir, 'bob'); + expect(inbox.length).toBe(1); + const msg = inbox[0] as Record; + expect(String(msg.text)).toContain('Fix the edge case'); + }); + }); + + // ---- Message Send ---- + describe('message send', () => { + it('sends a message to member inbox', () => { + const { stdout, exitCode } = run(claudeDir, [ + 'message', + 'send', + '--to', + 'bob', + '--text', + 'Hello Bob!', + '--summary', + 'Greeting', + '--from', + 'alice', + ]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.deliveredToInbox).toBe(true); + expect(parsed.messageId).toBeDefined(); + + const inbox = readInbox(claudeDir, 'bob'); + expect(inbox.length).toBe(1); + const msg = inbox[0] as Record; + expect(msg.from).toBe('alice'); + expect(msg.text).toBe('Hello Bob!'); + expect(msg.summary).toBe('Greeting'); + }); + + it('infers lead name from config when --from is missing', () => { + run(claudeDir, ['message', 'send', '--to', 'bob', '--text', 'Hi']); + const inbox = readInbox(claudeDir, 'bob'); + const msg = inbox[0] as Record; + // alice is first member with "lead" role + expect(msg.from).toBe('alice'); + }); + + it('fails without --to', () => { + const { exitCode, stderr } = run(claudeDir, ['message', 'send', '--text', 'No recipient']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Missing --to'); + }); + + it('fails without --text', () => { + const { exitCode, stderr } = run(claudeDir, ['message', 'send', '--to', 'bob']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Missing --text'); + }); + }); + + // ---- Process Management ---- + describe('process management', () => { + it('registers a process', () => { + const { stdout, exitCode } = run(claudeDir, [ + 'process', + 'register', + '--pid', + String(process.pid), + '--label', + 'dev-server', + '--port', + '3000', + '--from', + 'bob', + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain('process registered'); + expect(stdout).toContain(`pid=${process.pid}`); + expect(stdout).toContain('port=3000'); + }); + + it('lists processes with alive status', () => { + run(claudeDir, [ + 'process', + 'register', + '--pid', + String(process.pid), + '--label', + 'dev-server', + ]); + const { stdout, exitCode } = run(claudeDir, ['process', 'list']); + expect(exitCode).toBe(0); + const list = JSON.parse(stdout) as Record[]; + expect(list).toHaveLength(1); + expect(list[0].pid).toBe(process.pid); + expect(list[0].alive).toBe(true); + }); + + it('unregisters a process by pid', () => { + run(claudeDir, [ + 'process', + 'register', + '--pid', + String(process.pid), + '--label', + 'dev-server', + ]); + const { stdout, exitCode } = run(claudeDir, [ + 'process', + 'unregister', + '--pid', + String(process.pid), + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain('unregistered'); + + // List should be empty + const { stdout: listOut } = run(claudeDir, ['process', 'list']); + expect(JSON.parse(listOut)).toHaveLength(0); + }); + + it('fails register without --pid', () => { + const { exitCode, stderr } = run(claudeDir, ['process', 'register', '--label', 'test']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Invalid --pid'); + }); + + it('fails register without --label', () => { + const { exitCode, stderr } = run(claudeDir, ['process', 'register', '--pid', '1234']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Missing --label'); + }); + }); + + // ---- Highwatermark ---- + describe('highwatermark', () => { + it('respects highwatermark for task ID generation', () => { + // Create task 1 + run(claudeDir, ['task', 'create', '--subject', 'Task 1']); + expect(readTask(claudeDir, '1')).toBeDefined(); + + // Create task 2 + run(claudeDir, ['task', 'create', '--subject', 'Task 2']); + expect(readTask(claudeDir, '2')).toBeDefined(); + + // Delete task 2 from disk (simulating agent deletion) + fs.unlinkSync(path.join(claudeDir, 'tasks', TEAM, '2.json')); + + // Highwatermark should be 2, so next task should be 3 + const { stdout } = run(claudeDir, ['task', 'create', '--subject', 'Task 3']); + const parsed = JSON.parse(stdout); + expect(parsed.id).toBe('3'); + }); + }); + + // ---- Error handling ---- + describe('error handling', () => { + it('exits with error for unknown domain', () => { + const { exitCode, stderr } = run(claudeDir, ['foobar', 'something']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Unknown domain'); + }); + + it('exits with error for unknown task action', () => { + const { exitCode, stderr } = run(claudeDir, ['task', 'foobar']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Unknown task action'); + }); + + it('exits with error for unknown kanban action', () => { + const { exitCode, stderr } = run(claudeDir, ['kanban', 'foobar']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Unknown kanban action'); + }); + + it('exits with error for unknown review action', () => { + const { exitCode, stderr } = run(claudeDir, ['review', 'foobar']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Unknown review action'); + }); + + it('exits with error for unknown message action', () => { + const { exitCode, stderr } = run(claudeDir, ['message', 'foobar']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Unknown message action'); + }); + + it('exits with error for unknown process action', () => { + const { exitCode, stderr } = run(claudeDir, ['process', 'foobar']); + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Unknown process action'); + }); + }); + + // ---- Edge cases ---- + describe('edge cases', () => { + it('handles empty tasks directory gracefully for list', () => { + const { stdout, exitCode } = run(claudeDir, ['task', 'list']); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual([]); + }); + + it('handles missing tasks directory gracefully for briefing', () => { + // Remove tasks dir + fs.rmSync(path.join(claudeDir, 'tasks', TEAM), { recursive: true }); + const { stdout, exitCode } = run(claudeDir, ['task', 'briefing', '--for', 'alice']); + expect(exitCode).toBe(0); + expect(stdout).toContain('no tasks assigned to you'); + }); + + it('multiple comments accumulate', () => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Multi-comment', + status: 'in_progress', + owner: 'bob', + }); + run(claudeDir, ['task', 'comment', '1', '--text', 'First', '--from', 'alice']); + run(claudeDir, ['task', 'comment', '1', '--text', 'Second', '--from', 'bob']); + run(claudeDir, ['task', 'comment', '1', '--text', 'Third', '--from', 'alice']); + + const task = readTask(claudeDir, '1'); + const comments = task.comments as Record[]; + expect(comments).toHaveLength(3); + expect(comments.map((c) => c.text)).toEqual(['First', 'Second', 'Third']); + }); + + it('set-clarification preserves other task fields', () => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Rich task', + description: 'Detailed desc', + status: 'in_progress', + owner: 'bob', + blocks: ['2'], + blockedBy: [], + comments: [{ id: 'c1', author: 'alice', text: 'Note', createdAt: '2025-01-01T00:00:00Z' }], + }); + run(claudeDir, ['task', 'set-clarification', '1', 'lead']); + const task = readTask(claudeDir, '1'); + expect(task.needsClarification).toBe('lead'); + expect(task.subject).toBe('Rich task'); + expect(task.description).toBe('Detailed desc'); + expect(task.owner).toBe('bob'); + expect(task.blocks).toEqual(['2']); + const comments = task.comments as Record[]; + expect(comments).toHaveLength(1); + }); + + it('briefing excludes approved tasks', () => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Approved task', + status: 'completed', + owner: 'bob', + }); + run(claudeDir, ['kanban', 'set-column', '1', 'approved']); + + const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); + expect(stdout).not.toContain('Approved task'); + }); + + it('briefing excludes deleted tasks', () => { + writeTask(claudeDir, '1', { + id: '1', + subject: 'Deleted task', + status: 'deleted', + owner: 'bob', + }); + + const { stdout } = run(claudeDir, ['task', 'briefing', '--for', 'bob']); + expect(stdout).not.toContain('Deleted task'); + }); + }); +}); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts new file mode 100644 index 00000000..8e07a552 --- /dev/null +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock api module +vi.mock('@renderer/api', () => ({ + api: { + cliInstaller: { + getStatus: vi.fn(), + install: vi.fn(), + onProgress: vi.fn(() => vi.fn()), + }, + // Minimal stubs for other api methods referenced by store slices + getProjects: vi.fn(() => Promise.resolve([])), + getSessions: vi.fn(() => Promise.resolve([])), + notifications: { + get: vi.fn(() => + Promise.resolve({ + notifications: [], + total: 0, + totalCount: 0, + unreadCount: 0, + hasMore: false, + }) + ), + getUnreadCount: vi.fn(() => Promise.resolve(0)), + onNew: vi.fn(), + onUpdated: vi.fn(), + onClicked: vi.fn(), + }, + config: { get: vi.fn(() => Promise.resolve({})) }, + updater: { check: vi.fn(), onStatus: vi.fn() }, + context: { + getActive: vi.fn(() => Promise.resolve('local')), + list: vi.fn(), + onChanged: vi.fn(), + }, + teams: { + list: vi.fn(() => Promise.resolve([])), + onTeamChange: vi.fn(), + onProvisioningProgress: vi.fn(), + }, + ssh: { onStatus: vi.fn() }, + onFileChange: vi.fn(), + onTodoChange: vi.fn(), + getAppVersion: vi.fn(() => Promise.resolve('1.0.0')), + }, + isElectronMode: () => true, +})); + +import { api } from '@renderer/api'; +import { useStore } from '@renderer/store'; + +import type { CliInstallationStatus } from '@shared/types'; + +describe('cliInstallerSlice', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state + useStore.setState({ + cliStatus: null, + cliInstallerState: 'idle', + cliDownloadProgress: 0, + cliDownloadTransferred: 0, + cliDownloadTotal: 0, + cliInstallerError: null, + cliCompletedVersion: null, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('has correct defaults', () => { + const state = useStore.getState(); + expect(state.cliStatus).toBeNull(); + expect(state.cliInstallerState).toBe('idle'); + expect(state.cliDownloadProgress).toBe(0); + expect(state.cliInstallerError).toBeNull(); + }); + }); + + describe('fetchCliStatus', () => { + it('updates cliStatus from API', async () => { + const mockStatus: CliInstallationStatus = { + installed: true, + installedVersion: '2.1.59', + binaryPath: '/usr/local/bin/claude', + latestVersion: '2.1.59', + updateAvailable: false, + }; + vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); + + await useStore.getState().fetchCliStatus(); + + expect(useStore.getState().cliStatus).toEqual(mockStatus); + }); + + it('handles API errors gracefully', async () => { + vi.mocked(api.cliInstaller.getStatus).mockRejectedValue(new Error('Network error')); + + await useStore.getState().fetchCliStatus(); + + // Should not throw, status remains null + expect(useStore.getState().cliStatus).toBeNull(); + }); + + it('detects update available', async () => { + const mockStatus: CliInstallationStatus = { + installed: true, + installedVersion: '2.1.34', + binaryPath: '/usr/local/bin/claude', + latestVersion: '2.1.59', + updateAvailable: true, + }; + vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); + + await useStore.getState().fetchCliStatus(); + + expect(useStore.getState().cliStatus?.updateAvailable).toBe(true); + }); + }); + + describe('installCli', () => { + it('sets state to checking and calls API', () => { + vi.mocked(api.cliInstaller.install).mockResolvedValue(undefined); + + useStore.getState().installCli(); + + expect(useStore.getState().cliInstallerState).toBe('checking'); + expect(useStore.getState().cliInstallerError).toBeNull(); + expect(api.cliInstaller.install).toHaveBeenCalled(); + }); + + it('resets download progress on new install', () => { + useStore.setState({ + cliDownloadProgress: 50, + cliDownloadTransferred: 100_000_000, + cliDownloadTotal: 200_000_000, + }); + + vi.mocked(api.cliInstaller.install).mockResolvedValue(undefined); + + useStore.getState().installCli(); + + expect(useStore.getState().cliDownloadProgress).toBe(0); + expect(useStore.getState().cliDownloadTransferred).toBe(0); + expect(useStore.getState().cliDownloadTotal).toBe(0); + }); + }); + + describe('progress event handling', () => { + it('updates download progress from events', () => { + useStore.setState({ + cliInstallerState: 'downloading', + cliDownloadProgress: 50, + cliDownloadTransferred: 100_000_000, + cliDownloadTotal: 200_000_000, + }); + + const state = useStore.getState(); + expect(state.cliInstallerState).toBe('downloading'); + expect(state.cliDownloadProgress).toBe(50); + }); + + it('tracks completed version', () => { + useStore.setState({ + cliInstallerState: 'completed', + cliCompletedVersion: '2.1.59', + }); + + expect(useStore.getState().cliCompletedVersion).toBe('2.1.59'); + }); + + it('tracks error state', () => { + useStore.setState({ + cliInstallerState: 'error', + cliInstallerError: 'SHA256 checksum mismatch', + }); + + expect(useStore.getState().cliInstallerState).toBe('error'); + expect(useStore.getState().cliInstallerError).toBe('SHA256 checksum mismatch'); + }); + }); +}); diff --git a/test/renderer/store/tabSlice.test.ts b/test/renderer/store/tabSlice.test.ts index f3733b7f..582006c3 100644 --- a/test/renderer/store/tabSlice.test.ts +++ b/test/renderer/store/tabSlice.test.ts @@ -102,12 +102,15 @@ describe('tabSlice', () => { expect(sessionTabs).toHaveLength(2); }); - it('should not deduplicate dashboard tabs', () => { + it('should reuse existing dashboard tab instead of creating duplicate', () => { store.getState().openDashboard(); + const firstTabId = store.getState().activeTabId; + store.getState().openDashboard(); - expect(store.getState().openTabs).toHaveLength(2); - expect(store.getState().openTabs.filter((t) => t.type === 'dashboard')).toHaveLength(2); + expect(store.getState().openTabs).toHaveLength(1); + expect(store.getState().openTabs.filter((t) => t.type === 'dashboard')).toHaveLength(1); + expect(store.getState().activeTabId).toBe(firstTabId); }); });