mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 11:03:08 +00:00
Merge pull request #776 from gsxdsm/fix/claude-weekly-usage
feat: Add error handling to auto-mode facade and implement followUp f…
This commit is contained in:
@@ -13,6 +13,7 @@ import { GlobalAutoModeService } from './global-service.js';
|
|||||||
import { AutoModeServiceFacade } from './facade.js';
|
import { AutoModeServiceFacade } from './facade.js';
|
||||||
import type { SettingsService } from '../settings-service.js';
|
import type { SettingsService } from '../settings-service.js';
|
||||||
import type { FeatureLoader } from '../feature-loader.js';
|
import type { FeatureLoader } from '../feature-loader.js';
|
||||||
|
import type { ClaudeUsageService } from '../claude-usage-service.js';
|
||||||
import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js';
|
import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +28,8 @@ export class AutoModeServiceCompat {
|
|||||||
constructor(
|
constructor(
|
||||||
events: EventEmitter,
|
events: EventEmitter,
|
||||||
settingsService: SettingsService | null,
|
settingsService: SettingsService | null,
|
||||||
featureLoader: FeatureLoader
|
featureLoader: FeatureLoader,
|
||||||
|
claudeUsageService?: ClaudeUsageService | null
|
||||||
) {
|
) {
|
||||||
this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader);
|
this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader);
|
||||||
const sharedServices = this.globalService.getSharedServices();
|
const sharedServices = this.globalService.getSharedServices();
|
||||||
@@ -37,6 +39,7 @@ export class AutoModeServiceCompat {
|
|||||||
settingsService,
|
settingsService,
|
||||||
featureLoader,
|
featureLoader,
|
||||||
sharedServices,
|
sharedServices,
|
||||||
|
claudeUsageService: claudeUsageService ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type { SettingsService } from '../settings-service.js';
|
|||||||
import type { EventEmitter } from '../../lib/events.js';
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import type {
|
import type {
|
||||||
FacadeOptions,
|
FacadeOptions,
|
||||||
|
FacadeError,
|
||||||
AutoModeStatus,
|
AutoModeStatus,
|
||||||
ProjectAutoModeStatus,
|
ProjectAutoModeStatus,
|
||||||
WorktreeCapacityInfo,
|
WorktreeCapacityInfo,
|
||||||
@@ -89,6 +90,45 @@ export class AutoModeServiceFacade {
|
|||||||
private readonly settingsService: SettingsService | null
|
private readonly settingsService: SettingsService | null
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify and log an error at the facade boundary.
|
||||||
|
* Emits an error event to the UI so failures are surfaced to the user.
|
||||||
|
*
|
||||||
|
* @param error - The caught error
|
||||||
|
* @param method - The facade method name where the error occurred
|
||||||
|
* @param featureId - Optional feature ID for context
|
||||||
|
* @returns The classified FacadeError for structured consumption
|
||||||
|
*/
|
||||||
|
private handleFacadeError(error: unknown, method: string, featureId?: string): FacadeError {
|
||||||
|
const errorInfo = classifyError(error);
|
||||||
|
|
||||||
|
// Log at the facade boundary for debugging
|
||||||
|
logger.error(
|
||||||
|
`[${method}] ${featureId ? `Feature ${featureId}: ` : ''}${errorInfo.message}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit error event to UI unless it's an abort/cancellation
|
||||||
|
if (!errorInfo.isAbort && !errorInfo.isCancellation) {
|
||||||
|
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||||
|
featureId: featureId ?? null,
|
||||||
|
featureName: undefined,
|
||||||
|
branchName: null,
|
||||||
|
error: errorInfo.message,
|
||||||
|
errorType: errorInfo.type,
|
||||||
|
projectPath: this.projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
errorType: errorInfo.type,
|
||||||
|
message: errorInfo.message,
|
||||||
|
featureId,
|
||||||
|
projectPath: this.projectPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new AutoModeServiceFacade instance for a specific project.
|
* Create a new AutoModeServiceFacade instance for a specific project.
|
||||||
*
|
*
|
||||||
@@ -447,11 +487,16 @@ export class AutoModeServiceFacade {
|
|||||||
* @param maxConcurrency - Maximum concurrent features
|
* @param maxConcurrency - Maximum concurrent features
|
||||||
*/
|
*/
|
||||||
async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise<number> {
|
async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise<number> {
|
||||||
return this.autoLoopCoordinator.startAutoLoopForProject(
|
try {
|
||||||
this.projectPath,
|
return await this.autoLoopCoordinator.startAutoLoopForProject(
|
||||||
branchName,
|
this.projectPath,
|
||||||
maxConcurrency
|
branchName,
|
||||||
);
|
maxConcurrency
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleFacadeError(error, 'startAutoLoop');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -459,7 +504,12 @@ export class AutoModeServiceFacade {
|
|||||||
* @param branchName - The branch name, or null for main worktree
|
* @param branchName - The branch name, or null for main worktree
|
||||||
*/
|
*/
|
||||||
async stopAutoLoop(branchName: string | null = null): Promise<number> {
|
async stopAutoLoop(branchName: string | null = null): Promise<number> {
|
||||||
return this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName);
|
try {
|
||||||
|
return await this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleFacadeError(error, 'stopAutoLoop');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -500,14 +550,19 @@ export class AutoModeServiceFacade {
|
|||||||
_calledInternally?: boolean;
|
_calledInternally?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.executionService.executeFeature(
|
try {
|
||||||
this.projectPath,
|
return await this.executionService.executeFeature(
|
||||||
featureId,
|
this.projectPath,
|
||||||
useWorktrees,
|
featureId,
|
||||||
isAutoMode,
|
useWorktrees,
|
||||||
providedWorktreePath,
|
isAutoMode,
|
||||||
options
|
providedWorktreePath,
|
||||||
);
|
options
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleFacadeError(error, 'executeFeature', featureId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -515,9 +570,14 @@ export class AutoModeServiceFacade {
|
|||||||
* @param featureId - ID of the feature to stop
|
* @param featureId - ID of the feature to stop
|
||||||
*/
|
*/
|
||||||
async stopFeature(featureId: string): Promise<boolean> {
|
async stopFeature(featureId: string): Promise<boolean> {
|
||||||
// Cancel any pending plan approval for this feature
|
try {
|
||||||
this.cancelPlanApproval(featureId);
|
// Cancel any pending plan approval for this feature
|
||||||
return this.executionService.stopFeature(featureId);
|
this.cancelPlanApproval(featureId);
|
||||||
|
return await this.executionService.stopFeature(featureId);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleFacadeError(error, 'stopFeature', featureId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -552,23 +612,54 @@ export class AutoModeServiceFacade {
|
|||||||
imagePaths?: string[],
|
imagePaths?: string[],
|
||||||
useWorktrees = true
|
useWorktrees = true
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Stub: acquire concurrency slot then immediately throw.
|
|
||||||
// Heavy I/O (loadFeature, worktree resolution, context reading, prompt building)
|
|
||||||
// is deferred to the real AutoModeService.followUpFeature implementation.
|
|
||||||
validateWorkingDirectory(this.projectPath);
|
validateWorkingDirectory(this.projectPath);
|
||||||
|
|
||||||
const runningEntry = this.concurrencyManager.acquire({
|
|
||||||
featureId,
|
|
||||||
projectPath: this.projectPath,
|
|
||||||
isAutoMode: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// NOTE: Facade does not have runAgent - this method requires AutoModeService
|
// Load feature to build the prompt context
|
||||||
// Do NOT emit start events before throwing to prevent false start events
|
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
|
||||||
throw new Error(
|
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||||
'followUpFeature not fully implemented in facade - use AutoModeService.followUpFeature instead'
|
|
||||||
);
|
// Read previous agent output as context
|
||||||
|
const featureDir = getFeatureDir(this.projectPath, featureId);
|
||||||
|
let previousContext = '';
|
||||||
|
try {
|
||||||
|
previousContext = (await secureFs.readFile(
|
||||||
|
path.join(featureDir, 'agent-output.md'),
|
||||||
|
'utf-8'
|
||||||
|
)) as string;
|
||||||
|
} catch {
|
||||||
|
// No previous context available - that's OK
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the feature prompt section
|
||||||
|
const featurePrompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled Feature'}\n**Description:** ${feature.description}\n`;
|
||||||
|
|
||||||
|
// Get the follow-up prompt template and build the continuation prompt
|
||||||
|
const prompts = await getPromptCustomization(this.settingsService, '[Facade]');
|
||||||
|
let continuationPrompt = prompts.autoMode.followUpPromptTemplate;
|
||||||
|
continuationPrompt = continuationPrompt
|
||||||
|
.replace(/\{\{featurePrompt\}\}/g, featurePrompt)
|
||||||
|
.replace(/\{\{previousContext\}\}/g, previousContext)
|
||||||
|
.replace(/\{\{followUpInstructions\}\}/g, prompt);
|
||||||
|
|
||||||
|
// Store image paths on the feature so executeFeature can pick them up
|
||||||
|
if (imagePaths && imagePaths.length > 0) {
|
||||||
|
feature.imagePaths = imagePaths.map((p) => ({
|
||||||
|
path: p,
|
||||||
|
filename: p.split('/').pop() || p,
|
||||||
|
mimeType: 'image/*',
|
||||||
|
}));
|
||||||
|
await this.featureStateManager.updateFeatureStatus(
|
||||||
|
this.projectPath,
|
||||||
|
featureId,
|
||||||
|
feature.status || 'in_progress'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to executeFeature with the built continuation prompt
|
||||||
|
await this.executeFeature(featureId, useWorktrees, false, undefined, {
|
||||||
|
continuationPrompt,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorInfo = classifyError(error);
|
const errorInfo = classifyError(error);
|
||||||
if (!errorInfo.isAbort) {
|
if (!errorInfo.isAbort) {
|
||||||
@@ -582,8 +673,6 @@ export class AutoModeServiceFacade {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
this.concurrencyManager.release(featureId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export type {
|
|||||||
WorktreeCapacityInfo,
|
WorktreeCapacityInfo,
|
||||||
RunningAgentInfo,
|
RunningAgentInfo,
|
||||||
OrphanedFeatureInfo,
|
OrphanedFeatureInfo,
|
||||||
|
FacadeError,
|
||||||
GlobalAutoModeOperations,
|
GlobalAutoModeOperations,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { ConcurrencyManager } from '../concurrency-manager.js';
|
|||||||
import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js';
|
import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js';
|
||||||
import type { WorktreeResolver } from '../worktree-resolver.js';
|
import type { WorktreeResolver } from '../worktree-resolver.js';
|
||||||
import type { TypedEventBus } from '../typed-event-bus.js';
|
import type { TypedEventBus } from '../typed-event-bus.js';
|
||||||
|
import type { ClaudeUsageService } from '../claude-usage-service.js';
|
||||||
|
|
||||||
// Re-export types from extracted services for route consumption
|
// Re-export types from extracted services for route consumption
|
||||||
export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js';
|
export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js';
|
||||||
@@ -55,6 +56,8 @@ export interface FacadeOptions {
|
|||||||
featureLoader?: FeatureLoader;
|
featureLoader?: FeatureLoader;
|
||||||
/** Shared services for state sharing across facades (optional) */
|
/** Shared services for state sharing across facades (optional) */
|
||||||
sharedServices?: SharedServices;
|
sharedServices?: SharedServices;
|
||||||
|
/** ClaudeUsageService for checking usage limits before picking up features (optional) */
|
||||||
|
claudeUsageService?: ClaudeUsageService | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,6 +113,23 @@ export interface OrphanedFeatureInfo {
|
|||||||
missingBranch: string;
|
missingBranch: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured error object returned/emitted by facade methods.
|
||||||
|
* Provides consistent error information for callers and UI consumers.
|
||||||
|
*/
|
||||||
|
export interface FacadeError {
|
||||||
|
/** The facade method where the error originated */
|
||||||
|
method: string;
|
||||||
|
/** Classified error type from the error handler */
|
||||||
|
errorType: import('@automaker/types').ErrorType;
|
||||||
|
/** Human-readable error message */
|
||||||
|
message: string;
|
||||||
|
/** Feature ID if the error is associated with a specific feature */
|
||||||
|
featureId?: string;
|
||||||
|
/** Project path where the error occurred */
|
||||||
|
projectPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface describing global auto-mode operations (not project-specific).
|
* Interface describing global auto-mode operations (not project-specific).
|
||||||
* Used by routes that need global state access.
|
* Used by routes that need global state access.
|
||||||
|
|||||||
@@ -294,7 +294,16 @@ export class ClaudeUsageService {
|
|||||||
this.killPtyProcess(ptyProcess);
|
this.killPtyProcess(ptyProcess);
|
||||||
}
|
}
|
||||||
// Don't fail if we have data - return it instead
|
// Don't fail if we have data - return it instead
|
||||||
if (output.includes('Current session')) {
|
// Check cleaned output since raw output has ANSI codes between words
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const cleanedForCheck = output
|
||||||
|
.replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
||||||
|
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
|
||||||
|
if (
|
||||||
|
cleanedForCheck.includes('Current session') ||
|
||||||
|
cleanedForCheck.includes('% used') ||
|
||||||
|
cleanedForCheck.includes('% left')
|
||||||
|
) {
|
||||||
resolve(output);
|
resolve(output);
|
||||||
} else if (hasSeenTrustPrompt) {
|
} else if (hasSeenTrustPrompt) {
|
||||||
// Trust prompt was shown but we couldn't auto-approve it
|
// Trust prompt was shown but we couldn't auto-approve it
|
||||||
@@ -320,8 +329,13 @@ export class ClaudeUsageService {
|
|||||||
output += data;
|
output += data;
|
||||||
|
|
||||||
// Strip ANSI codes for easier matching
|
// Strip ANSI codes for easier matching
|
||||||
|
// Convert cursor forward (ESC[nC) to spaces first to preserve word boundaries,
|
||||||
|
// then strip remaining ANSI sequences. Without this, the Claude CLI TUI output
|
||||||
|
// like "Current week (all models)" becomes "Currentweek(allmodels)".
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
const cleanOutput = output
|
||||||
|
.replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10)))
|
||||||
|
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '');
|
||||||
|
|
||||||
// Check for specific authentication/permission errors
|
// Check for specific authentication/permission errors
|
||||||
// Must be very specific to avoid false positives from garbled terminal encoding
|
// Must be very specific to avoid false positives from garbled terminal encoding
|
||||||
@@ -356,7 +370,8 @@ export class ClaudeUsageService {
|
|||||||
const hasUsageIndicators =
|
const hasUsageIndicators =
|
||||||
cleanOutput.includes('Current session') ||
|
cleanOutput.includes('Current session') ||
|
||||||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
|
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
|
||||||
// Additional patterns for winpty - look for percentage patterns
|
// Look for percentage patterns - allow optional whitespace between % and left/used
|
||||||
|
// since cursor movement codes may or may not create spaces after stripping
|
||||||
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
|
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
|
||||||
cleanOutput.includes('Resets in') ||
|
cleanOutput.includes('Resets in') ||
|
||||||
cleanOutput.includes('Current week');
|
cleanOutput.includes('Current week');
|
||||||
@@ -382,12 +397,15 @@ export class ClaudeUsageService {
|
|||||||
// Handle Trust Dialog - multiple variants:
|
// Handle Trust Dialog - multiple variants:
|
||||||
// - "Do you want to work in this folder?"
|
// - "Do you want to work in this folder?"
|
||||||
// - "Ready to code here?" / "I'll need permission to work with your files"
|
// - "Ready to code here?" / "I'll need permission to work with your files"
|
||||||
|
// - "Quick safety check" / "Yes, I trust this folder"
|
||||||
// Since we are running in cwd (project dir), it is safe to approve.
|
// Since we are running in cwd (project dir), it is safe to approve.
|
||||||
if (
|
if (
|
||||||
!hasApprovedTrust &&
|
!hasApprovedTrust &&
|
||||||
(cleanOutput.includes('Do you want to work in this folder?') ||
|
(cleanOutput.includes('Do you want to work in this folder?') ||
|
||||||
cleanOutput.includes('Ready to code here') ||
|
cleanOutput.includes('Ready to code here') ||
|
||||||
cleanOutput.includes('permission to work with your files'))
|
cleanOutput.includes('permission to work with your files') ||
|
||||||
|
cleanOutput.includes('trust this folder') ||
|
||||||
|
cleanOutput.includes('safety check'))
|
||||||
) {
|
) {
|
||||||
hasApprovedTrust = true;
|
hasApprovedTrust = true;
|
||||||
hasSeenTrustPrompt = true;
|
hasSeenTrustPrompt = true;
|
||||||
@@ -471,10 +489,17 @@ export class ClaudeUsageService {
|
|||||||
* Handles CSI, OSC, and other common ANSI sequences
|
* Handles CSI, OSC, and other common ANSI sequences
|
||||||
*/
|
*/
|
||||||
private stripAnsiCodes(text: string): string {
|
private stripAnsiCodes(text: string): string {
|
||||||
// First strip ANSI sequences (colors, etc) and handle CR
|
// First, convert cursor movement sequences to whitespace to preserve word boundaries.
|
||||||
|
// The Claude CLI TUI uses ESC[nC (cursor forward) instead of actual spaces between words.
|
||||||
|
// Without this, "Current week (all models)" becomes "Currentweek(allmodels)" after stripping.
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
let clean = text
|
let clean = text
|
||||||
// CSI sequences: ESC [ ... (letter or @)
|
// Cursor forward (CSI n C): replace with n spaces to preserve word separation
|
||||||
|
.replace(/\x1B\[(\d+)C/g, (_match, n) => ' '.repeat(parseInt(n, 10)))
|
||||||
|
// Cursor movement (up/down/back/position): replace with newline or nothing
|
||||||
|
.replace(/\x1B\[\d*[ABD]/g, '') // cursor up (A), down (B), back (D)
|
||||||
|
.replace(/\x1B\[\d+;\d+[Hf]/g, '\n') // cursor position (H/f)
|
||||||
|
// Now strip remaining CSI sequences (colors, modes, etc.)
|
||||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '')
|
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '')
|
||||||
// OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC
|
// OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC
|
||||||
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '')
|
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '')
|
||||||
|
|||||||
@@ -107,6 +107,47 @@ export class FeatureStateManager {
|
|||||||
// Badge will show for 2 minutes after this timestamp
|
// Badge will show for 2 minutes after this timestamp
|
||||||
if (status === 'waiting_approval') {
|
if (status === 'waiting_approval') {
|
||||||
feature.justFinishedAt = new Date().toISOString();
|
feature.justFinishedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Finalize task statuses when feature is done:
|
||||||
|
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
|
||||||
|
// - Do NOT mark pending tasks as completed (they were never started)
|
||||||
|
// - Clear currentTaskId since no task is actively running
|
||||||
|
// This prevents cards in "waiting for review" from appearing to still have running tasks
|
||||||
|
if (feature.planSpec?.tasks) {
|
||||||
|
let tasksFinalized = 0;
|
||||||
|
for (const task of feature.planSpec.tasks) {
|
||||||
|
if (task.status === 'in_progress') {
|
||||||
|
task.status = 'completed';
|
||||||
|
tasksFinalized++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tasksFinalized > 0) {
|
||||||
|
logger.info(
|
||||||
|
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Update tasksCompleted count to reflect actual completed tasks
|
||||||
|
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||||
|
(t) => t.status === 'completed'
|
||||||
|
).length;
|
||||||
|
feature.planSpec.currentTaskId = undefined;
|
||||||
|
}
|
||||||
|
} else if (status === 'verified') {
|
||||||
|
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
|
||||||
|
// Do NOT mark pending tasks as completed - they were never started
|
||||||
|
if (feature.planSpec?.tasks) {
|
||||||
|
for (const task of feature.planSpec.tasks) {
|
||||||
|
if (task.status === 'in_progress') {
|
||||||
|
task.status = 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||||
|
(t) => t.status === 'completed'
|
||||||
|
).length;
|
||||||
|
feature.planSpec.currentTaskId = undefined;
|
||||||
|
}
|
||||||
|
// Clear the timestamp when moving to other statuses
|
||||||
|
feature.justFinishedAt = undefined;
|
||||||
} else {
|
} else {
|
||||||
// Clear the timestamp when moving to other statuses
|
// Clear the timestamp when moving to other statuses
|
||||||
feature.justFinishedAt = undefined;
|
feature.justFinishedAt = undefined;
|
||||||
|
|||||||
@@ -177,6 +177,66 @@ describe('claude-usage-service.ts', () => {
|
|||||||
// BEL is stripped, newlines and tabs preserved
|
// BEL is stripped, newlines and tabs preserved
|
||||||
expect(result).toBe('Line 1\nLine 2\tTabbed with bell');
|
expect(result).toBe('Line 1\nLine 2\tTabbed with bell');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should convert cursor forward (ESC[nC) to spaces', () => {
|
||||||
|
const service = new ClaudeUsageService();
|
||||||
|
// Claude CLI TUI uses ESC[1C instead of space between words
|
||||||
|
const input = 'Current\x1B[1Csession';
|
||||||
|
// @ts-expect-error - accessing private method for testing
|
||||||
|
const result = service.stripAnsiCodes(input);
|
||||||
|
|
||||||
|
expect(result).toBe('Current session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multi-character cursor forward sequences', () => {
|
||||||
|
const service = new ClaudeUsageService();
|
||||||
|
// ESC[3C = move cursor forward 3 positions = 3 spaces
|
||||||
|
const input = 'Hello\x1B[3Cworld';
|
||||||
|
// @ts-expect-error - accessing private method for testing
|
||||||
|
const result = service.stripAnsiCodes(input);
|
||||||
|
|
||||||
|
expect(result).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle real Claude CLI TUI output with cursor movement codes', () => {
|
||||||
|
const service = new ClaudeUsageService();
|
||||||
|
// Simulates actual Claude CLI /usage output where words are separated by ESC[1C
|
||||||
|
const input =
|
||||||
|
'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' +
|
||||||
|
'\x1B[32m█████████████████████████▌\x1B[0m\x1B[1C51%\x1B[1Cused\n' +
|
||||||
|
'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)';
|
||||||
|
// @ts-expect-error - accessing private method for testing
|
||||||
|
const result = service.stripAnsiCodes(input);
|
||||||
|
|
||||||
|
expect(result).toContain('Current week (all models)');
|
||||||
|
expect(result).toContain('51% used');
|
||||||
|
expect(result).toContain('Resets Feb 19 at 3pm (America/Los_Angeles)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse usage output with cursor movement codes between words', () => {
|
||||||
|
const service = new ClaudeUsageService();
|
||||||
|
// Simulates the full /usage TUI output with ESC[1C between every word
|
||||||
|
const output =
|
||||||
|
'Current\x1B[1Csession\n' +
|
||||||
|
'\x1B[32m█████████████▌\x1B[0m\x1B[1C27%\x1B[1Cused\n' +
|
||||||
|
'Resets\x1B[1C9pm\x1B[1C(America/Los_Angeles)\n' +
|
||||||
|
'\n' +
|
||||||
|
'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' +
|
||||||
|
'\x1B[32m█████████████████████████▌\x1B[0m\x1B[1C51%\x1B[1Cused\n' +
|
||||||
|
'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)\n' +
|
||||||
|
'\n' +
|
||||||
|
'Current\x1B[1Cweek\x1B[1C(Sonnet\x1B[1Conly)\n' +
|
||||||
|
'\x1B[32m██▌\x1B[0m\x1B[1C5%\x1B[1Cused\n' +
|
||||||
|
'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C11pm\x1B[1C(America/Los_Angeles)';
|
||||||
|
// @ts-expect-error - accessing private method for testing
|
||||||
|
const result = service.parseUsageOutput(output);
|
||||||
|
|
||||||
|
expect(result.sessionPercentage).toBe(27);
|
||||||
|
expect(result.weeklyPercentage).toBe(51);
|
||||||
|
expect(result.sonnetWeeklyPercentage).toBe(5);
|
||||||
|
expect(result.weeklyResetText).toContain('Resets Feb 19 at 3pm');
|
||||||
|
expect(result.weeklyResetText).not.toContain('America/Los_Angeles');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseResetTime', () => {
|
describe('parseResetTime', () => {
|
||||||
|
|||||||
@@ -151,6 +151,100 @@ describe('FeatureStateManager', () => {
|
|||||||
expect(savedFeature.justFinishedAt).toBeUndefined();
|
expect(savedFeature.justFinishedAt).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should finalize in_progress tasks but keep pending tasks when moving to waiting_approval', async () => {
|
||||||
|
const featureWithTasks: Feature = {
|
||||||
|
...mockFeature,
|
||||||
|
status: 'in_progress',
|
||||||
|
planSpec: {
|
||||||
|
status: 'approved',
|
||||||
|
version: 1,
|
||||||
|
reviewedByUser: true,
|
||||||
|
currentTaskId: 'task-2',
|
||||||
|
tasksCompleted: 1,
|
||||||
|
tasks: [
|
||||||
|
{ id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' },
|
||||||
|
{ id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' },
|
||||||
|
{ id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||||
|
data: featureWithTasks,
|
||||||
|
recovered: false,
|
||||||
|
source: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||||
|
|
||||||
|
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||||
|
// Already completed tasks stay completed
|
||||||
|
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
|
||||||
|
// in_progress tasks should be finalized to completed
|
||||||
|
expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed');
|
||||||
|
// pending tasks should remain pending (never started)
|
||||||
|
expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending');
|
||||||
|
// currentTaskId should be cleared
|
||||||
|
expect(savedFeature.planSpec?.currentTaskId).toBeUndefined();
|
||||||
|
// tasksCompleted should equal actual completed tasks count
|
||||||
|
expect(savedFeature.planSpec?.tasksCompleted).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should finalize tasks when moving to verified status', async () => {
|
||||||
|
const featureWithTasks: Feature = {
|
||||||
|
...mockFeature,
|
||||||
|
status: 'in_progress',
|
||||||
|
planSpec: {
|
||||||
|
status: 'approved',
|
||||||
|
version: 1,
|
||||||
|
reviewedByUser: true,
|
||||||
|
currentTaskId: 'task-2',
|
||||||
|
tasksCompleted: 1,
|
||||||
|
tasks: [
|
||||||
|
{ id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' },
|
||||||
|
{ id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' },
|
||||||
|
{ id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||||
|
data: featureWithTasks,
|
||||||
|
recovered: false,
|
||||||
|
source: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||||
|
|
||||||
|
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||||
|
// Already completed tasks stay completed
|
||||||
|
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
|
||||||
|
// in_progress tasks should be finalized to completed
|
||||||
|
expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed');
|
||||||
|
// pending tasks should remain pending (never started)
|
||||||
|
expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending');
|
||||||
|
// currentTaskId should be cleared
|
||||||
|
expect(savedFeature.planSpec?.currentTaskId).toBeUndefined();
|
||||||
|
// tasksCompleted should equal actual completed tasks count
|
||||||
|
expect(savedFeature.planSpec?.tasksCompleted).toBe(2);
|
||||||
|
// justFinishedAt should be cleared for verified
|
||||||
|
expect(savedFeature.justFinishedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle waiting_approval without planSpec tasks gracefully', async () => {
|
||||||
|
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||||
|
data: { ...mockFeature },
|
||||||
|
recovered: false,
|
||||||
|
source: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||||
|
|
||||||
|
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||||
|
expect(savedFeature.status).toBe('waiting_approval');
|
||||||
|
expect(savedFeature.justFinishedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should create notification for waiting_approval status', async () => {
|
it('should create notification for waiting_approval status', async () => {
|
||||||
const mockNotificationService = { createNotification: vi.fn() };
|
const mockNotificationService = { createNotification: vi.fn() };
|
||||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||||
|
|||||||
@@ -59,24 +59,19 @@ export function TaskProgressPanel({
|
|||||||
const planSpec = feature.planSpec;
|
const planSpec = feature.planSpec;
|
||||||
const planTasks = planSpec.tasks; // Already guarded by the if condition above
|
const planTasks = planSpec.tasks; // Already guarded by the if condition above
|
||||||
const currentId = planSpec.currentTaskId;
|
const currentId = planSpec.currentTaskId;
|
||||||
const completedCount = planSpec.tasksCompleted || 0;
|
|
||||||
|
|
||||||
// Convert planSpec tasks to TaskInfo with proper status
|
// Convert planSpec tasks to TaskInfo using their persisted status
|
||||||
// planTasks is guaranteed to be defined due to the if condition check
|
// planTasks is guaranteed to be defined due to the if condition check
|
||||||
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map(
|
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map((t: ParsedTask) => ({
|
||||||
(t: ParsedTask, index: number) => ({
|
id: t.id,
|
||||||
id: t.id,
|
description: t.description,
|
||||||
description: t.description,
|
filePath: t.filePath,
|
||||||
filePath: t.filePath,
|
phase: t.phase,
|
||||||
phase: t.phase,
|
status:
|
||||||
status:
|
t.id === currentId
|
||||||
index < completedCount
|
? ('in_progress' as const)
|
||||||
? ('completed' as const)
|
: (t.status as TaskInfo['status']) || ('pending' as const),
|
||||||
: t.id === currentId
|
}));
|
||||||
? ('in_progress' as const)
|
|
||||||
: ('pending' as const),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setTasks(initialTasks);
|
setTasks(initialTasks);
|
||||||
setCurrentTaskId(currentId || null);
|
setCurrentTaskId(currentId || null);
|
||||||
@@ -113,16 +108,12 @@ export function TaskProgressPanel({
|
|||||||
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
|
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
// Update status to in_progress and mark previous as completed
|
// Update only the started task to in_progress
|
||||||
return prev.map((t, idx) => {
|
// Do NOT assume previous tasks are completed - rely on actual task_complete events
|
||||||
|
return prev.map((t) => {
|
||||||
if (t.id === taskEvent.taskId) {
|
if (t.id === taskEvent.taskId) {
|
||||||
return { ...t, status: 'in_progress' as const };
|
return { ...t, status: 'in_progress' as const };
|
||||||
}
|
}
|
||||||
// If we are moving to a task that is further down the list, assume previous ones are completed
|
|
||||||
// This is a heuristic, but usually correct for sequential execution
|
|
||||||
if (idx < existingIndex && t.status !== 'completed') {
|
|
||||||
return { ...t, status: 'completed' as const };
|
|
||||||
}
|
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -151,6 +142,24 @@ export function TaskProgressPanel({
|
|||||||
setCurrentTaskId(null);
|
setCurrentTaskId(null);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'auto_mode_task_status':
|
||||||
|
if ('taskId' in event && 'status' in event) {
|
||||||
|
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_status' }>;
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === taskEvent.taskId
|
||||||
|
? { ...t, status: taskEvent.status as TaskInfo['status'] }
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (taskEvent.status === 'in_progress') {
|
||||||
|
setCurrentTaskId(taskEvent.taskId);
|
||||||
|
} else if (taskEvent.status === 'completed') {
|
||||||
|
setCurrentTaskId((current) => (current === taskEvent.taskId ? null : current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
|
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
|
||||||
|
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||||
|
|
||||||
// Error codes for distinguishing failure modes
|
// Error codes for distinguishing failure modes
|
||||||
const ERROR_CODES = {
|
const ERROR_CODES = {
|
||||||
@@ -146,13 +147,28 @@ export function UsagePopover() {
|
|||||||
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper component for the progress bar
|
// Helper component for the progress bar with optional pace indicator
|
||||||
const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
|
const ProgressBar = ({
|
||||||
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
percentage,
|
||||||
|
colorClass,
|
||||||
|
pacePercentage,
|
||||||
|
}: {
|
||||||
|
percentage: number;
|
||||||
|
colorClass: string;
|
||||||
|
pacePercentage?: number | null;
|
||||||
|
}) => (
|
||||||
|
<div className="relative h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn('h-full transition-all duration-500', colorClass)}
|
className={cn('h-full transition-all duration-500', colorClass)}
|
||||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
|
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||||
|
style={{ left: `${pacePercentage}%` }}
|
||||||
|
title={`Expected: ${Math.round(pacePercentage)}%`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -163,6 +179,7 @@ export function UsagePopover() {
|
|||||||
resetText,
|
resetText,
|
||||||
isPrimary = false,
|
isPrimary = false,
|
||||||
stale = false,
|
stale = false,
|
||||||
|
pacePercentage,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
@@ -170,6 +187,7 @@ export function UsagePopover() {
|
|||||||
resetText?: string;
|
resetText?: string;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
stale?: boolean;
|
stale?: boolean;
|
||||||
|
pacePercentage?: number | null;
|
||||||
}) => {
|
}) => {
|
||||||
const isValidPercentage =
|
const isValidPercentage =
|
||||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||||
@@ -177,6 +195,10 @@ export function UsagePopover() {
|
|||||||
|
|
||||||
const status = getStatusInfo(safePercentage);
|
const status = getStatusInfo(safePercentage);
|
||||||
const StatusIcon = status.icon;
|
const StatusIcon = status.icon;
|
||||||
|
const paceLabel =
|
||||||
|
isValidPercentage && pacePercentage != null
|
||||||
|
? getPaceStatusLabel(safePercentage, pacePercentage)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -211,15 +233,28 @@ export function UsagePopover() {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
percentage={safePercentage}
|
percentage={safePercentage}
|
||||||
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
|
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
|
||||||
|
pacePercentage={pacePercentage}
|
||||||
/>
|
/>
|
||||||
{resetText && (
|
<div className="mt-2 flex items-center justify-between">
|
||||||
<div className="mt-2 flex justify-end">
|
{paceLabel ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] font-medium',
|
||||||
|
safePercentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{paceLabel}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
{resetText && (
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{resetText}
|
{resetText}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -384,6 +419,7 @@ export function UsagePopover() {
|
|||||||
percentage={claudeUsage.sonnetWeeklyPercentage}
|
percentage={claudeUsage.sonnetWeeklyPercentage}
|
||||||
resetText={claudeUsage.sonnetResetText}
|
resetText={claudeUsage.sonnetResetText}
|
||||||
stale={isClaudeStale}
|
stale={isClaudeStale}
|
||||||
|
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
|
||||||
/>
|
/>
|
||||||
<UsageCard
|
<UsageCard
|
||||||
title="Weekly"
|
title="Weekly"
|
||||||
@@ -391,6 +427,7 @@ export function UsagePopover() {
|
|||||||
percentage={claudeUsage.weeklyPercentage}
|
percentage={claudeUsage.weeklyPercentage}
|
||||||
resetText={claudeUsage.weeklyResetText}
|
resetText={claudeUsage.weeklyResetText}
|
||||||
stale={isClaudeStale}
|
stale={isClaudeStale}
|
||||||
|
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
|
|
||||||
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
|
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
|
||||||
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
||||||
|
const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified';
|
||||||
const effectiveTodos = useMemo(() => {
|
const effectiveTodos = useMemo(() => {
|
||||||
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
|
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
|
||||||
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
|
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
|
||||||
@@ -163,6 +164,16 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
const currentTaskId = planSpec.currentTaskId;
|
const currentTaskId = planSpec.currentTaskId;
|
||||||
|
|
||||||
return planSpec.tasks.map((task: ParsedTask, index: number) => {
|
return planSpec.tasks.map((task: ParsedTask, index: number) => {
|
||||||
|
// If the feature is done (waiting_approval/verified), all tasks are completed
|
||||||
|
// This is a defensive UI-side check: the server should have already finalized
|
||||||
|
// task statuses, but stale data from before the fix could still show spinners
|
||||||
|
if (isFeatureFinished) {
|
||||||
|
return {
|
||||||
|
content: task.description,
|
||||||
|
status: 'completed' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Use real-time status from WebSocket events if available
|
// Use real-time status from WebSocket events if available
|
||||||
const realtimeStatus = taskStatusMap.get(task.id);
|
const realtimeStatus = taskStatusMap.get(task.id);
|
||||||
|
|
||||||
@@ -199,6 +210,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
feature.planSpec?.currentTaskId,
|
feature.planSpec?.currentTaskId,
|
||||||
agentInfo?.todos,
|
agentInfo?.todos,
|
||||||
taskStatusMap,
|
taskStatusMap,
|
||||||
|
isFeatureFinished,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Listen to WebSocket events for real-time task status updates
|
// Listen to WebSocket events for real-time task status updates
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-nocheck - header component props with optional handlers and status variants
|
// @ts-nocheck - header component props with optional handlers and status variants
|
||||||
import { memo, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
|
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -35,6 +36,8 @@ interface CardHeaderProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onViewOutput?: () => void;
|
onViewOutput?: () => void;
|
||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
|
dragHandleListeners?: DraggableSyntheticListeners;
|
||||||
|
dragHandleAttributes?: DraggableAttributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardHeaderSection = memo(function CardHeaderSection({
|
export const CardHeaderSection = memo(function CardHeaderSection({
|
||||||
@@ -46,6 +49,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onViewOutput,
|
onViewOutput,
|
||||||
onSpawnTask,
|
onSpawnTask,
|
||||||
|
dragHandleListeners,
|
||||||
|
dragHandleAttributes,
|
||||||
}: CardHeaderProps) {
|
}: CardHeaderProps) {
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
@@ -319,8 +324,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{isDraggable && (
|
{isDraggable && (
|
||||||
<div
|
<div
|
||||||
className="-ml-2 -mt-1 p-2 touch-none opacity-40 hover:opacity-70 transition-opacity"
|
className="-ml-2 -mt-1 p-2 touch-none cursor-grab active:cursor-grabbing opacity-40 hover:opacity-70 transition-opacity"
|
||||||
data-testid={`drag-handle-${feature.id}`}
|
data-testid={`drag-handle-${feature.id}`}
|
||||||
|
{...dragHandleAttributes}
|
||||||
|
{...dragHandleListeners}
|
||||||
>
|
>
|
||||||
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function getCursorClass(
|
|||||||
): string {
|
): string {
|
||||||
if (isSelectionMode) return 'cursor-pointer';
|
if (isSelectionMode) return 'cursor-pointer';
|
||||||
if (isOverlay) return 'cursor-grabbing';
|
if (isOverlay) return 'cursor-grabbing';
|
||||||
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
|
// Drag cursor is now only on the drag handle, not the full card
|
||||||
return 'cursor-default';
|
return 'cursor-default';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
const isSelectable = isSelectionMode && feature.status === selectionTarget;
|
const isSelectable = isSelectionMode && feature.status === selectionTarget;
|
||||||
|
|
||||||
const wrapperClasses = cn(
|
const wrapperClasses = cn(
|
||||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
'relative select-none outline-none transition-transform duration-200 ease-out',
|
||||||
getCursorClass(isOverlay, isDraggable, isSelectable),
|
getCursorClass(isOverlay, isDraggable, isSelectable),
|
||||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
|
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
|
||||||
// Visual feedback when another card is being dragged over this one
|
// Visual feedback when another card is being dragged over this one
|
||||||
@@ -254,6 +254,8 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onViewOutput={onViewOutput}
|
onViewOutput={onViewOutput}
|
||||||
onSpawnTask={onSpawnTask}
|
onSpawnTask={onSpawnTask}
|
||||||
|
dragHandleListeners={isDraggable ? listeners : undefined}
|
||||||
|
dragHandleAttributes={isDraggable ? attributes : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardContent className="px-3 pt-0 pb-0">
|
<CardContent className="px-3 pt-0 pb-0">
|
||||||
@@ -296,8 +298,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={dndStyle}
|
style={dndStyle}
|
||||||
{...attributes}
|
|
||||||
{...(isDraggable ? listeners : {})}
|
|
||||||
className={wrapperClasses}
|
className={wrapperClasses}
|
||||||
data-testid={`kanban-card-${feature.id}`}
|
data-testid={`kanban-card-${feature.id}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Spinner } from '@/components/ui/spinner';
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
|
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||||
|
|
||||||
interface MobileUsageBarProps {
|
interface MobileUsageBarProps {
|
||||||
showClaudeUsage: boolean;
|
showClaudeUsage: boolean;
|
||||||
@@ -23,11 +24,15 @@ function UsageBar({
|
|||||||
label,
|
label,
|
||||||
percentage,
|
percentage,
|
||||||
isStale,
|
isStale,
|
||||||
|
pacePercentage,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
isStale: boolean;
|
isStale: boolean;
|
||||||
|
pacePercentage?: number | null;
|
||||||
}) {
|
}) {
|
||||||
|
const paceLabel = pacePercentage != null ? getPaceStatusLabel(percentage, pacePercentage) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-1.5 first:mt-0">
|
<div className="mt-1.5 first:mt-0">
|
||||||
<div className="flex items-center justify-between mb-0.5">
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
@@ -49,7 +54,7 @@ function UsageBar({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
|
'relative h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
|
||||||
isStale && 'opacity-60'
|
isStale && 'opacity-60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -57,7 +62,24 @@ function UsageBar({
|
|||||||
className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))}
|
className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))}
|
||||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
|
{pacePercentage != null && pacePercentage > 0 && pacePercentage < 100 && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||||
|
style={{ left: `${pacePercentage}%` }}
|
||||||
|
title={`Expected: ${Math.round(pacePercentage)}%`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{paceLabel && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-[9px] mt-0.5',
|
||||||
|
percentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{paceLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -190,6 +212,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
|||||||
label="Weekly"
|
label="Weekly"
|
||||||
percentage={claudeUsage.weeklyPercentage}
|
percentage={claudeUsage.weeklyPercentage}
|
||||||
isStale={isClaudeStale}
|
isStale={isClaudeStale}
|
||||||
|
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,64 @@
|
|||||||
import type { ClaudeUsage } from '../types/usage-types';
|
import type { ClaudeUsage } from '../types/usage-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the expected weekly usage percentage based on how far through the week we are.
|
||||||
|
* Claude's weekly usage resets every Thursday. Given the reset time (when the NEXT reset occurs),
|
||||||
|
* we can determine how much of the week has elapsed and therefore what percentage of the budget
|
||||||
|
* should have been used if usage were evenly distributed.
|
||||||
|
*
|
||||||
|
* @param weeklyResetTime - ISO date string for when the weekly usage next resets
|
||||||
|
* @returns The expected usage percentage (0-100), or null if the reset time is invalid
|
||||||
|
*/
|
||||||
|
export function getExpectedWeeklyPacePercentage(
|
||||||
|
weeklyResetTime: string | undefined
|
||||||
|
): number | null {
|
||||||
|
if (!weeklyResetTime) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resetDate = new Date(weeklyResetTime);
|
||||||
|
if (isNaN(resetDate.getTime())) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// The week started 7 days before the reset
|
||||||
|
const weekStartDate = new Date(resetDate.getTime() - WEEK_MS);
|
||||||
|
|
||||||
|
// How far through the week are we?
|
||||||
|
const elapsed = now.getTime() - weekStartDate.getTime();
|
||||||
|
const fractionElapsed = elapsed / WEEK_MS;
|
||||||
|
|
||||||
|
// Clamp to 0-1 range
|
||||||
|
const clamped = Math.max(0, Math.min(1, fractionElapsed));
|
||||||
|
|
||||||
|
return clamped * 100;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable label for the pace status (ahead or behind expected usage).
|
||||||
|
*
|
||||||
|
* @param actualPercentage - The actual usage percentage (0-100)
|
||||||
|
* @param expectedPercentage - The expected usage percentage (0-100)
|
||||||
|
* @returns A string like "5% ahead of pace" or "10% behind pace", or null
|
||||||
|
*/
|
||||||
|
export function getPaceStatusLabel(
|
||||||
|
actualPercentage: number,
|
||||||
|
expectedPercentage: number | null
|
||||||
|
): string | null {
|
||||||
|
if (expectedPercentage === null) return null;
|
||||||
|
|
||||||
|
const diff = Math.round(actualPercentage - expectedPercentage);
|
||||||
|
|
||||||
|
if (diff === 0) return 'On pace';
|
||||||
|
// Using more than expected = behind pace (bad)
|
||||||
|
if (diff > 0) return `${Math.abs(diff)}% behind pace`;
|
||||||
|
// Using less than expected = ahead of pace (good)
|
||||||
|
return `${Math.abs(diff)}% ahead of pace`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
|
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
|
||||||
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
|
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
|
||||||
|
|||||||
Reference in New Issue
Block a user