diff --git a/ccw/src/core/routes/issue-routes.ts b/ccw/src/core/routes/issue-routes.ts index e8590972..6fc14a2f 100644 --- a/ccw/src/core/routes/issue-routes.ts +++ b/ccw/src/core/routes/issue-routes.ts @@ -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 { 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 { // 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 { 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 { 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)); diff --git a/ccw/src/templates/dashboard-css/32-issue-manager.css b/ccw/src/templates/dashboard-css/32-issue-manager.css index 7ea95e25..256077fb 100644 --- a/ccw/src/templates/dashboard-css/32-issue-manager.css +++ b/ccw/src/templates/dashboard-css/32-issue-manager.css @@ -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; +} diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 7a6ab0ba..0a395a0c 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -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': '队列', diff --git a/ccw/src/templates/dashboard-js/views/issue-manager.js b/ccw/src/templates/dashboard-js/views/issue-manager.js index 5afaac7a..7df0c163 100644 --- a/ccw/src/templates/dashboard-js/views/issue-manager.js +++ b/ccw/src/templates/dashboard-js/views/issue-manager.js @@ -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() { ''; // 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 ? ` ` : ''} +
+ ${renderSearchSuggestions()} +
@@ -339,7 +383,7 @@ function renderIssueCard(issue) {
- ${issue.id} + ${highlightMatch(issue.id, issueData.searchQuery)} ${issue.status || 'unknown'} ${isArchived ? '' + (t('issues.archived') || 'Archived') + '' : ''}
@@ -348,7 +392,7 @@ function renderIssueCard(issue) {
-

${issue.title || issue.id}

+

${highlightMatch(issue.title || issue.id, issueData.searchQuery)}

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

${escapeHtml(expandedQueueId)}

+
+
+ + ${t('common.loading') || 'Loading...'} +
+
+ `; + } + + // Show multi-queue cards view + return ` + +
+
+

${t('issues.executionQueues') || 'Execution Queues'}

+ ${queues.length} ${t('issues.queues') || 'queues'} +
+
+ + +
+
+ + ${queues.length === 0 ? ` +
-

${t('issues.queueEmpty') || 'Queue is empty'}

+

${t('issues.noQueues') || 'No queues found'}

${t('issues.queueEmptyHint') || 'Generate execution queue from bound solutions'}

+ ` : ` + +
+ ${queues.map(q => renderQueueCard(q, q.id === activeQueueId)).join('')} +
+ `} + `; +} + +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 ` +
+
+ ${safeQueueId} +
+ ${isActive ? 'Active' : ''} + ${queue.status || 'unknown'} +
+
+ +
+
+
+
+
+ ${completedCount}/${itemCount} ${queue.total_solutions ? 'solutions' : 'tasks'} + ${progressPercent}% +
+
+ +
+ + + ${issueCount} issues + + + + ${queue.created_at ? new Date(queue.created_at).toLocaleDateString() : 'N/A'} + +
+ +
+ + ${!isActive && queue.status !== 'merged' ? ` + + ` : ''} + ${queue.status !== 'merged' ? ` + + ` : ''} +
+
+ `; +} + +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 ` +
+ +

Failed to load queue: ${escapeHtml(err.message)}

+
`; } - // 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 ` + +
+ +
+

${escapeHtml(queue.id || queueId)}

+
+ ${isActive ? 'Active' : ''} + ${escapeHtml(queue.status || 'unknown')} +
+
+
+ ${!isActive && queue.status !== 'merged' ? ` + + ` : ''} + ${isActive ? ` + + ` : ''} + +
+
+ + +
+
+ ${isSolutionLevel ? (metadata.total_solutions || queueItems.length) : (metadata.total_tasks || queueItems.length)} + ${isSolutionLevel ? 'Solutions' : 'Tasks'} +
+
+ ${metadata.pending_count || queueItems.filter(i => i.status === 'pending').length} + Pending +
+
+ ${metadata.executing_count || queueItems.filter(i => i.status === 'executing').length} + Executing +
+
+ ${isSolutionLevel ? (metadata.completed_solutions || 0) : (metadata.completed_tasks || queueItems.filter(i => i.status === 'completed').length)} + Completed +
+
+ ${metadata.failed_count || queueItems.filter(i => i.status === 'failed').length} + Failed +
+
+ +
+

+ + ${t('issues.reorderHint') || 'Drag items within a group to reorder. Click item to view details.'} +

+
+ +
+ ${groups.map(group => renderQueueGroupWithDelete(group, groupedItems[group.id] || groupMap[group.id] || [], queueId)).join('')} +
+ + ${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 = ` +
+ +

Failed to load queue: ${escapeHtml(err.message || 'Unknown error')}

+ +
+ `; + 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 ` +
+
+
+ + ${group.id} (${isParallel ? 'Parallel' : 'Sequential'}) +
+ ${itemCount} ${itemLabel} +
+
+ ${items.map((item, idx) => renderQueueItemWithDelete(item, idx, items.length, queueId)).join('')} +
+
+ `; +} + +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 ` +
+ ${safeItemId} + ${safeIssueId} + ${isSolutionItem ? ` + + + ${item.task_count} tasks + + ${item.files_touched && item.files_touched.length > 0 ? ` + + + ${item.files_touched.length} + + ` : ''} + ` : ` + ${safeTaskId} + `} + + + + ${item.depends_on && item.depends_on.length > 0 ? ` + + + + ` : ''} + +
+ `; +} + +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 = ` +
+
+
+

Merge Queue

+ +
+
+

Merge ${safeSourceId} into another queue:

+ ${otherQueues.length === 0 ? ` +

No other queues available for merging

+ ` : ` +
+ + +
+

+ + Items from source queue will be appended to target queue. Source queue will be marked as "merged". +

+ `} +
+ +
+ `; + + 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 `

Queue is empty

`; + } + 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 ` -
@@ -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, '$1'); +} + 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) => ` +
+
${highlightMatch(issue.id, issueData.searchQuery)}
+
${highlightMatch(issue.title || issue.id, issueData.searchQuery)}
+
+ `).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 diff --git a/package.json b/package.json index 64d59871..b592186c 100644 --- a/package.json +++ b/package.json @@ -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",