mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat: 增强议题搜索功能与多队列卡片界面优化
搜索增强: - 添加防抖处理修复快速输入导致页面卡死的问题 - 扩展搜索范围至解决方案的描述和方法字段 - 新增搜索结果高亮显示匹配关键词 - 添加搜索下拉建议,支持键盘导航 多队列界面: - 优化队列展开视图的卡片布局使用CSS Grid - 添加取消激活队列功能及API端点 - 改进状态颜色分布和统计卡片样式 - 添加激活/取消激活按钮的中文国际化 修复: - 修复路由冲突导致的deactivate 404错误 - 修复异步加载后拖拽排序失效的问题
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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': '队列',
|
||||
|
||||
@@ -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,479 @@ function renderQueueSection() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<!-- Queue Cards Grid -->
|
||||
<div class="queue-cards-grid">
|
||||
${queues.map(q => renderQueueCard(q, q.id === activeQueueId)).join('')}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
// Group items by execution_group or treat all as single group
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user