feat: 增强议题搜索功能与多队列卡片界面优化

搜索增强:
- 添加防抖处理修复快速输入导致页面卡死的问题
- 扩展搜索范围至解决方案的描述和方法字段
- 新增搜索结果高亮显示匹配关键词
- 添加搜索下拉建议,支持键盘导航

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

修复:
- 修复路由冲突导致的deactivate 404错误
- 修复异步加载后拖拽排序失效的问题
This commit is contained in:
catlog22
2026-01-15 19:44:44 +08:00
parent ba526ea09e
commit 7db659f0e1
5 changed files with 1472 additions and 70 deletions

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 ==========
@@ -156,7 +156,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 +277,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 +353,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 +406,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 +481,195 @@ 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;
}
// 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));

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,223 @@
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%);
}
/* 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,15 @@ 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.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 +4601,15 @@ const i18n = {
'issues.queueCommandInfo': '运行命令后,点击"刷新"查看更新后的队列。',
'issues.alternative': '或者',
'issues.refreshAfter': '刷新队列',
'issues.activate': '激活',
'issues.deactivate': '取消激活',
'issues.queueActivated': '队列已激活',
'issues.queueDeactivated': '队列已取消激活',
'issues.executionQueues': '执行队列',
'issues.queues': '个队列',
'issues.noQueues': '暂无队列',
'issues.queueEmptyHint': '从绑定的解决方案生成执行队列',
'issues.refresh': '刷新',
// issue.* keys (legacy)
'issue.viewIssues': '议题',
'issue.viewQueue': '队列',

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,11 +149,24 @@ function renderIssueView() {
if (issueData.searchQuery) {
const query = issueData.searchQuery.toLowerCase();
filteredIssues = filteredIssues.filter(i =>
i.id.toLowerCase().includes(query) ||
(i.title && i.title.toLowerCase().includes(query)) ||
(i.context && i.context.toLowerCase().includes(query))
);
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));
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 = `
@@ -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,479 @@ 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>
` : ''}
</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');
}
}
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 +983,6 @@ function renderQueueSection() {
groupMap[groupId].push(item);
});
// Create synthetic groups
const syntheticGroups = Object.keys(groupMap).map(groupId => ({
id: groupId,
type: 'sequential',
@@ -450,7 +990,6 @@ function renderQueueSection() {
}));
return `
<!-- Queue Header -->
<div class="queue-toolbar mb-4">
<div class="queue-stats">
<div class="queue-info-card">
@@ -1234,6 +1773,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 +1919,178 @@ async function updateTaskStatus(issueId, taskId, status) {
}
// ========== Search Functions ==========
var searchDebounceTimer = null;
function handleIssueSearch(value) {
issueData.searchQuery = value;
renderIssueView();
// 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

@@ -1,6 +1,6 @@
{
"name": "claude-code-workflow",
"version": "6.3.29",
"version": "6.3.30",
"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",