mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
feat: Add error handling to auto-mode facade and implement followUp feature. Fix Claude weekly usage indicator. Fix mobile card drag
This commit is contained in:
@@ -13,6 +13,7 @@ import { GlobalAutoModeService } from './global-service.js';
|
||||
import { AutoModeServiceFacade } from './facade.js';
|
||||
import type { SettingsService } from '../settings-service.js';
|
||||
import type { FeatureLoader } from '../feature-loader.js';
|
||||
import type { ClaudeUsageService } from '../claude-usage-service.js';
|
||||
import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js';
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,8 @@ export class AutoModeServiceCompat {
|
||||
constructor(
|
||||
events: EventEmitter,
|
||||
settingsService: SettingsService | null,
|
||||
featureLoader: FeatureLoader
|
||||
featureLoader: FeatureLoader,
|
||||
claudeUsageService?: ClaudeUsageService | null
|
||||
) {
|
||||
this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader);
|
||||
const sharedServices = this.globalService.getSharedServices();
|
||||
@@ -37,6 +39,7 @@ export class AutoModeServiceCompat {
|
||||
settingsService,
|
||||
featureLoader,
|
||||
sharedServices,
|
||||
claudeUsageService: claudeUsageService ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { SettingsService } from '../settings-service.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type {
|
||||
FacadeOptions,
|
||||
FacadeError,
|
||||
AutoModeStatus,
|
||||
ProjectAutoModeStatus,
|
||||
WorktreeCapacityInfo,
|
||||
@@ -89,6 +90,45 @@ export class AutoModeServiceFacade {
|
||||
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.
|
||||
*
|
||||
@@ -447,11 +487,16 @@ export class AutoModeServiceFacade {
|
||||
* @param maxConcurrency - Maximum concurrent features
|
||||
*/
|
||||
async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise<number> {
|
||||
return this.autoLoopCoordinator.startAutoLoopForProject(
|
||||
try {
|
||||
return await this.autoLoopCoordinator.startAutoLoopForProject(
|
||||
this.projectPath,
|
||||
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
|
||||
*/
|
||||
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,7 +550,8 @@ export class AutoModeServiceFacade {
|
||||
_calledInternally?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
return this.executionService.executeFeature(
|
||||
try {
|
||||
return await this.executionService.executeFeature(
|
||||
this.projectPath,
|
||||
featureId,
|
||||
useWorktrees,
|
||||
@@ -508,6 +559,10 @@ export class AutoModeServiceFacade {
|
||||
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
|
||||
*/
|
||||
async stopFeature(featureId: string): Promise<boolean> {
|
||||
try {
|
||||
// Cancel any pending plan approval for this feature
|
||||
this.cancelPlanApproval(featureId);
|
||||
return this.executionService.stopFeature(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[],
|
||||
useWorktrees = true
|
||||
): 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);
|
||||
|
||||
const runningEntry = this.concurrencyManager.acquire({
|
||||
featureId,
|
||||
projectPath: this.projectPath,
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// NOTE: Facade does not have runAgent - this method requires AutoModeService
|
||||
// Do NOT emit start events before throwing to prevent false start events
|
||||
throw new Error(
|
||||
'followUpFeature not fully implemented in facade - use AutoModeService.followUpFeature instead'
|
||||
// Load feature to build the prompt context
|
||||
const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
|
||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||
|
||||
// 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) {
|
||||
const errorInfo = classifyError(error);
|
||||
if (!errorInfo.isAbort) {
|
||||
@@ -582,8 +673,6 @@ export class AutoModeServiceFacade {
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this.concurrencyManager.release(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export type {
|
||||
WorktreeCapacityInfo,
|
||||
RunningAgentInfo,
|
||||
OrphanedFeatureInfo,
|
||||
FacadeError,
|
||||
GlobalAutoModeOperations,
|
||||
} from './types.js';
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ConcurrencyManager } from '../concurrency-manager.js';
|
||||
import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js';
|
||||
import type { WorktreeResolver } from '../worktree-resolver.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
|
||||
export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js';
|
||||
@@ -55,6 +56,8 @@ export interface FacadeOptions {
|
||||
featureLoader?: FeatureLoader;
|
||||
/** Shared services for state sharing across facades (optional) */
|
||||
sharedServices?: SharedServices;
|
||||
/** ClaudeUsageService for checking usage limits before picking up features (optional) */
|
||||
claudeUsageService?: ClaudeUsageService | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +113,23 @@ export interface OrphanedFeatureInfo {
|
||||
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).
|
||||
* Used by routes that need global state access.
|
||||
|
||||
@@ -294,7 +294,16 @@ export class ClaudeUsageService {
|
||||
this.killPtyProcess(ptyProcess);
|
||||
}
|
||||
// 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);
|
||||
} else if (hasSeenTrustPrompt) {
|
||||
// Trust prompt was shown but we couldn't auto-approve it
|
||||
@@ -320,8 +329,13 @@ export class ClaudeUsageService {
|
||||
output += data;
|
||||
|
||||
// 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
|
||||
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
|
||||
// Must be very specific to avoid false positives from garbled terminal encoding
|
||||
@@ -356,7 +370,8 @@ export class ClaudeUsageService {
|
||||
const hasUsageIndicators =
|
||||
cleanOutput.includes('Current session') ||
|
||||
(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) ||
|
||||
cleanOutput.includes('Resets in') ||
|
||||
cleanOutput.includes('Current week');
|
||||
@@ -382,12 +397,15 @@ export class ClaudeUsageService {
|
||||
// Handle Trust Dialog - multiple variants:
|
||||
// - "Do you want to work in this folder?"
|
||||
// - "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.
|
||||
if (
|
||||
!hasApprovedTrust &&
|
||||
(cleanOutput.includes('Do you want to work in this folder?') ||
|
||||
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;
|
||||
hasSeenTrustPrompt = true;
|
||||
@@ -471,10 +489,17 @@ export class ClaudeUsageService {
|
||||
* Handles CSI, OSC, and other common ANSI sequences
|
||||
*/
|
||||
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
|
||||
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, '')
|
||||
// OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC
|
||||
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '')
|
||||
|
||||
@@ -107,6 +107,47 @@ export class FeatureStateManager {
|
||||
// Badge will show for 2 minutes after this timestamp
|
||||
if (status === 'waiting_approval') {
|
||||
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 {
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
|
||||
@@ -177,6 +177,66 @@ describe('claude-usage-service.ts', () => {
|
||||
// BEL is stripped, newlines and tabs preserved
|
||||
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', () => {
|
||||
|
||||
@@ -151,6 +151,96 @@ describe('FeatureStateManager', () => {
|
||||
expect(savedFeature.justFinishedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should finalize in_progress and 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;
|
||||
// All tasks should be completed
|
||||
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
|
||||
expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed');
|
||||
expect(savedFeature.planSpec?.tasks?.[2].status).toBe('completed');
|
||||
// currentTaskId should be cleared
|
||||
expect(savedFeature.planSpec?.currentTaskId).toBeUndefined();
|
||||
// tasksCompleted should equal total tasks
|
||||
expect(savedFeature.planSpec?.tasksCompleted).toBe(3);
|
||||
});
|
||||
|
||||
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;
|
||||
// All tasks should be completed
|
||||
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
|
||||
expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed');
|
||||
expect(savedFeature.planSpec?.tasks?.[2].status).toBe('completed');
|
||||
// currentTaskId should be cleared
|
||||
expect(savedFeature.planSpec?.currentTaskId).toBeUndefined();
|
||||
// tasksCompleted should equal total tasks
|
||||
expect(savedFeature.planSpec?.tasksCompleted).toBe(3);
|
||||
// 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 () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
@@ -59,24 +59,19 @@ export function TaskProgressPanel({
|
||||
const planSpec = feature.planSpec;
|
||||
const planTasks = planSpec.tasks; // Already guarded by the if condition above
|
||||
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
|
||||
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map(
|
||||
(t: ParsedTask, index: number) => ({
|
||||
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map((t: ParsedTask) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
filePath: t.filePath,
|
||||
phase: t.phase,
|
||||
status:
|
||||
index < completedCount
|
||||
? ('completed' as const)
|
||||
: t.id === currentId
|
||||
t.id === currentId
|
||||
? ('in_progress' as const)
|
||||
: ('pending' as const),
|
||||
})
|
||||
);
|
||||
: (t.status as TaskInfo['status']) || ('pending' as const),
|
||||
}));
|
||||
|
||||
setTasks(initialTasks);
|
||||
setCurrentTaskId(currentId || null);
|
||||
@@ -113,16 +108,12 @@ export function TaskProgressPanel({
|
||||
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update status to in_progress and mark previous as completed
|
||||
return prev.map((t, idx) => {
|
||||
// Update only the started task to in_progress
|
||||
// Do NOT assume previous tasks are completed - rely on actual task_complete events
|
||||
return prev.map((t) => {
|
||||
if (t.id === taskEvent.taskId) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -151,6 +142,24 @@ export function TaskProgressPanel({
|
||||
setCurrentTaskId(null);
|
||||
}
|
||||
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 { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
|
||||
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -146,13 +147,28 @@ export function UsagePopover() {
|
||||
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
||||
};
|
||||
|
||||
// Helper component for the progress bar
|
||||
const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
|
||||
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
// Helper component for the progress bar with optional pace indicator
|
||||
const ProgressBar = ({
|
||||
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
|
||||
className={cn('h-full transition-all duration-500', colorClass)}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -163,6 +179,7 @@ export function UsagePopover() {
|
||||
resetText,
|
||||
isPrimary = false,
|
||||
stale = false,
|
||||
pacePercentage,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -170,6 +187,7 @@ export function UsagePopover() {
|
||||
resetText?: string;
|
||||
isPrimary?: boolean;
|
||||
stale?: boolean;
|
||||
pacePercentage?: number | null;
|
||||
}) => {
|
||||
const isValidPercentage =
|
||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||
@@ -177,6 +195,10 @@ export function UsagePopover() {
|
||||
|
||||
const status = getStatusInfo(safePercentage);
|
||||
const StatusIcon = status.icon;
|
||||
const paceLabel =
|
||||
isValidPercentage && pacePercentage != null
|
||||
? getPaceStatusLabel(safePercentage, pacePercentage)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -211,16 +233,29 @@ export function UsagePopover() {
|
||||
<ProgressBar
|
||||
percentage={safePercentage}
|
||||
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
|
||||
pacePercentage={pacePercentage}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
{paceLabel ? (
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
safePercentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{paceLabel}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{resetText && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{resetText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -384,6 +419,7 @@ export function UsagePopover() {
|
||||
percentage={claudeUsage.sonnetWeeklyPercentage}
|
||||
resetText={claudeUsage.sonnetResetText}
|
||||
stale={isClaudeStale}
|
||||
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
|
||||
/>
|
||||
<UsageCard
|
||||
title="Weekly"
|
||||
@@ -391,6 +427,7 @@ export function UsagePopover() {
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
resetText={claudeUsage.weeklyResetText}
|
||||
stale={isClaudeStale}
|
||||
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
|
||||
// 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
|
||||
const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified';
|
||||
const effectiveTodos = useMemo(() => {
|
||||
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
|
||||
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
|
||||
@@ -163,6 +164,16 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
const currentTaskId = planSpec.currentTaskId;
|
||||
|
||||
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
|
||||
const realtimeStatus = taskStatusMap.get(task.id);
|
||||
|
||||
@@ -199,6 +210,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
feature.planSpec?.currentTaskId,
|
||||
agentInfo?.todos,
|
||||
taskStatusMap,
|
||||
isFeatureFinished,
|
||||
]);
|
||||
|
||||
// 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
|
||||
import { memo, useState } from 'react';
|
||||
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -35,6 +36,8 @@ interface CardHeaderProps {
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onSpawnTask?: () => void;
|
||||
dragHandleListeners?: DraggableSyntheticListeners;
|
||||
dragHandleAttributes?: DraggableAttributes;
|
||||
}
|
||||
|
||||
export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
@@ -46,6 +49,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
onSpawnTask,
|
||||
dragHandleListeners,
|
||||
dragHandleAttributes,
|
||||
}: CardHeaderProps) {
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
@@ -319,8 +324,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
<div className="flex items-start gap-2">
|
||||
{isDraggable && (
|
||||
<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}`}
|
||||
{...dragHandleAttributes}
|
||||
{...dragHandleListeners}
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ function getCursorClass(
|
||||
): string {
|
||||
if (isSelectionMode) return 'cursor-pointer';
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
const isSelectable = isSelectionMode && feature.status === selectionTarget;
|
||||
|
||||
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),
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
|
||||
// Visual feedback when another card is being dragged over this one
|
||||
@@ -254,6 +254,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
onSpawnTask={onSpawnTask}
|
||||
dragHandleListeners={isDraggable ? listeners : undefined}
|
||||
dragHandleAttributes={isDraggable ? attributes : undefined}
|
||||
/>
|
||||
|
||||
<CardContent className="px-3 pt-0 pb-0">
|
||||
@@ -296,8 +298,6 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={dndStyle}
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
className={wrapperClasses}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils';
|
||||
|
||||
interface MobileUsageBarProps {
|
||||
showClaudeUsage: boolean;
|
||||
@@ -23,11 +24,15 @@ function UsageBar({
|
||||
label,
|
||||
percentage,
|
||||
isStale,
|
||||
pacePercentage,
|
||||
}: {
|
||||
label: string;
|
||||
percentage: number;
|
||||
isStale: boolean;
|
||||
pacePercentage?: number | null;
|
||||
}) {
|
||||
const paceLabel = pacePercentage != null ? getPaceStatusLabel(percentage, pacePercentage) : null;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 first:mt-0">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
@@ -49,7 +54,7 @@ function UsageBar({
|
||||
</div>
|
||||
<div
|
||||
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'
|
||||
)}
|
||||
>
|
||||
@@ -57,7 +62,24 @@ function UsageBar({
|
||||
className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))}
|
||||
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>
|
||||
{paceLabel && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-[9px] mt-0.5',
|
||||
percentage > (pacePercentage ?? 0) ? 'text-orange-500' : 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{paceLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,6 +212,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
||||
label="Weekly"
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
isStale={isClaudeStale}
|
||||
pacePercentage={getExpectedWeeklyPacePercentage(claudeUsage.weeklyResetTime)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
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)
|
||||
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
|
||||
|
||||
Reference in New Issue
Block a user