mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
623afc1d35 | ||
|
|
085652560a | ||
|
|
af4ddb1280 | ||
|
|
7db659f0e1 | ||
|
|
ba526ea09e | ||
|
|
c308e429f8 |
@@ -177,7 +177,7 @@ export function run(argv: string[]): void {
|
||||
.option('--model <model>', 'Model override')
|
||||
.option('--cd <path>', 'Working directory')
|
||||
.option('--includeDirs <dirs>', 'Additional directories (--include-directories for gemini/qwen, --add-dir for codex/claude)')
|
||||
.option('--timeout <ms>', 'Timeout in milliseconds (0=disabled, controlled by external caller)', '0')
|
||||
// --timeout removed - controlled by external caller (bash timeout)
|
||||
.option('--stream', 'Enable streaming output (default: non-streaming with caching)')
|
||||
.option('--limit <n>', 'History limit')
|
||||
.option('--status <status>', 'Filter by status')
|
||||
|
||||
@@ -116,7 +116,7 @@ interface CliExecOptions {
|
||||
model?: string;
|
||||
cd?: string;
|
||||
includeDirs?: string;
|
||||
timeout?: string;
|
||||
// timeout removed - controlled by external caller (bash timeout)
|
||||
stream?: boolean; // Enable streaming (default: false, caches output)
|
||||
resume?: string | boolean; // true = last, string = execution ID, comma-separated for merge
|
||||
id?: string; // Custom execution ID (e.g., IMPL-001-step1)
|
||||
@@ -535,7 +535,7 @@ async function statusAction(debug?: boolean): Promise<void> {
|
||||
* @param {Object} options - CLI options
|
||||
*/
|
||||
async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise<void> {
|
||||
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, stream, resume, id, noNative, cache, injectMode, debug } = options;
|
||||
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, stream, resume, id, noNative, cache, injectMode, debug } = options;
|
||||
|
||||
// Enable debug mode if --debug flag is set
|
||||
if (debug) {
|
||||
@@ -842,7 +842,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
||||
model,
|
||||
cd,
|
||||
includeDirs,
|
||||
timeout: timeout ? parseInt(timeout, 10) : 0, // 0 = no internal timeout, controlled by external caller
|
||||
// timeout removed - controlled by external caller (bash timeout)
|
||||
resume,
|
||||
id, // custom execution ID
|
||||
noNative,
|
||||
@@ -1221,7 +1221,7 @@ export async function cliCommand(
|
||||
console.log(chalk.gray(' --model <model> Model override'));
|
||||
console.log(chalk.gray(' --cd <path> Working directory'));
|
||||
console.log(chalk.gray(' --includeDirs <dirs> Additional directories'));
|
||||
console.log(chalk.gray(' --timeout <ms> Timeout (default: 0=disabled)'));
|
||||
// --timeout removed - controlled by external caller (bash timeout)
|
||||
console.log(chalk.gray(' --resume [id] Resume previous session'));
|
||||
console.log(chalk.gray(' --cache <items> Cache: comma-separated @patterns and text'));
|
||||
console.log(chalk.gray(' --inject-mode <m> Inject mode: none, full, progressive'));
|
||||
|
||||
@@ -589,6 +589,18 @@ function loadProjectOverview(workflowDir: string): ProjectOverview | null {
|
||||
const statistics = (projectData.statistics || developmentStatus?.statistics) as Record<string, unknown> | undefined;
|
||||
const metadata = projectData._metadata as Record<string, unknown> | undefined;
|
||||
|
||||
// Helper to extract string array from mixed array (handles both string[] and {name: string}[])
|
||||
const extractStringArray = (arr: unknown[] | undefined): string[] => {
|
||||
if (!arr) return [];
|
||||
return arr.map(item => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (typeof item === 'object' && item !== null && 'name' in item) {
|
||||
return String((item as { name: unknown }).name);
|
||||
}
|
||||
return String(item);
|
||||
});
|
||||
};
|
||||
|
||||
// Load guidelines from separate file if exists
|
||||
let guidelines: ProjectGuidelines | null = null;
|
||||
if (existsSync(guidelinesFile)) {
|
||||
@@ -633,17 +645,17 @@ function loadProjectOverview(workflowDir: string): ProjectOverview | null {
|
||||
description: (overview?.description as string) || '',
|
||||
initializedAt: (projectData.initialized_at as string) || null,
|
||||
technologyStack: {
|
||||
languages: (technologyStack?.languages as string[]) || [],
|
||||
frameworks: (technologyStack?.frameworks as string[]) || [],
|
||||
build_tools: (technologyStack?.build_tools as string[]) || [],
|
||||
test_frameworks: (technologyStack?.test_frameworks as string[]) || []
|
||||
languages: extractStringArray(technologyStack?.languages),
|
||||
frameworks: extractStringArray(technologyStack?.frameworks),
|
||||
build_tools: extractStringArray(technologyStack?.build_tools),
|
||||
test_frameworks: extractStringArray(technologyStack?.test_frameworks)
|
||||
},
|
||||
architecture: {
|
||||
style: (architecture?.style as string) || 'Unknown',
|
||||
layers: (architecture?.layers as string[]) || [],
|
||||
patterns: (architecture?.patterns as string[]) || []
|
||||
layers: extractStringArray(architecture?.layers as unknown[] | undefined),
|
||||
patterns: extractStringArray(architecture?.patterns as unknown[] | undefined)
|
||||
},
|
||||
keyComponents: (overview?.key_components as string[]) || [],
|
||||
keyComponents: extractStringArray(overview?.key_components as unknown[] | undefined),
|
||||
features: (projectData.features as unknown[]) || [],
|
||||
developmentIndex: {
|
||||
feature: (developmentIndex?.feature as unknown[]) || [],
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* - POST /api/queue/reorder - Reorder queue items
|
||||
*/
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { join, resolve, normalize } from 'path';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
// ========== JSONL Helper Functions ==========
|
||||
@@ -67,6 +67,12 @@ function readIssueHistoryJsonl(issuesDir: string): any[] {
|
||||
}
|
||||
}
|
||||
|
||||
function writeIssueHistoryJsonl(issuesDir: string, issues: any[]) {
|
||||
if (!existsSync(issuesDir)) mkdirSync(issuesDir, { recursive: true });
|
||||
const historyPath = join(issuesDir, 'issue-history.jsonl');
|
||||
writeFileSync(historyPath, issues.map(i => JSON.stringify(i)).join('\n'));
|
||||
}
|
||||
|
||||
function writeSolutionsJsonl(issuesDir: string, issueId: string, solutions: any[]) {
|
||||
const solutionsDir = join(issuesDir, 'solutions');
|
||||
if (!existsSync(solutionsDir)) mkdirSync(solutionsDir, { recursive: true });
|
||||
@@ -156,7 +162,30 @@ function writeQueue(issuesDir: string, queue: any) {
|
||||
|
||||
function getIssueDetail(issuesDir: string, issueId: string) {
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
let issue = issues.find(i => i.id === issueId);
|
||||
|
||||
// Fallback: Reconstruct issue from solution file if issue not in issues.jsonl
|
||||
if (!issue) {
|
||||
const solutionPath = join(issuesDir, 'solutions', `${issueId}.jsonl`);
|
||||
if (existsSync(solutionPath)) {
|
||||
const solutions = readSolutionsJsonl(issuesDir, issueId);
|
||||
if (solutions.length > 0) {
|
||||
const boundSolution = solutions.find(s => s.is_bound) || solutions[0];
|
||||
issue = {
|
||||
id: issueId,
|
||||
title: boundSolution?.description || issueId,
|
||||
status: 'completed',
|
||||
priority: 3,
|
||||
context: boundSolution?.approach || '',
|
||||
bound_solution_id: boundSolution?.id || null,
|
||||
created_at: boundSolution?.created_at || new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
_reconstructed: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const solutions = readSolutionsJsonl(issuesDir, issueId);
|
||||
@@ -254,11 +283,46 @@ function bindSolutionToIssue(issuesDir: string, issueId: string, solutionId: str
|
||||
return { success: true, bound: solutionId };
|
||||
}
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
/**
|
||||
* Validate that the provided path is safe (no path traversal)
|
||||
* Returns the resolved, normalized path or null if invalid
|
||||
*/
|
||||
function validateProjectPath(requestedPath: string, basePath: string): string | null {
|
||||
if (!requestedPath) return basePath;
|
||||
|
||||
// Resolve to absolute path and normalize
|
||||
const resolvedPath = resolve(normalize(requestedPath));
|
||||
const resolvedBase = resolve(normalize(basePath));
|
||||
|
||||
// For local development tool, we allow any absolute path
|
||||
// but prevent obvious traversal attempts
|
||||
if (requestedPath.includes('..') && !resolvedPath.startsWith(resolvedBase)) {
|
||||
// Check if it's trying to escape with ..
|
||||
const normalizedRequested = normalize(requestedPath);
|
||||
if (normalizedRequested.startsWith('..')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
// ========== Route Handler ==========
|
||||
|
||||
export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const rawProjectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
// Validate project path to prevent path traversal
|
||||
const projectPath = validateProjectPath(rawProjectPath, initialPath);
|
||||
if (!projectPath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid project path' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const issuesDir = join(projectPath, '.workflow', 'issues');
|
||||
|
||||
// ===== Queue Routes (top-level /api/queue) =====
|
||||
@@ -295,7 +359,8 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
// GET /api/queue/:id - Get specific queue by ID
|
||||
const queueDetailMatch = pathname.match(/^\/api\/queue\/([^/]+)$/);
|
||||
if (queueDetailMatch && req.method === 'GET' && queueDetailMatch[1] !== 'history' && queueDetailMatch[1] !== 'reorder') {
|
||||
const reservedQueuePaths = ['history', 'reorder', 'switch', 'deactivate', 'merge'];
|
||||
if (queueDetailMatch && req.method === 'GET' && !reservedQueuePaths.includes(queueDetailMatch[1])) {
|
||||
const queueId = queueDetailMatch[1];
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
const queueFilePath = join(queuesDir, `${queueId}.json`);
|
||||
@@ -347,6 +412,29 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/deactivate - Deactivate current queue (set active to null)
|
||||
if (pathname === '/api/queue/deactivate' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
const indexPath = join(queuesDir, 'index.json');
|
||||
|
||||
try {
|
||||
const index = existsSync(indexPath)
|
||||
? JSON.parse(readFileSync(indexPath, 'utf8'))
|
||||
: { active_queue_id: null, queues: [] };
|
||||
|
||||
const previousActiveId = index.active_queue_id;
|
||||
index.active_queue_id = null;
|
||||
writeFileSync(indexPath, JSON.stringify(index, null, 2));
|
||||
|
||||
return { success: true, previous_active_id: previousActiveId };
|
||||
} catch (err) {
|
||||
return { error: 'Failed to deactivate queue' };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/reorder - Reorder queue items (supports both solutions and tasks)
|
||||
if (pathname === '/api/queue/reorder' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
@@ -399,6 +487,237 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// DELETE /api/queue/:queueId/item/:itemId - Delete item from queue
|
||||
const queueItemDeleteMatch = pathname.match(/^\/api\/queue\/([^/]+)\/item\/([^/]+)$/);
|
||||
if (queueItemDeleteMatch && req.method === 'DELETE') {
|
||||
const queueId = queueItemDeleteMatch[1];
|
||||
const itemId = decodeURIComponent(queueItemDeleteMatch[2]);
|
||||
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
const queueFilePath = join(queuesDir, `${queueId}.json`);
|
||||
|
||||
if (!existsSync(queueFilePath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: `Queue ${queueId} not found` }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const queue = JSON.parse(readFileSync(queueFilePath, 'utf8'));
|
||||
const items = queue.solutions || queue.tasks || [];
|
||||
const filteredItems = items.filter((item: any) => item.item_id !== itemId);
|
||||
|
||||
if (filteredItems.length === items.length) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: `Item ${itemId} not found in queue` }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update queue items
|
||||
if (queue.solutions) {
|
||||
queue.solutions = filteredItems;
|
||||
} else {
|
||||
queue.tasks = filteredItems;
|
||||
}
|
||||
|
||||
// Recalculate metadata
|
||||
const completedCount = filteredItems.filter((i: any) => i.status === 'completed').length;
|
||||
queue._metadata = {
|
||||
...queue._metadata,
|
||||
updated_at: new Date().toISOString(),
|
||||
...(queue.solutions
|
||||
? { total_solutions: filteredItems.length, completed_solutions: completedCount }
|
||||
: { total_tasks: filteredItems.length, completed_tasks: completedCount })
|
||||
};
|
||||
|
||||
writeFileSync(queueFilePath, JSON.stringify(queue, null, 2));
|
||||
|
||||
// Update index counts
|
||||
const indexPath = join(queuesDir, 'index.json');
|
||||
if (existsSync(indexPath)) {
|
||||
try {
|
||||
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
||||
const queueEntry = index.queues?.find((q: any) => q.id === queueId);
|
||||
if (queueEntry) {
|
||||
if (queue.solutions) {
|
||||
queueEntry.total_solutions = filteredItems.length;
|
||||
queueEntry.completed_solutions = completedCount;
|
||||
} else {
|
||||
queueEntry.total_tasks = filteredItems.length;
|
||||
queueEntry.completed_tasks = completedCount;
|
||||
}
|
||||
writeFileSync(indexPath, JSON.stringify(index, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update queue index:', err);
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, queueId, deletedItemId: itemId }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to delete item' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// DELETE /api/queue/:queueId - Delete entire queue
|
||||
const queueDeleteMatch = pathname.match(/^\/api\/queue\/([^/]+)$/);
|
||||
if (queueDeleteMatch && req.method === 'DELETE') {
|
||||
const queueId = queueDeleteMatch[1];
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
const queueFilePath = join(queuesDir, `${queueId}.json`);
|
||||
const indexPath = join(queuesDir, 'index.json');
|
||||
|
||||
if (!existsSync(queueFilePath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: `Queue ${queueId} not found` }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete queue file
|
||||
unlinkSync(queueFilePath);
|
||||
|
||||
// Update index
|
||||
if (existsSync(indexPath)) {
|
||||
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
||||
|
||||
// Remove from queues array
|
||||
index.queues = (index.queues || []).filter((q: any) => q.id !== queueId);
|
||||
|
||||
// Clear active if this was the active queue
|
||||
if (index.active_queue_id === queueId) {
|
||||
index.active_queue_id = null;
|
||||
}
|
||||
|
||||
writeFileSync(indexPath, JSON.stringify(index, null, 2));
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, deletedQueueId: queueId }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to delete queue' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/merge - Merge source queue into target queue
|
||||
if (pathname === '/api/queue/merge' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const { sourceQueueId, targetQueueId } = body;
|
||||
if (!sourceQueueId || !targetQueueId) {
|
||||
return { error: 'sourceQueueId and targetQueueId required' };
|
||||
}
|
||||
|
||||
if (sourceQueueId === targetQueueId) {
|
||||
return { error: 'Cannot merge queue into itself' };
|
||||
}
|
||||
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
const sourcePath = join(queuesDir, `${sourceQueueId}.json`);
|
||||
const targetPath = join(queuesDir, `${targetQueueId}.json`);
|
||||
|
||||
if (!existsSync(sourcePath)) return { error: `Source queue ${sourceQueueId} not found` };
|
||||
if (!existsSync(targetPath)) return { error: `Target queue ${targetQueueId} not found` };
|
||||
|
||||
try {
|
||||
const sourceQueue = JSON.parse(readFileSync(sourcePath, 'utf8'));
|
||||
const targetQueue = JSON.parse(readFileSync(targetPath, 'utf8'));
|
||||
|
||||
const sourceItems = sourceQueue.solutions || sourceQueue.tasks || [];
|
||||
const targetItems = targetQueue.solutions || targetQueue.tasks || [];
|
||||
const isSolutionBased = !!targetQueue.solutions;
|
||||
|
||||
// Re-index source items to avoid ID conflicts
|
||||
const maxOrder = targetItems.reduce((max: number, i: any) => Math.max(max, i.execution_order || 0), 0);
|
||||
const reindexedSourceItems = sourceItems.map((item: any, idx: number) => ({
|
||||
...item,
|
||||
item_id: `${item.item_id}-merged`,
|
||||
execution_order: maxOrder + idx + 1,
|
||||
execution_group: item.execution_group ? `M-${item.execution_group}` : 'M-ungrouped'
|
||||
}));
|
||||
|
||||
// Merge items
|
||||
const mergedItems = [...targetItems, ...reindexedSourceItems];
|
||||
|
||||
if (isSolutionBased) {
|
||||
targetQueue.solutions = mergedItems;
|
||||
} else {
|
||||
targetQueue.tasks = mergedItems;
|
||||
}
|
||||
|
||||
// Merge issue_ids
|
||||
const mergedIssueIds = [...new Set([
|
||||
...(targetQueue.issue_ids || []),
|
||||
...(sourceQueue.issue_ids || [])
|
||||
])];
|
||||
targetQueue.issue_ids = mergedIssueIds;
|
||||
|
||||
// Update metadata
|
||||
const completedCount = mergedItems.filter((i: any) => i.status === 'completed').length;
|
||||
targetQueue._metadata = {
|
||||
...targetQueue._metadata,
|
||||
updated_at: new Date().toISOString(),
|
||||
...(isSolutionBased
|
||||
? { total_solutions: mergedItems.length, completed_solutions: completedCount }
|
||||
: { total_tasks: mergedItems.length, completed_tasks: completedCount })
|
||||
};
|
||||
|
||||
// Write merged queue
|
||||
writeFileSync(targetPath, JSON.stringify(targetQueue, null, 2));
|
||||
|
||||
// Update source queue status
|
||||
sourceQueue.status = 'merged';
|
||||
sourceQueue._metadata = {
|
||||
...sourceQueue._metadata,
|
||||
merged_into: targetQueueId,
|
||||
merged_at: new Date().toISOString()
|
||||
};
|
||||
writeFileSync(sourcePath, JSON.stringify(sourceQueue, null, 2));
|
||||
|
||||
// Update index
|
||||
const indexPath = join(queuesDir, 'index.json');
|
||||
if (existsSync(indexPath)) {
|
||||
try {
|
||||
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
||||
const sourceEntry = index.queues?.find((q: any) => q.id === sourceQueueId);
|
||||
const targetEntry = index.queues?.find((q: any) => q.id === targetQueueId);
|
||||
if (sourceEntry) {
|
||||
sourceEntry.status = 'merged';
|
||||
}
|
||||
if (targetEntry) {
|
||||
if (isSolutionBased) {
|
||||
targetEntry.total_solutions = mergedItems.length;
|
||||
targetEntry.completed_solutions = completedCount;
|
||||
} else {
|
||||
targetEntry.total_tasks = mergedItems.length;
|
||||
targetEntry.completed_tasks = completedCount;
|
||||
}
|
||||
targetEntry.issue_ids = mergedIssueIds;
|
||||
}
|
||||
writeFileSync(indexPath, JSON.stringify(index, null, 2));
|
||||
} catch {
|
||||
// Ignore index update errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sourceQueueId,
|
||||
targetQueueId,
|
||||
mergedItemCount: sourceItems.length,
|
||||
totalItems: mergedItems.length
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: 'Failed to merge queues' };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy: GET /api/issues/queue (backward compat)
|
||||
if (pathname === '/api/issues/queue' && req.method === 'GET') {
|
||||
const queue = groupQueueByExecutionGroup(readQueue(issuesDir));
|
||||
@@ -546,6 +865,39 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/issues/:id/archive - Archive issue (move to history)
|
||||
const archiveMatch = pathname.match(/^\/api\/issues\/([^/]+)\/archive$/);
|
||||
if (archiveMatch && req.method === 'POST') {
|
||||
const issueId = decodeURIComponent(archiveMatch[1]);
|
||||
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issueIndex = issues.findIndex(i => i.id === issueId);
|
||||
|
||||
if (issueIndex === -1) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the issue and add archive metadata
|
||||
const issue = issues[issueIndex];
|
||||
issue.archived_at = new Date().toISOString();
|
||||
issue.status = 'completed';
|
||||
|
||||
// Move to history
|
||||
const history = readIssueHistoryJsonl(issuesDir);
|
||||
history.push(issue);
|
||||
writeIssueHistoryJsonl(issuesDir, history);
|
||||
|
||||
// Remove from active issues
|
||||
issues.splice(issueIndex, 1);
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, issueId, archivedAt: issue.archived_at }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/issues/:id/solutions - Add solution
|
||||
const addSolMatch = pathname.match(/^\/api\/issues\/([^/]+)\/solutions$/);
|
||||
if (addSolMatch && req.method === 'POST') {
|
||||
|
||||
@@ -429,14 +429,16 @@
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px hsl(var(--foreground) / 0.04);
|
||||
}
|
||||
|
||||
.queue-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
@@ -1256,6 +1258,68 @@
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Search Highlight */
|
||||
.search-highlight {
|
||||
background: hsl(45 93% 47% / 0.3);
|
||||
color: inherit;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Search Suggestions Dropdown */
|
||||
.search-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.1);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-suggestions.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-suggestion-item {
|
||||
padding: 0.625rem 0.875rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.5);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.search-suggestion-item:hover,
|
||||
.search-suggestion-item.selected {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.search-suggestion-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.suggestion-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
CREATE BUTTON
|
||||
========================================== */
|
||||
@@ -1780,61 +1844,147 @@
|
||||
}
|
||||
|
||||
.queue-items {
|
||||
padding: 0.75rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Parallel items use CSS Grid for uniform sizing */
|
||||
.queue-items.parallel {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.queue-items.parallel .queue-item {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"id id delete"
|
||||
"issue issue issue"
|
||||
"solution solution solution";
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
align-items: start;
|
||||
padding: 0.75rem;
|
||||
min-height: 90px;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Card content layout */
|
||||
.queue-items.parallel .queue-item .queue-item-id {
|
||||
grid-area: id;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.queue-items.parallel .queue-item .queue-item-issue {
|
||||
grid-area: issue;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.queue-items.parallel .queue-item .queue-item-solution {
|
||||
grid-area: solution;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
/* Hide extra elements in parallel view */
|
||||
.queue-items.parallel .queue-item .queue-item-files,
|
||||
.queue-items.parallel .queue-item .queue-item-priority,
|
||||
.queue-items.parallel .queue-item .queue-item-deps,
|
||||
.queue-items.parallel .queue-item .queue-item-task {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Delete button positioned in corner */
|
||||
.queue-items.parallel .queue-item .queue-item-delete {
|
||||
grid-area: delete;
|
||||
justify-self: end;
|
||||
padding: 0.125rem;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.queue-group-type {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.queue-group-type.parallel {
|
||||
color: hsl(142 71% 45%);
|
||||
color: hsl(142 71% 40%);
|
||||
background: hsl(142 71% 45% / 0.1);
|
||||
}
|
||||
|
||||
.queue-group-type.sequential {
|
||||
color: hsl(262 83% 58%);
|
||||
color: hsl(262 83% 50%);
|
||||
background: hsl(262 83% 58% / 0.1);
|
||||
}
|
||||
|
||||
/* Queue Item Status Colors */
|
||||
/* Queue Item Status Colors - Enhanced visual distinction */
|
||||
|
||||
/* Pending - Default subtle state */
|
||||
.queue-item.pending,
|
||||
.queue-item:not(.ready):not(.executing):not(.completed):not(.failed):not(.blocked) {
|
||||
border-color: hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
|
||||
/* Ready - Blue tint, ready to execute */
|
||||
.queue-item.ready {
|
||||
border-color: hsl(199 89% 48%);
|
||||
background: hsl(199 89% 48% / 0.06);
|
||||
border-left: 3px solid hsl(199 89% 48%);
|
||||
}
|
||||
|
||||
/* Executing - Amber with pulse animation */
|
||||
.queue-item.executing {
|
||||
border-color: hsl(45 93% 47%);
|
||||
background: hsl(45 93% 47% / 0.05);
|
||||
border-color: hsl(38 92% 50%);
|
||||
background: hsl(38 92% 50% / 0.08);
|
||||
border-left: 3px solid hsl(38 92% 50%);
|
||||
animation: executing-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes executing-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 hsl(38 92% 50% / 0.3); }
|
||||
50% { box-shadow: 0 0 8px 2px hsl(38 92% 50% / 0.2); }
|
||||
}
|
||||
|
||||
/* Completed - Green success state */
|
||||
.queue-item.completed {
|
||||
border-color: hsl(var(--success));
|
||||
background: hsl(var(--success) / 0.05);
|
||||
border-color: hsl(142 71% 45%);
|
||||
background: hsl(142 71% 45% / 0.06);
|
||||
border-left: 3px solid hsl(142 71% 45%);
|
||||
}
|
||||
|
||||
/* Failed - Red error state */
|
||||
.queue-item.failed {
|
||||
border-color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 0.05);
|
||||
border-color: hsl(0 84% 60%);
|
||||
background: hsl(0 84% 60% / 0.06);
|
||||
border-left: 3px solid hsl(0 84% 60%);
|
||||
}
|
||||
|
||||
/* Blocked - Purple/violet blocked state */
|
||||
.queue-item.blocked {
|
||||
border-color: hsl(262 83% 58%);
|
||||
opacity: 0.7;
|
||||
background: hsl(262 83% 58% / 0.05);
|
||||
border-left: 3px solid hsl(262 83% 58%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Priority indicator */
|
||||
@@ -2236,61 +2386,89 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
padding: 1rem 1.25rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.queue-stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.06);
|
||||
}
|
||||
|
||||
.queue-stat-card .queue-stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.queue-stat-card .queue-stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
margin-top: 0.25rem;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Pending - Slate/Gray with subtle blue tint */
|
||||
.queue-stat-card.pending {
|
||||
border-color: hsl(var(--muted-foreground) / 0.3);
|
||||
border-color: hsl(215 20% 65% / 0.4);
|
||||
background: linear-gradient(135deg, hsl(215 20% 95%) 0%, hsl(var(--card)) 100%);
|
||||
}
|
||||
|
||||
.queue-stat-card.pending .queue-stat-value {
|
||||
color: hsl(var(--muted-foreground));
|
||||
color: hsl(215 20% 45%);
|
||||
}
|
||||
|
||||
.queue-stat-card.pending .queue-stat-label {
|
||||
color: hsl(215 20% 55%);
|
||||
}
|
||||
|
||||
/* Executing - Amber/Orange - attention-grabbing */
|
||||
.queue-stat-card.executing {
|
||||
border-color: hsl(45 93% 47% / 0.5);
|
||||
background: hsl(45 93% 47% / 0.05);
|
||||
border-color: hsl(38 92% 50% / 0.5);
|
||||
background: linear-gradient(135deg, hsl(38 92% 95%) 0%, hsl(45 93% 97%) 100%);
|
||||
}
|
||||
|
||||
.queue-stat-card.executing .queue-stat-value {
|
||||
color: hsl(45 93% 47%);
|
||||
color: hsl(38 92% 40%);
|
||||
}
|
||||
|
||||
.queue-stat-card.executing .queue-stat-label {
|
||||
color: hsl(38 70% 45%);
|
||||
}
|
||||
|
||||
/* Completed - Green - success indicator */
|
||||
.queue-stat-card.completed {
|
||||
border-color: hsl(var(--success) / 0.5);
|
||||
background: hsl(var(--success) / 0.05);
|
||||
border-color: hsl(142 71% 45% / 0.5);
|
||||
background: linear-gradient(135deg, hsl(142 71% 95%) 0%, hsl(142 50% 97%) 100%);
|
||||
}
|
||||
|
||||
.queue-stat-card.completed .queue-stat-value {
|
||||
color: hsl(var(--success));
|
||||
color: hsl(142 71% 35%);
|
||||
}
|
||||
|
||||
.queue-stat-card.completed .queue-stat-label {
|
||||
color: hsl(142 50% 40%);
|
||||
}
|
||||
|
||||
/* Failed - Red - error indicator */
|
||||
.queue-stat-card.failed {
|
||||
border-color: hsl(var(--destructive) / 0.5);
|
||||
background: hsl(var(--destructive) / 0.05);
|
||||
border-color: hsl(0 84% 60% / 0.5);
|
||||
background: linear-gradient(135deg, hsl(0 84% 95%) 0%, hsl(0 70% 97%) 100%);
|
||||
}
|
||||
|
||||
.queue-stat-card.failed .queue-stat-value {
|
||||
color: hsl(var(--destructive));
|
||||
color: hsl(0 84% 45%);
|
||||
}
|
||||
|
||||
.queue-stat-card.failed .queue-stat-label {
|
||||
color: hsl(0 60% 50%);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
@@ -2874,3 +3052,251 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
MULTI-QUEUE CARDS VIEW
|
||||
========================================== */
|
||||
|
||||
/* Queue Cards Header */
|
||||
.queue-cards-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Queue Cards Grid */
|
||||
.queue-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Individual Queue Card */
|
||||
.queue-card {
|
||||
position: relative;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.queue-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.08);
|
||||
}
|
||||
|
||||
.queue-card.active {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.05);
|
||||
}
|
||||
|
||||
.queue-card.merged {
|
||||
opacity: 0.6;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.queue-card.merged:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Queue Card Header */
|
||||
.queue-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.queue-card-id {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.queue-card-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Queue Card Stats - Progress Bar */
|
||||
.queue-card-stats {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.queue-card-stats .progress-bar {
|
||||
height: 6px;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.queue-card-stats .progress-fill {
|
||||
height: 100%;
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.queue-card-stats .progress-fill.completed {
|
||||
background: hsl(var(--success, 142 76% 36%));
|
||||
}
|
||||
|
||||
.queue-card-progress {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Queue Card Meta */
|
||||
.queue-card-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Queue Card Actions */
|
||||
.queue-card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Queue Detail Header */
|
||||
.queue-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.queue-detail-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.queue-detail-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Queue Item Delete Button */
|
||||
.queue-item-delete {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.queue-item:hover .queue-item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.queue-item-delete:hover {
|
||||
color: hsl(var(--destructive, 0 84% 60%));
|
||||
background: hsl(var(--destructive, 0 84% 60%) / 0.1);
|
||||
}
|
||||
|
||||
/* Queue Error State */
|
||||
.queue-error {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for queue cards */
|
||||
@media (max-width: 640px) {
|
||||
.queue-cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.queue-cards-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.queue-detail-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.queue-detail-title {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
WARNING BUTTON STYLE
|
||||
========================================== */
|
||||
|
||||
.btn-warning,
|
||||
.btn-secondary.btn-warning {
|
||||
color: hsl(38 92% 40%);
|
||||
border-color: hsl(38 92% 50% / 0.5);
|
||||
background: hsl(38 92% 50% / 0.08);
|
||||
}
|
||||
|
||||
.btn-warning:hover,
|
||||
.btn-secondary.btn-warning:hover {
|
||||
background: hsl(38 92% 50% / 0.15);
|
||||
border-color: hsl(38 92% 50%);
|
||||
}
|
||||
|
||||
.btn-danger,
|
||||
.btn-secondary.btn-danger,
|
||||
.btn-sm.btn-danger {
|
||||
color: hsl(var(--destructive));
|
||||
border-color: hsl(var(--destructive) / 0.5);
|
||||
background: hsl(var(--destructive) / 0.08);
|
||||
}
|
||||
|
||||
.btn-danger:hover,
|
||||
.btn-secondary.btn-danger:hover,
|
||||
.btn-sm.btn-danger:hover {
|
||||
background: hsl(var(--destructive) / 0.15);
|
||||
border-color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Issue Detail Actions */
|
||||
.issue-detail-actions {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.issue-detail-actions .flex {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Active queue badge enhancement */
|
||||
.queue-active-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(142 71% 35%);
|
||||
background: hsl(142 71% 45% / 0.15);
|
||||
border: 1px solid hsl(142 71% 45% / 0.3);
|
||||
border-radius: 9999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
@@ -2269,6 +2269,25 @@ const i18n = {
|
||||
'issues.queueCommandInfo': 'After running the command, click "Refresh" to see the updated queue.',
|
||||
'issues.alternative': 'Alternative',
|
||||
'issues.refreshAfter': 'Refresh Queue',
|
||||
'issues.activate': 'Activate',
|
||||
'issues.deactivate': 'Deactivate',
|
||||
'issues.queueActivated': 'Queue activated',
|
||||
'issues.queueDeactivated': 'Queue deactivated',
|
||||
'issues.deleteQueue': 'Delete queue',
|
||||
'issues.confirmDeleteQueue': 'Are you sure you want to delete this queue? This action cannot be undone.',
|
||||
'issues.queueDeleted': 'Queue deleted successfully',
|
||||
'issues.actions': 'Actions',
|
||||
'issues.archive': 'Archive',
|
||||
'issues.delete': 'Delete',
|
||||
'issues.confirmDeleteIssue': 'Are you sure you want to delete this issue? This action cannot be undone.',
|
||||
'issues.confirmArchiveIssue': 'Archive this issue? It will be moved to history.',
|
||||
'issues.issueDeleted': 'Issue deleted successfully',
|
||||
'issues.issueArchived': 'Issue archived successfully',
|
||||
'issues.executionQueues': 'Execution Queues',
|
||||
'issues.queues': 'queues',
|
||||
'issues.noQueues': 'No queues found',
|
||||
'issues.queueEmptyHint': 'Generate execution queue from bound solutions',
|
||||
'issues.refresh': 'Refresh',
|
||||
// issue.* keys (legacy)
|
||||
'issue.viewIssues': 'Issues',
|
||||
'issue.viewQueue': 'Queue',
|
||||
@@ -4592,6 +4611,25 @@ const i18n = {
|
||||
'issues.queueCommandInfo': '运行命令后,点击"刷新"查看更新后的队列。',
|
||||
'issues.alternative': '或者',
|
||||
'issues.refreshAfter': '刷新队列',
|
||||
'issues.activate': '激活',
|
||||
'issues.deactivate': '取消激活',
|
||||
'issues.queueActivated': '队列已激活',
|
||||
'issues.queueDeactivated': '队列已取消激活',
|
||||
'issues.deleteQueue': '删除队列',
|
||||
'issues.confirmDeleteQueue': '确定要删除此队列吗?此操作无法撤销。',
|
||||
'issues.queueDeleted': '队列删除成功',
|
||||
'issues.actions': '操作',
|
||||
'issues.archive': '归档',
|
||||
'issues.delete': '删除',
|
||||
'issues.confirmDeleteIssue': '确定要删除此议题吗?此操作无法撤销。',
|
||||
'issues.confirmArchiveIssue': '归档此议题?它将被移动到历史记录中。',
|
||||
'issues.issueDeleted': '议题删除成功',
|
||||
'issues.issueArchived': '议题归档成功',
|
||||
'issues.executionQueues': '执行队列',
|
||||
'issues.queues': '个队列',
|
||||
'issues.noQueues': '暂无队列',
|
||||
'issues.queueEmptyHint': '从绑定的解决方案生成执行队列',
|
||||
'issues.refresh': '刷新',
|
||||
// issue.* keys (legacy)
|
||||
'issue.viewIssues': '议题',
|
||||
'issue.viewQueue': '队列',
|
||||
|
||||
@@ -6381,12 +6381,12 @@ async function showWatcherControlModal() {
|
||||
|
||||
// Get first indexed project path as default
|
||||
let defaultPath = '';
|
||||
if (indexes.success && indexes.projects && indexes.projects.length > 0) {
|
||||
// Sort by last_indexed desc and pick the most recent
|
||||
const sorted = indexes.projects.sort((a, b) =>
|
||||
new Date(b.last_indexed || 0) - new Date(a.last_indexed || 0)
|
||||
if (indexes.success && indexes.indexes && indexes.indexes.length > 0) {
|
||||
// Sort by lastModified desc and pick the most recent
|
||||
const sorted = indexes.indexes.sort((a, b) =>
|
||||
new Date(b.lastModified || 0) - new Date(a.lastModified || 0)
|
||||
);
|
||||
defaultPath = sorted[0].source_root || '';
|
||||
defaultPath = sorted[0].path || '';
|
||||
}
|
||||
|
||||
const modalHtml = buildWatcherControlContent(status, defaultPath);
|
||||
|
||||
@@ -13,7 +13,11 @@ var issueData = {
|
||||
selectedSolutionIssueId: null,
|
||||
statusFilter: 'all',
|
||||
searchQuery: '',
|
||||
viewMode: 'issues' // 'issues' | 'queue'
|
||||
viewMode: 'issues', // 'issues' | 'queue'
|
||||
// Search suggestions state
|
||||
searchSuggestions: [],
|
||||
showSuggestions: false,
|
||||
selectedSuggestion: -1
|
||||
};
|
||||
var issueLoading = false;
|
||||
var issueDragState = {
|
||||
@@ -21,6 +25,13 @@ var issueDragState = {
|
||||
groupId: null
|
||||
};
|
||||
|
||||
// Multi-queue state
|
||||
var queueData = {
|
||||
queues: [], // All queue index entries
|
||||
activeQueueId: null, // Currently active queue
|
||||
expandedQueueId: null // Queue showing execution groups
|
||||
};
|
||||
|
||||
// ========== Main Render Function ==========
|
||||
async function renderIssueManager() {
|
||||
const container = document.getElementById('mainContent');
|
||||
@@ -36,7 +47,7 @@ async function renderIssueManager() {
|
||||
'</div>';
|
||||
|
||||
// Load data
|
||||
await Promise.all([loadIssueData(), loadQueueData()]);
|
||||
await Promise.all([loadIssueData(), loadQueueData(), loadAllQueues()]);
|
||||
|
||||
// Render the main view
|
||||
renderIssueView();
|
||||
@@ -82,6 +93,20 @@ async function loadQueueData() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllQueues() {
|
||||
try {
|
||||
const response = await fetch('/api/queue/history?path=' + encodeURIComponent(projectPath));
|
||||
if (!response.ok) throw new Error('Failed to load queue history');
|
||||
const data = await response.json();
|
||||
queueData.queues = data.queues || [];
|
||||
queueData.activeQueueId = data.active_queue_id;
|
||||
} catch (err) {
|
||||
console.error('Failed to load all queues:', err);
|
||||
queueData.queues = [];
|
||||
queueData.activeQueueId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIssueDetail(issueId) {
|
||||
try {
|
||||
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '?path=' + encodeURIComponent(projectPath));
|
||||
@@ -124,12 +149,25 @@ function renderIssueView() {
|
||||
|
||||
if (issueData.searchQuery) {
|
||||
const query = issueData.searchQuery.toLowerCase();
|
||||
filteredIssues = filteredIssues.filter(i =>
|
||||
filteredIssues = filteredIssues.filter(i => {
|
||||
// Basic field search
|
||||
const basicMatch =
|
||||
i.id.toLowerCase().includes(query) ||
|
||||
(i.title && i.title.toLowerCase().includes(query)) ||
|
||||
(i.context && i.context.toLowerCase().includes(query))
|
||||
(i.context && i.context.toLowerCase().includes(query));
|
||||
|
||||
if (basicMatch) return true;
|
||||
|
||||
// Search in solutions
|
||||
if (i.solutions && i.solutions.length > 0) {
|
||||
return i.solutions.some(sol =>
|
||||
(sol.description && sol.description.toLowerCase().includes(query)) ||
|
||||
(sol.approach && sol.approach.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="issue-manager">
|
||||
@@ -273,12 +311,18 @@ function renderIssueListSection(issues) {
|
||||
id="issueSearchInput"
|
||||
placeholder="${t('issues.searchPlaceholder') || 'Search issues...'}"
|
||||
value="${issueData.searchQuery}"
|
||||
oninput="handleIssueSearch(this.value)" />
|
||||
oninput="handleIssueSearch(this.value)"
|
||||
onkeydown="handleSearchKeydown(event)"
|
||||
onfocus="showSearchSuggestions()"
|
||||
autocomplete="off" />
|
||||
${issueData.searchQuery ? `
|
||||
<button class="issue-search-clear" onclick="clearIssueSearch()">
|
||||
<i data-lucide="x" class="w-3 h-3"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<div class="search-suggestions ${issueData.showSuggestions && issueData.searchSuggestions.length > 0 ? 'show' : ''}" id="searchSuggestions">
|
||||
${renderSearchSuggestions()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue-filters">
|
||||
@@ -339,7 +383,7 @@ function renderIssueCard(issue) {
|
||||
<div class="issue-card ${isArchived ? 'archived' : ''}" onclick="openIssueDetail('${issue.id}'${isArchived ? ', true' : ''})">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="issue-id font-mono text-sm">${issue.id}</span>
|
||||
<span class="issue-id font-mono text-sm">${highlightMatch(issue.id, issueData.searchQuery)}</span>
|
||||
<span class="issue-status ${statusColors[issue.status] || ''}">${issue.status || 'unknown'}</span>
|
||||
${isArchived ? '<span class="issue-archived-badge">' + (t('issues.archived') || 'Archived') + '</span>' : ''}
|
||||
</div>
|
||||
@@ -348,7 +392,7 @@ function renderIssueCard(issue) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="issue-title text-foreground font-medium mb-2">${issue.title || issue.id}</h3>
|
||||
<h3 class="issue-title text-foreground font-medium mb-2">${highlightMatch(issue.title || issue.id, issueData.searchQuery)}</h3>
|
||||
|
||||
<div class="issue-meta flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span class="flex items-center gap-1">
|
||||
@@ -398,25 +442,57 @@ async function filterIssuesByStatus(status) {
|
||||
|
||||
// ========== Queue Section ==========
|
||||
function renderQueueSection() {
|
||||
const queue = issueData.queue;
|
||||
// Support both solution-level and task-level queues
|
||||
const queueItems = queue.solutions || queue.tasks || [];
|
||||
const isSolutionLevel = !!(queue.solutions && queue.solutions.length > 0);
|
||||
const metadata = queue._metadata || {};
|
||||
const queues = queueData.queues || [];
|
||||
const activeQueueId = queueData.activeQueueId;
|
||||
const expandedQueueId = queueData.expandedQueueId;
|
||||
|
||||
// Check if queue is empty
|
||||
if (queueItems.length === 0) {
|
||||
// If a queue is expanded, show loading then load detail
|
||||
if (expandedQueueId) {
|
||||
// Show loading state first, then load async
|
||||
setTimeout(() => loadAndRenderExpandedQueue(expandedQueueId), 0);
|
||||
return `
|
||||
<div class="queue-empty-container">
|
||||
<div class="queue-empty-toolbar">
|
||||
<button class="btn-secondary" onclick="showQueueHistoryModal()" title="${t('issues.queueHistory') || 'Queue History'}">
|
||||
<i data-lucide="history" class="w-4 h-4"></i>
|
||||
<span>${t('issues.history') || 'History'}</span>
|
||||
<div id="queueExpandedWrapper" class="queue-expanded-wrapper">
|
||||
<div class="queue-detail-header mb-4">
|
||||
<button class="btn-secondary" onclick="queueData.expandedQueueId = null; renderIssueView();">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
<span>${t('common.back') || 'Back'}</span>
|
||||
</button>
|
||||
<div class="queue-detail-title">
|
||||
<h3 class="font-mono text-lg">${escapeHtml(expandedQueueId)}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div id="expandedQueueContent" class="flex items-center justify-center py-8">
|
||||
<i data-lucide="loader-2" class="w-6 h-6 animate-spin"></i>
|
||||
<span class="ml-2">${t('common.loading') || 'Loading...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Show multi-queue cards view
|
||||
return `
|
||||
<!-- Queue Cards Header -->
|
||||
<div class="queue-cards-header mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold">${t('issues.executionQueues') || 'Execution Queues'}</h3>
|
||||
<span class="text-sm text-muted-foreground">${queues.length} ${t('issues.queues') || 'queues'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn-secondary" onclick="loadAllQueues().then(() => renderIssueView())" title="${t('issues.refresh') || 'Refresh'}">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<button class="btn-primary" onclick="createExecutionQueue()">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
<span>${t('issues.createQueue') || 'Create Queue'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${queues.length === 0 ? `
|
||||
<div class="queue-empty-container">
|
||||
<div class="queue-empty">
|
||||
<i data-lucide="git-branch" class="w-16 h-16"></i>
|
||||
<p class="queue-empty-title">${t('issues.queueEmpty') || 'Queue is empty'}</p>
|
||||
<p class="queue-empty-title">${t('issues.noQueues') || 'No queues found'}</p>
|
||||
<p class="queue-empty-hint">${t('issues.queueEmptyHint') || 'Generate execution queue from bound solutions'}</p>
|
||||
<button class="queue-create-btn" onclick="createExecutionQueue()">
|
||||
<i data-lucide="play" class="w-4 h-4"></i>
|
||||
@@ -424,14 +500,509 @@ function renderQueueSection() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<!-- Queue Cards Grid -->
|
||||
<div class="queue-cards-grid">
|
||||
${queues.map(q => renderQueueCard(q, q.id === activeQueueId)).join('')}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderQueueCard(queue, isActive) {
|
||||
const itemCount = queue.total_solutions || queue.total_tasks || 0;
|
||||
const completedCount = queue.completed_solutions || queue.completed_tasks || 0;
|
||||
const progressPercent = itemCount > 0 ? Math.round((completedCount / itemCount) * 100) : 0;
|
||||
const issueCount = queue.issue_ids?.length || 0;
|
||||
const statusClass = queue.status === 'merged' ? 'merged' : queue.status || '';
|
||||
const safeQueueId = escapeHtml(queue.id || '');
|
||||
|
||||
return `
|
||||
<div class="queue-card ${isActive ? 'active' : ''} ${statusClass}" onclick="toggleQueueExpand('${safeQueueId}')">
|
||||
<div class="queue-card-header">
|
||||
<span class="queue-card-id font-mono">${safeQueueId}</span>
|
||||
<div class="queue-card-badges">
|
||||
${isActive ? '<span class="queue-active-badge">Active</span>' : ''}
|
||||
<span class="queue-status-badge ${statusClass}">${queue.status || 'unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-card-stats">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill ${queue.status === 'completed' ? 'completed' : ''}" style="width: ${progressPercent}%"></div>
|
||||
</div>
|
||||
<div class="queue-card-progress">
|
||||
<span>${completedCount}/${itemCount} ${queue.total_solutions ? 'solutions' : 'tasks'}</span>
|
||||
<span class="text-muted-foreground">${progressPercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-card-meta">
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="layers" class="w-3 h-3"></i>
|
||||
${issueCount} issues
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="calendar" class="w-3 h-3"></i>
|
||||
${queue.created_at ? new Date(queue.created_at).toLocaleDateString() : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="queue-card-actions" onclick="event.stopPropagation()">
|
||||
<button class="btn-sm" onclick="toggleQueueExpand('${safeQueueId}')" title="View details">
|
||||
<i data-lucide="eye" class="w-3 h-3"></i>
|
||||
</button>
|
||||
${!isActive && queue.status !== 'merged' ? `
|
||||
<button class="btn-sm btn-primary" onclick="activateQueue('${safeQueueId}')" title="Set as active">
|
||||
<i data-lucide="check-circle" class="w-3 h-3"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
${queue.status !== 'merged' ? `
|
||||
<button class="btn-sm" onclick="showMergeQueueModal('${safeQueueId}')" title="Merge into another queue">
|
||||
<i data-lucide="git-merge" class="w-3 h-3"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn-sm btn-danger" onclick="confirmDeleteQueue('${safeQueueId}')" title="${t('issues.deleteQueue') || 'Delete queue'}">
|
||||
<i data-lucide="trash-2" class="w-3 h-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function toggleQueueExpand(queueId) {
|
||||
if (queueData.expandedQueueId === queueId) {
|
||||
queueData.expandedQueueId = null;
|
||||
} else {
|
||||
queueData.expandedQueueId = queueId;
|
||||
}
|
||||
renderIssueView();
|
||||
}
|
||||
|
||||
async function activateQueue(queueId) {
|
||||
try {
|
||||
const response = await fetch('/api/queue/switch?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ queueId })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showNotification(t('issues.queueActivated') || 'Queue activated: ' + queueId, 'success');
|
||||
await Promise.all([loadQueueData(), loadAllQueues()]);
|
||||
renderIssueView();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to activate queue', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to activate queue:', err);
|
||||
showNotification('Failed to activate queue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateQueue(queueId) {
|
||||
try {
|
||||
const response = await fetch('/api/queue/deactivate?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ queueId })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showNotification(t('issues.queueDeactivated') || 'Queue deactivated', 'success');
|
||||
queueData.activeQueueId = null;
|
||||
await Promise.all([loadQueueData(), loadAllQueues()]);
|
||||
renderIssueView();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to deactivate queue', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to deactivate queue:', err);
|
||||
showNotification('Failed to deactivate queue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteQueue(queueId) {
|
||||
const msg = t('issues.confirmDeleteQueue') || 'Are you sure you want to delete this queue? This action cannot be undone.';
|
||||
if (confirm(msg)) {
|
||||
deleteQueue(queueId);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteQueue(queueId) {
|
||||
try {
|
||||
const response = await fetch('/api/queue/' + encodeURIComponent(queueId) + '?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showNotification(t('issues.queueDeleted') || 'Queue deleted successfully', 'success');
|
||||
queueData.expandedQueueId = null;
|
||||
await Promise.all([loadQueueData(), loadAllQueues()]);
|
||||
renderIssueView();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to delete queue', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete queue:', err);
|
||||
showNotification('Failed to delete queue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function renderExpandedQueueView(queueId) {
|
||||
const safeQueueId = escapeHtml(queueId || '');
|
||||
// Fetch queue detail
|
||||
let queue;
|
||||
try {
|
||||
const response = await fetch('/api/queue/' + encodeURIComponent(queueId) + '?path=' + encodeURIComponent(projectPath));
|
||||
queue = await response.json();
|
||||
if (queue.error) throw new Error(queue.error);
|
||||
} catch (err) {
|
||||
return `
|
||||
<div class="queue-error">
|
||||
<button class="btn-secondary mb-4" onclick="queueData.expandedQueueId = null; renderIssueView();">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i> Back
|
||||
</button>
|
||||
<p class="text-red-500">Failed to load queue: ${escapeHtml(err.message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Group items by execution_group or treat all as single group
|
||||
const queueItems = queue.solutions || queue.tasks || [];
|
||||
const isSolutionLevel = !!(queue.solutions && queue.solutions.length > 0);
|
||||
const metadata = queue._metadata || {};
|
||||
const isActive = queueId === queueData.activeQueueId;
|
||||
|
||||
// Group items by execution_group
|
||||
const groupMap = {};
|
||||
queueItems.forEach(item => {
|
||||
const groupId = item.execution_group || 'default';
|
||||
if (!groupMap[groupId]) groupMap[groupId] = [];
|
||||
groupMap[groupId].push(item);
|
||||
});
|
||||
|
||||
const groups = queue.execution_groups || Object.keys(groupMap).map(groupId => ({
|
||||
id: groupId,
|
||||
type: groupId.startsWith('P') ? 'parallel' : 'sequential',
|
||||
solution_count: groupMap[groupId]?.length || 0
|
||||
}));
|
||||
const groupedItems = queue.grouped_items || groupMap;
|
||||
|
||||
return `
|
||||
<!-- Back Button & Queue Header -->
|
||||
<div class="queue-detail-header mb-4">
|
||||
<button class="btn-secondary" onclick="queueData.expandedQueueId = null; renderIssueView();">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||
<span>${t('common.back') || 'Back'}</span>
|
||||
</button>
|
||||
<div class="queue-detail-title">
|
||||
<h3 class="font-mono text-lg">${escapeHtml(queue.id || queueId)}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
${isActive ? '<span class="queue-active-badge">Active</span>' : ''}
|
||||
<span class="queue-status-badge ${escapeHtml(queue.status || '')}">${escapeHtml(queue.status || 'unknown')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue-detail-actions">
|
||||
${!isActive && queue.status !== 'merged' ? `
|
||||
<button class="btn-primary" onclick="activateQueue('${safeQueueId}')">
|
||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
||||
<span>${t('issues.activate') || 'Activate'}</span>
|
||||
</button>
|
||||
` : ''}
|
||||
${isActive ? `
|
||||
<button class="btn-secondary btn-warning" onclick="deactivateQueue('${safeQueueId}')">
|
||||
<i data-lucide="x-circle" class="w-4 h-4"></i>
|
||||
<span>${t('issues.deactivate') || 'Deactivate'}</span>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn-secondary" onclick="refreshExpandedQueue('${safeQueueId}')">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Stats -->
|
||||
<div class="queue-stats-grid mb-4">
|
||||
<div class="queue-stat-card">
|
||||
<span class="queue-stat-value">${isSolutionLevel ? (metadata.total_solutions || queueItems.length) : (metadata.total_tasks || queueItems.length)}</span>
|
||||
<span class="queue-stat-label">${isSolutionLevel ? 'Solutions' : 'Tasks'}</span>
|
||||
</div>
|
||||
<div class="queue-stat-card pending">
|
||||
<span class="queue-stat-value">${metadata.pending_count || queueItems.filter(i => i.status === 'pending').length}</span>
|
||||
<span class="queue-stat-label">Pending</span>
|
||||
</div>
|
||||
<div class="queue-stat-card executing">
|
||||
<span class="queue-stat-value">${metadata.executing_count || queueItems.filter(i => i.status === 'executing').length}</span>
|
||||
<span class="queue-stat-label">Executing</span>
|
||||
</div>
|
||||
<div class="queue-stat-card completed">
|
||||
<span class="queue-stat-value">${isSolutionLevel ? (metadata.completed_solutions || 0) : (metadata.completed_tasks || queueItems.filter(i => i.status === 'completed').length)}</span>
|
||||
<span class="queue-stat-label">Completed</span>
|
||||
</div>
|
||||
<div class="queue-stat-card failed">
|
||||
<span class="queue-stat-value">${metadata.failed_count || queueItems.filter(i => i.status === 'failed').length}</span>
|
||||
<span class="queue-stat-label">Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="queue-info mb-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
|
||||
${t('issues.reorderHint') || 'Drag items within a group to reorder. Click item to view details.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="queue-timeline">
|
||||
${groups.map(group => renderQueueGroupWithDelete(group, groupedItems[group.id] || groupMap[group.id] || [], queueId)).join('')}
|
||||
</div>
|
||||
|
||||
${queue.conflicts && queue.conflicts.length > 0 ? renderConflictsSection(queue.conflicts) : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
// Async loader for expanded queue view - renders into DOM container
|
||||
async function loadAndRenderExpandedQueue(queueId) {
|
||||
const wrapper = document.getElementById('queueExpandedWrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
try {
|
||||
const html = await renderExpandedQueueView(queueId);
|
||||
wrapper.innerHTML = html;
|
||||
// Re-init icons and drag-drop after DOM update
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
// Initialize drag-drop for queue items
|
||||
initQueueDragDrop();
|
||||
} catch (err) {
|
||||
console.error('Failed to load expanded queue:', err);
|
||||
wrapper.innerHTML = `
|
||||
<div class="text-center py-8 text-red-500">
|
||||
<i data-lucide="alert-circle" class="w-8 h-8 mx-auto mb-2"></i>
|
||||
<p>Failed to load queue: ${escapeHtml(err.message || 'Unknown error')}</p>
|
||||
<button class="btn-secondary mt-4" onclick="queueData.expandedQueueId = null; renderIssueView();">
|
||||
<i data-lucide="arrow-left" class="w-4 h-4"></i> Back
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderQueueGroupWithDelete(group, items, queueId) {
|
||||
const isParallel = group.type === 'parallel';
|
||||
const itemCount = group.solution_count || group.task_count || items.length;
|
||||
const itemLabel = group.solution_count ? 'solutions' : 'tasks';
|
||||
|
||||
return `
|
||||
<div class="queue-group" data-group-id="${group.id}">
|
||||
<div class="queue-group-header">
|
||||
<div class="queue-group-type ${isParallel ? 'parallel' : 'sequential'}">
|
||||
<i data-lucide="${isParallel ? 'git-merge' : 'arrow-right'}" class="w-4 h-4"></i>
|
||||
${group.id} (${isParallel ? 'Parallel' : 'Sequential'})
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${itemCount} ${itemLabel}</span>
|
||||
</div>
|
||||
<div class="queue-items ${isParallel ? 'parallel' : 'sequential'}">
|
||||
${items.map((item, idx) => renderQueueItemWithDelete(item, idx, items.length, queueId)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderQueueItemWithDelete(item, index, total, queueId) {
|
||||
const statusColors = {
|
||||
pending: '',
|
||||
ready: 'ready',
|
||||
executing: 'executing',
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
blocked: 'blocked'
|
||||
};
|
||||
|
||||
const isSolutionItem = item.task_count !== undefined;
|
||||
const safeItemId = escapeHtml(item.item_id || '');
|
||||
const safeIssueId = escapeHtml(item.issue_id || '');
|
||||
const safeQueueId = escapeHtml(queueId || '');
|
||||
const safeSolutionId = escapeHtml(item.solution_id || '');
|
||||
const safeTaskId = escapeHtml(item.task_id || '-');
|
||||
const safeFilesTouched = item.files_touched ? escapeHtml(item.files_touched.join(', ')) : '';
|
||||
const safeDependsOn = item.depends_on ? escapeHtml(item.depends_on.join(', ')) : '';
|
||||
|
||||
return `
|
||||
<div class="queue-item ${statusColors[item.status] || ''}"
|
||||
draggable="true"
|
||||
data-item-id="${safeItemId}"
|
||||
data-group-id="${escapeHtml(item.execution_group || '')}"
|
||||
onclick="openQueueItemDetail('${safeItemId}')">
|
||||
<span class="queue-item-id font-mono text-xs">${safeItemId}</span>
|
||||
<span class="queue-item-issue text-xs text-muted-foreground">${safeIssueId}</span>
|
||||
${isSolutionItem ? `
|
||||
<span class="queue-item-solution text-sm" title="${safeSolutionId}">
|
||||
<i data-lucide="package" class="w-3 h-3 inline mr-1"></i>
|
||||
${item.task_count} tasks
|
||||
</span>
|
||||
${item.files_touched && item.files_touched.length > 0 ? `
|
||||
<span class="queue-item-files text-xs text-muted-foreground" title="${safeFilesTouched}">
|
||||
<i data-lucide="file" class="w-3 h-3"></i>
|
||||
${item.files_touched.length}
|
||||
</span>
|
||||
` : ''}
|
||||
` : `
|
||||
<span class="queue-item-task text-sm">${safeTaskId}</span>
|
||||
`}
|
||||
<span class="queue-item-priority" style="opacity: ${item.semantic_priority || 0.5}">
|
||||
<i data-lucide="arrow-up" class="w-3 h-3"></i>
|
||||
</span>
|
||||
${item.depends_on && item.depends_on.length > 0 ? `
|
||||
<span class="queue-item-deps text-xs text-muted-foreground" title="Depends on: ${safeDependsOn}">
|
||||
<i data-lucide="link" class="w-3 h-3"></i>
|
||||
</span>
|
||||
` : ''}
|
||||
<button class="queue-item-delete btn-icon" onclick="event.stopPropagation(); deleteQueueItem('${safeQueueId}', '${safeItemId}')" title="Delete item">
|
||||
<i data-lucide="trash-2" class="w-3 h-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function deleteQueueItem(queueId, itemId) {
|
||||
if (!confirm('Delete this item from queue?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/queue/' + queueId + '/item/' + encodeURIComponent(itemId) + '?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('Item deleted from queue', 'success');
|
||||
await Promise.all([loadQueueData(), loadAllQueues()]);
|
||||
renderIssueView();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to delete item', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete queue item:', err);
|
||||
showNotification('Failed to delete item', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshExpandedQueue(queueId) {
|
||||
await Promise.all([loadQueueData(), loadAllQueues()]);
|
||||
renderIssueView();
|
||||
}
|
||||
|
||||
// ========== Queue Merge Modal ==========
|
||||
function showMergeQueueModal(sourceQueueId) {
|
||||
let modal = document.getElementById('mergeQueueModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'mergeQueueModal';
|
||||
modal.className = 'issue-modal';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
const otherQueues = queueData.queues.filter(q =>
|
||||
q.id !== sourceQueueId && q.status !== 'merged'
|
||||
);
|
||||
|
||||
const safeSourceId = escapeHtml(sourceQueueId || '');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="issue-modal-backdrop" onclick="hideMergeQueueModal()"></div>
|
||||
<div class="issue-modal-content" style="max-width: 500px;">
|
||||
<div class="issue-modal-header">
|
||||
<h3><i data-lucide="git-merge" class="w-5 h-5 inline mr-2"></i>Merge Queue</h3>
|
||||
<button class="btn-icon" onclick="hideMergeQueueModal()">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="issue-modal-body">
|
||||
<p class="mb-4">Merge <strong class="font-mono">${safeSourceId}</strong> into another queue:</p>
|
||||
${otherQueues.length === 0 ? `
|
||||
<p class="text-muted-foreground text-center py-4">No other queues available for merging</p>
|
||||
` : `
|
||||
<div class="form-group">
|
||||
<label>Target Queue</label>
|
||||
<select id="targetQueueSelect" class="w-full">
|
||||
${otherQueues.map(q => `
|
||||
<option value="${escapeHtml(q.id)}">${escapeHtml(q.id)} (${q.total_solutions || q.total_tasks || 0} items)</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
|
||||
Items from source queue will be appended to target queue. Source queue will be marked as "merged".
|
||||
</p>
|
||||
`}
|
||||
</div>
|
||||
<div class="issue-modal-footer">
|
||||
<button class="btn-secondary" onclick="hideMergeQueueModal()">Cancel</button>
|
||||
${otherQueues.length > 0 ? `
|
||||
<button class="btn-primary" onclick="executeQueueMerge('${safeSourceId}')">
|
||||
<i data-lucide="git-merge" class="w-4 h-4"></i>
|
||||
Merge
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function hideMergeQueueModal() {
|
||||
const modal = document.getElementById('mergeQueueModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function executeQueueMerge(sourceQueueId) {
|
||||
const targetQueueId = document.getElementById('targetQueueSelect')?.value;
|
||||
if (!targetQueueId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/queue/merge?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourceQueueId, targetQueueId })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('Merged ' + result.mergedItemCount + ' items into ' + targetQueueId, 'success');
|
||||
hideMergeQueueModal();
|
||||
queueData.expandedQueueId = null;
|
||||
await Promise.all([loadQueueData(), loadAllQueues()]);
|
||||
renderIssueView();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to merge queues', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to merge queues:', err);
|
||||
showNotification('Failed to merge queues', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Legacy Queue Render (for backward compatibility) ==========
|
||||
function renderLegacyQueueSection() {
|
||||
const queue = issueData.queue;
|
||||
const queueItems = queue.solutions || queue.tasks || [];
|
||||
const isSolutionLevel = !!(queue.solutions && queue.solutions.length > 0);
|
||||
const metadata = queue._metadata || {};
|
||||
|
||||
if (queueItems.length === 0) {
|
||||
return `<div class="queue-empty"><p>Queue is empty</p></div>`;
|
||||
}
|
||||
|
||||
const groups = queue.execution_groups || [];
|
||||
let groupedItems = queue.grouped_items || {};
|
||||
|
||||
// If no execution_groups, create a default grouping from queue items
|
||||
if (groups.length === 0 && queueItems.length > 0) {
|
||||
const groupMap = {};
|
||||
queueItems.forEach(item => {
|
||||
@@ -442,7 +1013,6 @@ function renderQueueSection() {
|
||||
groupMap[groupId].push(item);
|
||||
});
|
||||
|
||||
// Create synthetic groups
|
||||
const syntheticGroups = Object.keys(groupMap).map(groupId => ({
|
||||
id: groupId,
|
||||
type: 'sequential',
|
||||
@@ -450,7 +1020,6 @@ function renderQueueSection() {
|
||||
}));
|
||||
|
||||
return `
|
||||
<!-- Queue Header -->
|
||||
<div class="queue-toolbar mb-4">
|
||||
<div class="queue-stats">
|
||||
<div class="queue-info-card">
|
||||
@@ -866,6 +1435,23 @@ function renderIssueDetailPanel(issue) {
|
||||
`).join('') : '<p class="text-sm text-muted-foreground">' + (t('issues.noTasks') || 'No tasks') + '</p>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="detail-section issue-detail-actions">
|
||||
<label class="detail-label">${t('issues.actions') || 'Actions'}</label>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
${!issue._isArchived ? `
|
||||
<button class="btn-secondary btn-sm" onclick="confirmArchiveIssue('${issue.id}')">
|
||||
<i data-lucide="archive" class="w-4 h-4"></i>
|
||||
${t('issues.archive') || 'Archive'}
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn-secondary btn-sm btn-danger" onclick="confirmDeleteIssue('${issue.id}', ${issue._isArchived || false})">
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
${t('issues.delete') || 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -880,6 +1466,67 @@ function closeIssueDetail() {
|
||||
issueData.selectedIssue = null;
|
||||
}
|
||||
|
||||
// ========== Issue Delete & Archive ==========
|
||||
function confirmDeleteIssue(issueId, isArchived) {
|
||||
const msg = t('issues.confirmDeleteIssue') || 'Are you sure you want to delete this issue? This action cannot be undone.';
|
||||
if (confirm(msg)) {
|
||||
deleteIssue(issueId, isArchived);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIssue(issueId, isArchived) {
|
||||
try {
|
||||
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showNotification(t('issues.issueDeleted') || 'Issue deleted successfully', 'success');
|
||||
closeIssueDetail();
|
||||
if (isArchived) {
|
||||
issueData.historyIssues = issueData.historyIssues.filter(i => i.id !== issueId);
|
||||
} else {
|
||||
issueData.issues = issueData.issues.filter(i => i.id !== issueId);
|
||||
}
|
||||
renderIssueView();
|
||||
updateIssueBadge();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to delete issue', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete issue:', err);
|
||||
showNotification('Failed to delete issue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmArchiveIssue(issueId) {
|
||||
const msg = t('issues.confirmArchiveIssue') || 'Archive this issue? It will be moved to history.';
|
||||
if (confirm(msg)) {
|
||||
archiveIssue(issueId);
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveIssue(issueId) {
|
||||
try {
|
||||
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '/archive?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showNotification(t('issues.issueArchived') || 'Issue archived successfully', 'success');
|
||||
closeIssueDetail();
|
||||
await loadIssueData();
|
||||
renderIssueView();
|
||||
updateIssueBadge();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to archive issue', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to archive issue:', err);
|
||||
showNotification('Failed to archive issue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSolutionExpand(solId) {
|
||||
const el = document.getElementById('solution-' + solId);
|
||||
if (el) {
|
||||
@@ -1234,6 +1881,20 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Helper: escape regex special characters
|
||||
function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Helper: highlight matching text in search results
|
||||
function highlightMatch(text, query) {
|
||||
if (!text || !query) return escapeHtml(text || '');
|
||||
const escaped = escapeHtml(text);
|
||||
const escapedQuery = escapeRegex(escapeHtml(query));
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
return escaped.replace(regex, '<mark class="search-highlight">$1</mark>');
|
||||
}
|
||||
|
||||
function openQueueItemDetail(itemId) {
|
||||
// Support both solution-level and task-level queues
|
||||
const items = issueData.queue.solutions || issueData.queue.tasks || [];
|
||||
@@ -1366,16 +2027,178 @@ async function updateTaskStatus(issueId, taskId, status) {
|
||||
}
|
||||
|
||||
// ========== Search Functions ==========
|
||||
var searchDebounceTimer = null;
|
||||
|
||||
function handleIssueSearch(value) {
|
||||
issueData.searchQuery = value;
|
||||
|
||||
// Update suggestions immediately (no debounce for dropdown)
|
||||
updateSearchSuggestions(value);
|
||||
issueData.showSuggestions = value.length > 0;
|
||||
issueData.selectedSuggestion = -1;
|
||||
updateSuggestionsDropdown();
|
||||
|
||||
// Clear previous timer
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
}
|
||||
|
||||
// 300ms debounce for full re-render to prevent freeze on rapid input
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
renderIssueView();
|
||||
// Restore input focus and cursor position
|
||||
const input = document.getElementById('issueSearchInput');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.setSelectionRange(value.length, value.length);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function clearIssueSearch() {
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
}
|
||||
issueData.searchQuery = '';
|
||||
issueData.showSuggestions = false;
|
||||
issueData.searchSuggestions = [];
|
||||
issueData.selectedSuggestion = -1;
|
||||
renderIssueView();
|
||||
}
|
||||
|
||||
// Update search suggestions based on query
|
||||
function updateSearchSuggestions(query) {
|
||||
if (!query || query.length < 1) {
|
||||
issueData.searchSuggestions = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const allIssues = [...issueData.issues, ...issueData.historyIssues];
|
||||
|
||||
// Find matching issues (max 6)
|
||||
issueData.searchSuggestions = allIssues
|
||||
.filter(issue => {
|
||||
const idMatch = issue.id.toLowerCase().includes(q);
|
||||
const titleMatch = issue.title && issue.title.toLowerCase().includes(q);
|
||||
const contextMatch = issue.context && issue.context.toLowerCase().includes(q);
|
||||
const solutionMatch = issue.solutions && issue.solutions.some(sol =>
|
||||
(sol.description && sol.description.toLowerCase().includes(q)) ||
|
||||
(sol.approach && sol.approach.toLowerCase().includes(q))
|
||||
);
|
||||
return idMatch || titleMatch || contextMatch || solutionMatch;
|
||||
})
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
// Render search suggestions dropdown
|
||||
function renderSearchSuggestions() {
|
||||
if (!issueData.searchSuggestions || issueData.searchSuggestions.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return issueData.searchSuggestions.map((issue, index) => `
|
||||
<div class="search-suggestion-item ${index === issueData.selectedSuggestion ? 'selected' : ''}"
|
||||
onclick="selectSuggestion(${index})"
|
||||
onmouseenter="issueData.selectedSuggestion = ${index}">
|
||||
<div class="suggestion-id">${highlightMatch(issue.id, issueData.searchQuery)}</div>
|
||||
<div class="suggestion-title">${highlightMatch(issue.title || issue.id, issueData.searchQuery)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Show search suggestions
|
||||
function showSearchSuggestions() {
|
||||
if (issueData.searchQuery) {
|
||||
updateSearchSuggestions(issueData.searchQuery);
|
||||
issueData.showSuggestions = true;
|
||||
updateSuggestionsDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Hide search suggestions
|
||||
function hideSearchSuggestions() {
|
||||
issueData.showSuggestions = false;
|
||||
issueData.selectedSuggestion = -1;
|
||||
const dropdown = document.getElementById('searchSuggestions');
|
||||
if (dropdown) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
// Update suggestions dropdown without full re-render
|
||||
function updateSuggestionsDropdown() {
|
||||
const dropdown = document.getElementById('searchSuggestions');
|
||||
if (dropdown) {
|
||||
dropdown.innerHTML = renderSearchSuggestions();
|
||||
if (issueData.showSuggestions && issueData.searchSuggestions.length > 0) {
|
||||
dropdown.classList.add('show');
|
||||
} else {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select a suggestion
|
||||
function selectSuggestion(index) {
|
||||
const issue = issueData.searchSuggestions[index];
|
||||
if (issue) {
|
||||
hideSearchSuggestions();
|
||||
openIssueDetail(issue.id, issue._isArchived);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard navigation in search
|
||||
function handleSearchKeydown(event) {
|
||||
const suggestions = issueData.searchSuggestions || [];
|
||||
|
||||
if (!issueData.showSuggestions || suggestions.length === 0) {
|
||||
// If Enter and no suggestions, just search
|
||||
if (event.key === 'Enter') {
|
||||
hideSearchSuggestions();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
issueData.selectedSuggestion = Math.min(
|
||||
issueData.selectedSuggestion + 1,
|
||||
suggestions.length - 1
|
||||
);
|
||||
updateSuggestionsDropdown();
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
issueData.selectedSuggestion = Math.max(issueData.selectedSuggestion - 1, -1);
|
||||
updateSuggestionsDropdown();
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (issueData.selectedSuggestion >= 0) {
|
||||
selectSuggestion(issueData.selectedSuggestion);
|
||||
} else {
|
||||
hideSearchSuggestions();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
hideSearchSuggestions();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const searchContainer = document.querySelector('.issue-search');
|
||||
if (searchContainer && !searchContainer.contains(event.target)) {
|
||||
hideSearchSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Create Issue Modal ==========
|
||||
function generateIssueId() {
|
||||
// Generate unique ID: ISSUE-YYYYMMDD-XXX format
|
||||
|
||||
@@ -956,15 +956,13 @@ function renderSkillFileModal() {
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-hidden p-4">
|
||||
<div class="flex-1 min-h-0 overflow-auto p-4">
|
||||
${isEditing ? `
|
||||
<textarea id="skillFileContent"
|
||||
class="w-full h-full min-h-[400px] px-4 py-3 bg-background border border-border rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
spellcheck="false">${escapeHtml(content)}</textarea>
|
||||
` : `
|
||||
<div class="w-full h-full min-h-[400px] overflow-auto">
|
||||
<pre class="px-4 py-3 bg-muted/30 rounded-lg text-sm font-mono whitespace-pre-wrap break-words">${escapeHtml(content)}</pre>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ const ParamsSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
cd: z.string().optional(),
|
||||
includeDirs: z.string().optional(),
|
||||
timeout: z.number().default(0), // 0 = no internal timeout, controlled by external caller (e.g., bash timeout)
|
||||
// timeout removed - controlled by external caller (bash timeout)
|
||||
resume: z.union([z.boolean(), z.string()]).optional(), // true = last, string = single ID or comma-separated IDs
|
||||
id: z.string().optional(), // Custom execution ID (e.g., IMPL-001-step1)
|
||||
noNative: z.boolean().optional(), // Force prompt concatenation instead of native resume
|
||||
@@ -388,7 +388,7 @@ async function executeCliTool(
|
||||
throw new Error(`Invalid params: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category, parentExecutionId, outputFormat } = parsed.data;
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, resume, id: customId, noNative, category, parentExecutionId, outputFormat } = parsed.data;
|
||||
|
||||
// Validate and determine working directory early (needed for conversation lookup)
|
||||
let workingDir: string;
|
||||
@@ -862,7 +862,6 @@ async function executeCliTool(
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
// Handle stdout
|
||||
child.stdout!.on('data', (data: Buffer) => {
|
||||
@@ -924,18 +923,14 @@ async function executeCliTool(
|
||||
debugLog('CLOSE', `Process closed`, {
|
||||
exitCode: code,
|
||||
duration: `${duration}ms`,
|
||||
timedOut,
|
||||
stdoutLength: stdout.length,
|
||||
stderrLength: stderr.length,
|
||||
outputUnitsCount: allOutputUnits.length
|
||||
});
|
||||
|
||||
// Determine status - prioritize output content over exit code
|
||||
let status: 'success' | 'error' | 'timeout' = 'success';
|
||||
if (timedOut) {
|
||||
status = 'timeout';
|
||||
debugLog('STATUS', `Execution timed out after ${duration}ms`);
|
||||
} else if (code !== 0) {
|
||||
let status: 'success' | 'error' = 'success';
|
||||
if (code !== 0) {
|
||||
// Non-zero exit code doesn't always mean failure
|
||||
// Check if there's valid output (AI response) - treat as success
|
||||
const hasValidOutput = stdout.trim().length > 0;
|
||||
@@ -1169,25 +1164,8 @@ async function executeCliTool(
|
||||
reject(new Error(`Failed to spawn ${tool}: ${error.message}\n Command: ${command} ${args.join(' ')}\n Working Dir: ${workingDir}`));
|
||||
});
|
||||
|
||||
// Timeout handling (timeout=0 disables internal timeout, controlled by external caller)
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
if (timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
child.on('close', () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
// Timeout controlled by external caller (bash timeout)
|
||||
// When parent process terminates, child will be cleaned up via process exit handler
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1228,12 +1206,8 @@ Modes:
|
||||
includeDirs: {
|
||||
type: 'string',
|
||||
description: 'Additional directories (comma-separated). Maps to --include-directories for gemini/qwen, --add-dir for codex'
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds (default: 0 = disabled, controlled by external caller)',
|
||||
default: 0
|
||||
}
|
||||
// timeout removed - controlled by external caller (bash timeout)
|
||||
},
|
||||
required: ['tool', 'prompt']
|
||||
}
|
||||
|
||||
@@ -3645,6 +3645,84 @@ def index_status(
|
||||
console.print(f" SPLADE encoder: {'[green]Yes[/green]' if splade_available else f'[red]No[/red] ({splade_err})'}")
|
||||
|
||||
|
||||
# ==================== Index Update Command ====================
|
||||
|
||||
@index_app.command("update")
|
||||
def index_update(
|
||||
file_path: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, help="Path to the file to update in the index."),
|
||||
json_mode: bool = typer.Option(False, "--json", help="Output JSON response."),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
|
||||
) -> None:
|
||||
"""Update the index for a single file incrementally.
|
||||
|
||||
This is a lightweight command designed for use in hooks (e.g., Claude Code PostToolUse).
|
||||
It updates only the specified file without scanning the entire directory.
|
||||
|
||||
The file's parent directory must already be indexed via 'codexlens index init'.
|
||||
|
||||
Examples:
|
||||
codexlens index update src/main.py # Update single file
|
||||
codexlens index update ./foo.ts --json # JSON output for hooks
|
||||
"""
|
||||
_configure_logging(verbose, json_mode)
|
||||
|
||||
from codexlens.watcher.incremental_indexer import IncrementalIndexer
|
||||
|
||||
registry: RegistryStore | None = None
|
||||
indexer: IncrementalIndexer | None = None
|
||||
|
||||
try:
|
||||
registry = RegistryStore()
|
||||
registry.initialize()
|
||||
mapper = PathMapper()
|
||||
config = Config()
|
||||
|
||||
resolved_path = file_path.resolve()
|
||||
|
||||
# Check if project is indexed
|
||||
source_root = mapper.get_project_root(resolved_path)
|
||||
if not source_root or not registry.get_project(source_root):
|
||||
error_msg = f"Project containing file is not indexed: {file_path}"
|
||||
if json_mode:
|
||||
print_json(success=False, error=error_msg)
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] {error_msg}")
|
||||
console.print("[dim]Run 'codexlens index init' on the project root first.[/dim]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
indexer = IncrementalIndexer(registry, mapper, config)
|
||||
result = indexer._index_file(resolved_path)
|
||||
|
||||
if result.success:
|
||||
if json_mode:
|
||||
print_json(success=True, result={
|
||||
"path": str(result.path),
|
||||
"symbols_count": result.symbols_count,
|
||||
"status": "updated",
|
||||
})
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Updated index for [bold]{result.path.name}[/bold] ({result.symbols_count} symbols)")
|
||||
else:
|
||||
error_msg = result.error or f"Failed to update index for {file_path}"
|
||||
if json_mode:
|
||||
print_json(success=False, error=error_msg)
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] {error_msg}")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
except CodexLensError as exc:
|
||||
if json_mode:
|
||||
print_json(success=False, error=str(exc))
|
||||
else:
|
||||
console.print(f"[red]Update failed:[/red] {exc}")
|
||||
raise typer.Exit(code=1)
|
||||
finally:
|
||||
if indexer:
|
||||
indexer.close()
|
||||
if registry:
|
||||
registry.close()
|
||||
|
||||
|
||||
# ==================== Index All Command ====================
|
||||
|
||||
@index_app.command("all")
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-code-workflow",
|
||||
"version": "6.3.23",
|
||||
"version": "6.3.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-code-workflow",
|
||||
"version": "6.3.23",
|
||||
"version": "6.3.31",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-workflow",
|
||||
"version": "6.3.29",
|
||||
"version": "6.3.31",
|
||||
"description": "JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution",
|
||||
"type": "module",
|
||||
"main": "ccw/src/index.js",
|
||||
|
||||
Reference in New Issue
Block a user