Compare commits

...

6 Commits

Author SHA1 Message Date
catlog22
623afc1d35 6.3.31 2026-01-15 22:30:57 +08:00
catlog22
085652560a refactor: 移除 ccw cli 内部超时参数,改由外部 bash 控制
- 移除 --timeout 命令行选项和内部超时处理逻辑
- 进程生命周期跟随父进程(bash)状态
- 简化代码,超时控制交由外部调用者管理
2026-01-15 22:30:22 +08:00
catlog22
af4ddb1280 feat: 添加队列和议题删除功能,支持归档议题 2026-01-15 19:58:54 +08:00
catlog22
7db659f0e1 feat: 增强议题搜索功能与多队列卡片界面优化
搜索增强:
- 添加防抖处理修复快速输入导致页面卡死的问题
- 扩展搜索范围至解决方案的描述和方法字段
- 新增搜索结果高亮显示匹配关键词
- 添加搜索下拉建议,支持键盘导航

多队列界面:
- 优化队列展开视图的卡片布局使用CSS Grid
- 添加取消激活队列功能及API端点
- 改进状态颜色分布和统计卡片样式
- 添加激活/取消激活按钮的中文国际化

修复:
- 修复路由冲突导致的deactivate 404错误
- 修复异步加载后拖拽排序失效的问题
2026-01-15 19:44:44 +08:00
catlog22
ba526ea09e fix: 修复 Dashboard 概况页面无法显示项目信息的问题
添加 extractStringArray 辅助函数来处理混合数组类型(字符串数组和对象数组),
使 loadProjectOverview 函数能够正确处理 project-tech.json 中的数据结构。

修复的字段包括:
- languages: 对象数组 [{name, file_count, primary}] → 字符串数组
- frameworks: 保持兼容字符串数组
- key_components: 对象数组 [{name, description, path}] → 字符串数组
- layers/patterns: 保持兼容混合类型

Closes #79
2026-01-15 18:58:42 +08:00
catlog22
c308e429f8 feat: 添加增量更新命令以支持单文件索引更新 2026-01-15 18:14:51 +08:00
13 changed files with 1827 additions and 126 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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': '队列',

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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