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.
This commit is contained in:
parent
321673ff6d
commit
c99a9cfc48
52 changed files with 3940 additions and 151 deletions
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
79
src/main/ipc/cliInstaller.ts
Normal file
79
src/main/ipc/cliInstaller.ts
Normal file
|
|
@ -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<IpcResult<CliInstallationStatus>> {
|
||||
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<IpcResult<void>> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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<IpcResult<void>> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
479
src/main/services/infrastructure/CliInstallerService.ts
Normal file
479
src/main/services/infrastructure/CliInstallerService.ts
Normal file
|
|
@ -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<IncomingMessage> {
|
||||
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<string> {
|
||||
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<T>(url: string): Promise<T> {
|
||||
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<string, GcsPlatformEntry>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<CliInstallationStatus> {
|
||||
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<void> {
|
||||
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<GcsManifest>(`${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<string> {
|
||||
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<string>((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<void> {
|
||||
return new Promise<void>((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<void> {
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
} catch {
|
||||
// Ignore — file may already be cleaned up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -135,6 +135,14 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
|
|||
let cachedPath: string | null | undefined;
|
||||
|
||||
export class ClaudeBinaryResolver {
|
||||
/**
|
||||
* Clear the cached binary path.
|
||||
* Call after CLI install/update so the next resolve() picks up the new location.
|
||||
*/
|
||||
static clearCache(): void {
|
||||
cachedPath = undefined;
|
||||
}
|
||||
|
||||
static async resolve(): Promise<string | null> {
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <id> [--team <team>]',
|
||||
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
|
||||
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
|
||||
' node teamctl.js task briefing --for <member-name> [--team <team>]',
|
||||
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
||||
' node teamctl.js kanban clear <id> [--team <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 <id> <lead|user|clear>');
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await this.taskWriter.setNeedsClarification(teamName, taskId, value);
|
||||
}
|
||||
|
||||
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
||||
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}`,
|
||||
|
|
|
|||
|
|
@ -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 <taskId> lead --from "<your-name>"
|
||||
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 <taskId> clear --from "<your-name>"
|
||||
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 <taskId> 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 <taskId> 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,
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -190,6 +190,34 @@ export class TeamTaskWriter {
|
|||
});
|
||||
}
|
||||
|
||||
async setNeedsClarification(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
value: 'lead' | 'user' | null
|
||||
): Promise<void> {
|
||||
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<string, unknown>;
|
||||
if (value) {
|
||||
task.needsClarification = value;
|
||||
} else {
|
||||
delete task.needsClarification;
|
||||
}
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
});
|
||||
}
|
||||
|
||||
async addComment(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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<TeamTask[]>(TEAM_GET_DELETED_TASKS, teamName);
|
||||
},
|
||||
setTaskClarification: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
value: 'lead' | 'user' | null
|
||||
) => {
|
||||
return invokeIpcWithResult<void>(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<CliInstallationStatus> => {
|
||||
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
|
||||
},
|
||||
install: async (): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(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
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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<TeamTask[]> => {
|
||||
return [];
|
||||
},
|
||||
setTaskClarification: async (
|
||||
_teamName: string,
|
||||
_taskId: string,
|
||||
_value: 'lead' | 'user' | null
|
||||
): Promise<void> => {
|
||||
// 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<void> => {
|
||||
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
||||
},
|
||||
onProgress: (): (() => void) => {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
434
src/renderer/components/dashboard/CliStatusBanner.tsx
Normal file
434
src/renderer/components/dashboard/CliStatusBanner.tsx
Normal file
|
|
@ -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<BannerVariant, { border: string; bg: string }> = {
|
||||
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 (
|
||||
<p className="mt-1 truncate font-mono text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
/** Mini log panel shown during the installing phase */
|
||||
const LogPanel = ({ logs }: { logs: string[] }): React.JSX.Element | null => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
if (logs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="mt-2 max-h-24 overflow-y-auto rounded border font-mono text-xs leading-relaxed"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
padding: '6px 8px',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{logs.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
<span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>›</span> {line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" style={{ color: '#f87171' }} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: '#f87171' }}>
|
||||
{title}
|
||||
</p>
|
||||
{details.length > 0 && (
|
||||
<div
|
||||
className="mt-1.5 rounded border px-2 py-1.5 font-mono text-xs leading-relaxed"
|
||||
style={{
|
||||
borderColor: 'rgba(239, 68, 68, 0.2)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.04)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{details.map((line, i) => (
|
||||
<div key={i} className="break-all">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// 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 (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{
|
||||
borderColor: VARIANT_STYLES.error.border,
|
||||
backgroundColor: VARIANT_STYLES.error.bg,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 shrink-0" style={{ color: '#f87171' }} />
|
||||
<span className="text-sm" style={{ color: '#f87171' }}>
|
||||
Failed to check CLI status
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Still loading or initial render
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<Loader2
|
||||
className="size-4 shrink-0 animate-spin"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Checking Claude CLI...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Downloading ────────────────────────────────────────────────────────
|
||||
if (installerState === 'downloading') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 space-y-2 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Downloading Claude CLI...
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{downloadTotal > 0
|
||||
? `${formatBytes(downloadTransferred)} / ${formatBytes(downloadTotal)} (${downloadProgress}%)`
|
||||
: formatBytes(downloadTransferred)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-1.5 w-full overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||
>
|
||||
{downloadTotal > 0 ? (
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${downloadProgress}%`, backgroundColor: '#3b82f6' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-1/3 animate-pulse rounded-full"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Checking / Verifying ───────────────────────────────────────────────
|
||||
if (installerState === 'checking' || installerState === 'verifying') {
|
||||
const label =
|
||||
installerState === 'checking' ? 'Checking latest version...' : 'Verifying checksum...';
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<DetailLine text={installerDetail} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Installing (with log panel) ────────────────────────────────────────
|
||||
if (installerState === 'installing') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Installing Claude CLI...
|
||||
</span>
|
||||
</div>
|
||||
<LogPanel logs={installerLogs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Completed ──────────────────────────────────────────────────────────
|
||||
if (installerState === 'completed') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
|
||||
<span className="text-sm" style={{ color: '#4ade80' }}>
|
||||
Successfully installed Claude CLI v{completedVersion ?? 'latest'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error ──────────────────────────────────────────────────────────────
|
||||
if (installerState === 'error') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<ErrorDisplay error={installerError ?? 'Installation failed'} onRetry={handleInstall} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Idle state with status ─────────────────────────────────────────────
|
||||
if (!cliStatus) return null;
|
||||
|
||||
// Not installed — red error banner
|
||||
if (!cliStatus.installed) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 p-4"
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0" style={{ color: '#ef4444' }} />
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: '#f87171' }}>
|
||||
Claude CLI is required
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Claude CLI is required for team provisioning and session management. Install it to
|
||||
get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Install Claude CLI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Installed — show version, path, update info
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
|
||||
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
|
||||
</span>
|
||||
{cliStatus.updateAvailable && cliStatus.latestVersion && (
|
||||
<span className="text-xs" style={{ color: '#60a5fa' }}>
|
||||
→ v{cliStatus.latestVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cliStatus.binaryPath && (
|
||||
<button
|
||||
className="truncate font-mono text-xs hover:underline"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
title={`Reveal in file manager: ${cliStatus.binaryPath}`}
|
||||
onClick={() => void api.showInFolder(cliStatus.binaryPath!)}
|
||||
>
|
||||
{cliStatus.binaryPath}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{cliStatus.updateAvailable ? (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Update
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={cliStatusLoading}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<RefreshCw className={cliStatusLoading ? 'size-3.5 animate-spin' : 'size-3.5'} />
|
||||
{cliStatusLoading ? 'Checking...' : 'Check for Updates'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cliStatusError && !cliStatusLoading && (
|
||||
<p className="mt-2 text-xs" style={{ color: '#f87171' }}>
|
||||
Failed to check for updates. Check your network connection and try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 */}
|
||||
<div className="relative mx-auto max-w-5xl px-8 py-12">
|
||||
{/* CLI Status Banner */}
|
||||
<CliStatusBanner />
|
||||
|
||||
{/* Team select + Search */}
|
||||
<div className="mb-12 flex items-center justify-center gap-3">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide
|
|||
|
||||
import { SettingsSectionHeader } from '../components';
|
||||
|
||||
import { CliStatusSection } from './CliStatusSection';
|
||||
|
||||
interface AdvancedSectionProps {
|
||||
readonly saving: boolean;
|
||||
readonly onResetToDefaults: () => void;
|
||||
|
|
@ -144,6 +146,8 @@ export const AdvancedSection = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<CliStatusSection />
|
||||
|
||||
<SettingsSectionHeader title="About" />
|
||||
<div className="flex items-start gap-4 py-3">
|
||||
<img src={appIcon} alt="App Icon" className="size-10 rounded-lg" />
|
||||
|
|
|
|||
246
src/renderer/components/settings/sections/CliStatusSection.tsx
Normal file
246
src/renderer/components/settings/sections/CliStatusSection.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="mb-2">
|
||||
<SettingsSectionHeader title="Claude CLI" />
|
||||
<div className="space-y-3 py-2">
|
||||
{/* Loading status */}
|
||||
{!cliStatus && installerState === 'idle' && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking CLI...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status display */}
|
||||
{cliStatus && installerState === 'idle' && (
|
||||
<div className="space-y-2">
|
||||
{cliStatus.installed ? (
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
<Terminal
|
||||
className="size-4 shrink-0"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
/>
|
||||
<span>Claude CLI v{cliStatus.installedVersion ?? 'unknown'}</span>
|
||||
</div>
|
||||
{cliStatus.binaryPath && (
|
||||
<p
|
||||
className="ml-6 truncate text-xs"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
title={cliStatus.binaryPath}
|
||||
>
|
||||
{cliStatus.binaryPath}
|
||||
</p>
|
||||
)}
|
||||
{cliStatus.updateAvailable && cliStatus.latestVersion && (
|
||||
<div className="ml-6 flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: '#60a5fa' }}>
|
||||
v{cliStatus.installedVersion} → v{cliStatus.latestVersion}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<AlertTriangle className="size-4 shrink-0" style={{ color: '#fbbf24' }} />
|
||||
Claude CLI not installed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
{!cliStatus.installed && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Install Claude CLI
|
||||
</button>
|
||||
)}
|
||||
{cliStatus.installed && cliStatus.updateAvailable && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isBusy}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
{cliStatus.installed && !cliStatus.updateAvailable && (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check for Updates
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloading */}
|
||||
{installerState === 'downloading' && (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="flex items-center justify-between text-xs"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<span>Downloading...</span>
|
||||
<span>
|
||||
{downloadTotal > 0
|
||||
? `${formatBytes(downloadTransferred)} / ${formatBytes(downloadTotal)} (${downloadProgress}%)`
|
||||
: `${formatBytes(downloadTransferred)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-1.5 w-full overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||
>
|
||||
{downloadTotal > 0 ? (
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${downloadProgress}%`,
|
||||
backgroundColor: '#3b82f6',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-1/3 animate-pulse rounded-full"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checking */}
|
||||
{installerState === 'checking' && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking latest version...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verifying */}
|
||||
{installerState === 'verifying' && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Verifying checksum...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installing */}
|
||||
{installerState === 'installing' && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Installing...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed */}
|
||||
{installerState === 'completed' && (
|
||||
<div className="flex items-center gap-2 text-sm" style={{ color: '#4ade80' }}>
|
||||
<CheckCircle className="size-4" />
|
||||
Installed v{completedVersion ?? 'latest'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{installerState === 'error' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm" style={{ color: '#f87171' }}>
|
||||
<AlertTriangle className="size-4" />
|
||||
{installerError ?? 'Installation failed'}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<TaskDetailDialog
|
||||
|
|
@ -63,6 +76,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
members={activeMembers}
|
||||
onClose={closeGlobalTaskDetail}
|
||||
onOwnerChange={undefined}
|
||||
onViewChanges={handleViewChanges}
|
||||
headerExtra={
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export const TaskCommentsSection = ({
|
|||
|
||||
{comments.length > 0 ? (
|
||||
<div className="mb-3 space-y-2">
|
||||
{comments.map((comment) => (
|
||||
{[...comments].reverse().map((comment) => (
|
||||
<div key={comment.id} className="group p-2.5">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
Clock,
|
||||
FileCode,
|
||||
FileDiff,
|
||||
HelpCircle,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
|
|
@ -117,10 +118,15 @@ export const TaskDetailDialog = ({
|
|||
|
||||
// Lazy-load task changes when dialog is open and task is completed
|
||||
const isTaskCompleted = currentTask?.status === 'completed';
|
||||
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
|
||||
const activeChangeSet = useStore((s) => s.activeChangeSet);
|
||||
const changeSetLoading = useStore((s) => s.changeSetLoading);
|
||||
const fetchTaskChanges = useStore((s) => s.fetchTaskChanges);
|
||||
|
||||
// Use the lightweight cache to know if changes exist before full data loads
|
||||
const changesCacheKey = currentTask ? `${teamName}:${currentTask.id}` : '';
|
||||
const taskKnownHasChanges = useStore((s) => s.taskHasChanges[changesCacheKey]) === true;
|
||||
|
||||
const taskChangesFiles = useMemo(() => {
|
||||
if (!activeChangeSet || !currentTask) return null;
|
||||
if ('taskId' in activeChangeSet && activeChangeSet.taskId === currentTask.id) {
|
||||
|
|
@ -280,6 +286,35 @@ export const TaskDetailDialog = ({
|
|||
: null}
|
||||
</div>
|
||||
|
||||
{/* Clarification banner */}
|
||||
{currentTask.needsClarification ? (
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-md px-3 py-2 text-xs ${
|
||||
currentTask.needsClarification === 'user'
|
||||
? 'border border-red-500/20 bg-red-500/10 text-red-400'
|
||||
: 'border border-blue-500/20 bg-blue-500/10 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HelpCircle size={14} />
|
||||
{currentTask.needsClarification === 'user'
|
||||
? 'Awaiting clarification from you'
|
||||
: 'Awaiting clarification from team lead'}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void setTaskNeedsClarification(teamName, currentTask.id, null);
|
||||
}}
|
||||
>
|
||||
Mark resolved
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Description */}
|
||||
<CollapsibleTeamSection title="Description" icon={<AlignLeft size={14} />} defaultOpen>
|
||||
{currentTask.description ? (
|
||||
|
|
@ -291,27 +326,15 @@ export const TaskDetailDialog = ({
|
|||
)}
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
{/* Execution Logs — sessions that reference this task */}
|
||||
<CollapsibleTeamSection title="Execution Logs" icon={<ScrollText size={14} />} defaultOpen>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<MemberLogsTab
|
||||
teamName={teamName}
|
||||
taskId={currentTask.id}
|
||||
taskOwner={currentTask.owner}
|
||||
taskStatus={currentTask.status}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
{/* Changes */}
|
||||
{isTaskCompleted && onViewChanges ? (
|
||||
<CollapsibleTeamSection
|
||||
title="Changes"
|
||||
icon={<FileDiff size={14} />}
|
||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
||||
defaultOpen={!!taskChangesFiles && taskChangesFiles.length > 0}
|
||||
defaultOpen={taskKnownHasChanges}
|
||||
>
|
||||
{changeSetLoading && !taskChangesFiles ? (
|
||||
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading changes...
|
||||
|
|
@ -329,7 +352,7 @@ export const TaskDetailDialog = ({
|
|||
}}
|
||||
>
|
||||
<FileCode size={14} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[var(--color-text-secondary)]">
|
||||
<span className="truncate font-mono text-[var(--color-text-secondary)]">
|
||||
{file.relativePath}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
|
|
@ -349,6 +372,18 @@ export const TaskDetailDialog = ({
|
|||
</CollapsibleTeamSection>
|
||||
) : null}
|
||||
|
||||
{/* Execution Logs — sessions that reference this task */}
|
||||
<CollapsibleTeamSection title="Execution Logs" icon={<ScrollText size={14} />} defaultOpen>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<MemberLogsTab
|
||||
teamName={teamName}
|
||||
taskId={currentTask.id}
|
||||
taskOwner={currentTask.owner}
|
||||
taskStatus={currentTask.status}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
<div className="mb-3 space-y-2">
|
||||
{/* Dependencies */}
|
||||
{blockedByIds.length > 0 ? (
|
||||
|
|
@ -483,6 +518,13 @@ export const TaskDetailDialog = ({
|
|||
}
|
||||
defaultOpen
|
||||
>
|
||||
<TaskCommentInput
|
||||
teamName={teamName}
|
||||
taskId={currentTask.id}
|
||||
members={members}
|
||||
replyTo={effectiveReplyTo}
|
||||
onClearReply={clearReply}
|
||||
/>
|
||||
<TaskCommentsSection
|
||||
teamName={teamName}
|
||||
taskId={currentTask.id}
|
||||
|
|
@ -494,15 +536,6 @@ export const TaskDetailDialog = ({
|
|||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
{/* Comment input — always visible outside the collapsible section */}
|
||||
<TaskCommentInput
|
||||
teamName={teamName}
|
||||
taskId={currentTask.id}
|
||||
members={members}
|
||||
replyTo={effectiveReplyTo}
|
||||
onClearReply={clearReply}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
{onDeleteTask && currentTask ? (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
ArrowRightFromLine,
|
||||
CheckCircle2,
|
||||
FileCode,
|
||||
HelpCircle,
|
||||
Play,
|
||||
Trash2,
|
||||
XCircle,
|
||||
|
|
@ -190,6 +191,18 @@ export const KanbanTaskCard = ({
|
|||
#{task.id}
|
||||
</Badge>
|
||||
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
|
||||
{task.needsClarification ? (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
task.needsClarification === 'user'
|
||||
? 'bg-red-500/15 text-red-400'
|
||||
: 'bg-blue-500/15 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
<HelpCircle size={10} />
|
||||
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
||||
</span>
|
||||
) : null}
|
||||
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{task.subject}
|
||||
</h5>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
|
|||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
|
||||
|
||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||
|
|
@ -78,6 +79,14 @@ export const MemberDetailDialog = ({
|
|||
|
||||
const [activeTab, setActiveTab] = useState<MemberDetailTab>('tasks');
|
||||
|
||||
const {
|
||||
stats: memberStats,
|
||||
loading: statsLoading,
|
||||
error: statsError,
|
||||
} = useMemberStats(teamName, member?.name ?? null);
|
||||
|
||||
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
|
||||
|
||||
if (!member) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -102,7 +111,9 @@ export const MemberDetailDialog = ({
|
|||
inProgressTasks={inProgressTasks}
|
||||
completedTasks={completedTasks}
|
||||
messageCount={memberMessages.length}
|
||||
lastActiveAt={member.lastActiveAt}
|
||||
totalTokens={totalTokens}
|
||||
statsLoading={statsLoading}
|
||||
statsComputedAt={memberStats?.computedAt}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -148,6 +159,9 @@ export const MemberDetailDialog = ({
|
|||
<MemberStatsTab
|
||||
teamName={teamName}
|
||||
memberName={member.name}
|
||||
prefetchedStats={memberStats}
|
||||
prefetchedLoading={statsLoading}
|
||||
prefetchedError={statsError}
|
||||
onFileClick={(filePath) => onViewMemberChanges?.(member.name, filePath)}
|
||||
onShowAllFiles={() => onViewMemberChanges?.(member.name)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="grid min-w-0 flex-1 grid-cols-4 gap-1.5">
|
||||
|
|
@ -76,9 +84,10 @@ export const MemberDetailStats = ({
|
|||
onClick={onTabChange ? () => onTabChange('messages') : undefined}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Activity"
|
||||
value={lastActive}
|
||||
onClick={onTabChange ? () => onTabChange('logs') : undefined}
|
||||
label="Tokens"
|
||||
value={tokensValue}
|
||||
sub={tokensSub}
|
||||
onClick={onTabChange ? () => onTabChange('stats') : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<MemberFullStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const usePrefetched = prefetchedStats !== undefined;
|
||||
|
||||
const [localStats, setLocalStats] = useState<MemberFullStats | null>(null);
|
||||
const [localLoading, setLocalLoading] = useState(!usePrefetched);
|
||||
const [localError, setLocalError] = useState<string | null>(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 = ({
|
|||
<FileCode size={10} className="shrink-0 opacity-50" />
|
||||
<span className="min-w-0 truncate">{basename}</span>
|
||||
{fStats && (fStats.added > 0 || fStats.removed > 0) && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1 font-mono text-[10px]">
|
||||
<span className="flex shrink-0 items-center gap-1 font-mono text-[10px]">
|
||||
{fStats.added > 0 && <span className="text-emerald-400">+{fStats.added}</span>}
|
||||
{fStats.removed > 0 && <span className="text-red-400">-{fStats.removed}</span>}
|
||||
</span>
|
||||
|
|
@ -277,16 +296,3 @@ const StatsFooter = ({ stats }: { stats: MemberFullStats }): React.JSX.Element =
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>(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<EditorView>());
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<string, HunkDecision>
|
||||
hunkDecisions: Record<string, HunkDecision>,
|
||||
fileDecisions: Record<string, HunkDecision>,
|
||||
fileChunkCounts: Record<string, number>
|
||||
): 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<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
fileChunkCounts: Record<string, number>;
|
||||
viewedSet?: Set<string>;
|
||||
collapsedFolders: Set<string>;
|
||||
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 (
|
||||
<button
|
||||
data-tree-file={node.file.filePath}
|
||||
|
|
@ -240,6 +253,8 @@ const TreeItem = ({
|
|||
onSelectFile={onSelectFile}
|
||||
depth={depth + 1}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
fileChunkCounts={fileChunkCounts}
|
||||
viewedSet={viewedSet}
|
||||
collapsedFolders={collapsedFolders}
|
||||
onToggleFolder={onToggleFolder}
|
||||
|
|
@ -287,6 +302,8 @@ export const ReviewFileTree = ({
|
|||
activeFilePath,
|
||||
}: ReviewFileTreeProps): JSX.Element => {
|
||||
const hunkDecisions = useStore((state) => state.hunkDecisions);
|
||||
const fileDecisions = useStore((state) => state.fileDecisions);
|
||||
const fileChunkCounts = useStore((state) => state.fileChunkCounts);
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => new Set());
|
||||
|
||||
|
|
@ -347,6 +364,8 @@ export const ReviewFileTree = ({
|
|||
onSelectFile={onSelectFile}
|
||||
depth={0}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
fileChunkCounts={fileChunkCounts}
|
||||
viewedSet={viewedSet}
|
||||
collapsedFolders={collapsedFolders}
|
||||
onToggleFolder={toggleFolder}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Check, Eye, EyeOff, GitMerge, Loader2, Pencil, X } from 'lucide-react';
|
||||
import { Check, Eye, EyeOff, GitMerge, Loader2, Pencil, Undo2, X } from 'lucide-react';
|
||||
|
||||
import type { ChangeStats } from '@shared/types';
|
||||
|
||||
|
|
@ -19,6 +19,8 @@ interface ReviewToolbarProps {
|
|||
onApply: () => void;
|
||||
onCollapseUnchangedChange: (collapse: boolean) => void;
|
||||
editedCount?: number;
|
||||
canUndo?: boolean;
|
||||
onUndo?: () => void;
|
||||
}
|
||||
|
||||
export const ReviewToolbar = ({
|
||||
|
|
@ -34,6 +36,8 @@ export const ReviewToolbar = ({
|
|||
onCollapseUnchangedChange: _onCollapseUnchangedChange,
|
||||
instantApply = false,
|
||||
editedCount = 0,
|
||||
canUndo = false,
|
||||
onUndo,
|
||||
}: ReviewToolbarProps): React.ReactElement => {
|
||||
const hasRejected = stats.rejected > 0;
|
||||
const canApply = hasRejected && !applying;
|
||||
|
|
@ -138,32 +142,54 @@ export const ReviewToolbar = ({
|
|||
|
||||
{editedCount > 0 && <div className="h-4 w-px bg-border" />}
|
||||
|
||||
{/* Actions */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onAcceptAll}
|
||||
className="flex items-center gap-1 rounded bg-green-500/15 px-2.5 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
Accept All
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Accept all changes across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
{canUndo && onUndo && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
className="flex items-center gap-1 rounded bg-zinc-500/15 px-2.5 py-1 text-xs text-zinc-300 transition-colors hover:bg-zinc-500/25"
|
||||
>
|
||||
<Undo2 className="size-3" />
|
||||
Undo
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Undo last bulk operation (
|
||||
{/Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘Z' : 'Ctrl+Z'})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRejectAll}
|
||||
className="flex items-center gap-1 rounded bg-red-500/15 px-2.5 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/25"
|
||||
>
|
||||
<X className="size-3" />
|
||||
Reject All
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Reject all changes across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Actions — hidden when all hunks are already decided */}
|
||||
{stats.pending > 0 && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onAcceptAll}
|
||||
className="flex items-center gap-1 rounded bg-green-500/15 px-2.5 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
Accept All
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Accept all changes across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRejectAll}
|
||||
className="flex items-center gap-1 rounded bg-red-500/15 px-2.5 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/25"
|
||||
>
|
||||
<X className="size-3" />
|
||||
Reject All
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Reject all changes across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!instantApply && (
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
|
|||
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' },
|
||||
};
|
||||
|
|
|
|||
67
src/renderer/hooks/useCliInstaller.ts
Normal file
67
src/renderer/hooks/useCliInstaller.ts
Normal file
|
|
@ -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<void>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
45
src/renderer/hooks/useMemberStats.ts
Normal file
45
src/renderer/hooks/useMemberStats.ts
Normal file
|
|
@ -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<MemberFullStats | null>(null);
|
||||
const [loading, setLoading] = useState(memberName !== null);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
|
|
@ -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<AppState>()((...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<typeof setTimeout> | 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) => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ import type { StateCreator } from 'zustand';
|
|||
|
||||
const logger = createLogger('changeReviewSlice');
|
||||
|
||||
/** Snapshot of review decisions for undo support */
|
||||
interface DecisionSnapshot {
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
}
|
||||
|
||||
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<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
/** Actual CodeMirror chunk count per file (may differ from snippets.length) */
|
||||
fileChunkCounts: Record<string, number>;
|
||||
/** Undo stack for bulk review operations (Accept All / Reject All) */
|
||||
reviewUndoStack: DecisionSnapshot[];
|
||||
fileContents: Record<string, FileChangeWithContent>;
|
||||
fileContentsLoading: Record<string, boolean>;
|
||||
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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, HunkDecision>,
|
||||
totalChunks: number
|
||||
): number {
|
||||
const decided = new Set<number>();
|
||||
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<string, number>
|
||||
): number {
|
||||
return fileChunkCounts[filePath] ?? snippetsLength;
|
||||
}
|
||||
|
||||
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
|
||||
set,
|
||||
get
|
||||
|
|
@ -123,6 +178,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
// Phase 2 initial state
|
||||
hunkDecisions: {},
|
||||
fileDecisions: {},
|
||||
fileChunkCounts: {},
|
||||
reviewUndoStack: [],
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
collapseUnchanged: true,
|
||||
|
|
@ -180,6 +237,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
selectedReviewFilePath: null,
|
||||
hunkDecisions: {},
|
||||
fileDecisions: {},
|
||||
fileChunkCounts: {},
|
||||
reviewUndoStack: [],
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
applyError: null,
|
||||
|
|
@ -194,6 +253,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
changeSetLoading: false,
|
||||
changeSetError: null,
|
||||
selectedReviewFilePath: null,
|
||||
fileChunkCounts: {},
|
||||
reviewUndoStack: [],
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
applyError: null,
|
||||
|
|
@ -210,6 +271,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
selectedReviewFilePath: null,
|
||||
hunkDecisions: {},
|
||||
fileDecisions: {},
|
||||
fileChunkCounts: {},
|
||||
reviewUndoStack: [],
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
applyError: null,
|
||||
|
|
@ -267,9 +330,17 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
// ── Phase 2 actions ──
|
||||
|
||||
setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => {
|
||||
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<AppState, [], [], ChangeRevie
|
|||
}));
|
||||
},
|
||||
|
||||
setFileChunkCount: (filePath: string, count: number) => {
|
||||
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<AppState, [], [], ChangeRevie
|
|||
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}`] = 'rejected';
|
||||
}
|
||||
set({
|
||||
|
|
@ -318,7 +423,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
|
||||
for (const file of state.activeChangeSet.files) {
|
||||
newFileDecisions[file.filePath] = 'accepted';
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
|
||||
for (let i = 0; i < count; i++) {
|
||||
newHunkDecisions[`${file.filePath}:${i}`] = 'accepted';
|
||||
}
|
||||
}
|
||||
|
|
@ -334,7 +440,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
|
||||
for (const file of state.activeChangeSet.files) {
|
||||
newFileDecisions[file.filePath] = 'rejected';
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
|
||||
for (let i = 0; i < count; i++) {
|
||||
newHunkDecisions[`${file.filePath}:${i}`] = 'rejected';
|
||||
}
|
||||
}
|
||||
|
|
@ -546,9 +653,12 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
try {
|
||||
await api.review.saveEditedFile(filePath, content);
|
||||
set((s) => {
|
||||
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) });
|
||||
|
|
|
|||
96
src/renderer/store/slices/cliInstallerSlice.ts
Normal file
96
src/renderer/store/slices/cliInstallerSlice.ts
Normal file
|
|
@ -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<void>;
|
||||
installCli: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Slice Creator
|
||||
// =============================================================================
|
||||
|
||||
export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstallerSlice> = (
|
||||
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);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -28,6 +28,34 @@ import type {
|
|||
} from '@shared/types';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
// --- Clarification notification tracking ---
|
||||
const notifiedClarificationTaskKeys = new Set<string>();
|
||||
|
||||
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<void>;
|
||||
setTaskNeedsClarification: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
value: 'lead' | 'user' | null
|
||||
) => Promise<void>;
|
||||
deletedTasks: TeamTask[];
|
||||
deletedTasksLoading: boolean;
|
||||
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
|
|
@ -142,6 +178,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||
setTaskClarification: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
value: 'lead' | 'user' | null
|
||||
) => Promise<void>;
|
||||
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
||||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||
killProcess: (teamName: string, pid: number) => Promise<void>;
|
||||
|
|
@ -585,6 +591,7 @@ export interface ElectronAPI {
|
|||
projectRoot?: string,
|
||||
userSelectedFromDialog?: boolean
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
showInFolder: (filePath: string) => Promise<void>;
|
||||
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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
82
src/shared/types/cliInstaller.ts
Normal file
82
src/shared/types/cliInstaller.ts
Normal file
|
|
@ -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<CliInstallationStatus>;
|
||||
/** Start install/update flow. Progress sent via onProgress events. */
|
||||
install: () => Promise<void>;
|
||||
/** Subscribe to progress events. Returns cleanup function. */
|
||||
onProgress: (cb: (event: unknown, data: CliInstallerProgress) => void) => () => void;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
249
test/main/services/infrastructure/CliInstallerService.test.ts
Normal file
249
test/main/services/infrastructure/CliInstallerService.test.ts
Normal file
|
|
@ -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<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
execFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
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<typeof import('https')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('http', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('http')>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
984
test/main/services/team/teamctl.test.ts
Normal file
984
test/main/services/team/teamctl.test.ts
Normal file
|
|
@ -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<string, unknown>): 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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<typeof import('@main/utils/pathDecoder')>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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<string, Record<string, unknown>>;
|
||||
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<string, Record<string, unknown>>;
|
||||
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<string, Record<string, unknown>>;
|
||||
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<string, Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
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<string, Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
// 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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
185
test/renderer/store/cliInstallerSlice.test.ts
Normal file
185
test/renderer/store/cliInstallerSlice.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue