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:
iliya 2026-02-26 17:58:51 +02:00
parent 321673ff6d
commit c99a9cfc48
52 changed files with 3940 additions and 151 deletions

View file

@ -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');
}

View 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 };
}
}

View file

@ -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');

View file

@ -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 ---

View file

@ -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,

View file

@ -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.

View 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
}
}
}

View file

@ -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';

View file

@ -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'

View file

@ -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.

View file

@ -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;

View file

@ -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}`,

View file

@ -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,
}))
);

View file

@ -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,

View file

@ -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
// =============================================================================

View file

@ -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

View file

@ -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 () => {};
},
};
}

View 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 }}>&rsaquo;</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' }}>
&rarr; 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>
);
};

View file

@ -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

View file

@ -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" />

View 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} &rarr; 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>
);
};

View file

@ -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) => {

View file

@ -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),

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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)}
/>

View file

@ -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>
);

View file

@ -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();
}

View file

@ -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}
/>
)}

View file

@ -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) {

View file

@ -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}

View file

@ -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>

View file

@ -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' },
};

View 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,
};
}

View 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 };
}

View file

@ -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) => {

View file

@ -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) });

View 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);
});
},
});

View file

@ -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 {

View file

@ -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;

View file

@ -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.
*/

View file

@ -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;
}
// =============================================================================

View 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 mainrenderer 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;
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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);
});

View 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();
});
});
});

View 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');
});
});
});

View 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');
});
});
});

View file

@ -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);
});
});