mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: add CLI Stream Viewer component for real-time output monitoring
- Implemented a new CLI Stream Viewer to display real-time output from CLI executions. - Added state management for CLI executions, including handling of start, output, completion, and errors. - Introduced UI rendering for stream tabs and content, with auto-scroll functionality. - Integrated keyboard shortcuts for toggling the viewer and handling user interactions. feat: create Issue Manager view for managing issues and execution queue - Developed the Issue Manager view to manage issues, solutions, and execution queue. - Implemented data loading functions for fetching issues and queue data from the API. - Added filtering and rendering logic for issues and queue items, including drag-and-drop functionality. - Created detail panel for viewing and editing issue details, including tasks and solutions.
This commit is contained in:
@@ -278,6 +278,11 @@ export function run(argv: string[]): void {
|
||||
.option('--format <fmt>', 'Output format: json, markdown')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--force', 'Force operation')
|
||||
// New options for solution/queue management
|
||||
.option('--solution <path>', 'Solution JSON file path')
|
||||
.option('--solution-id <id>', 'Solution ID')
|
||||
.option('--result <json>', 'Execution result JSON')
|
||||
.option('--reason <text>', 'Failure reason')
|
||||
.action((subcommand, args, options) => issueCommand(subcommand, args, options));
|
||||
|
||||
program.parse(argv);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
512
ccw/src/core/routes/issue-routes.ts
Normal file
512
ccw/src/core/routes/issue-routes.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Issue Routes Module (Optimized - Flat JSONL Storage)
|
||||
*
|
||||
* Storage Structure:
|
||||
* .workflow/issues/
|
||||
* ├── issues.jsonl # All issues (one per line)
|
||||
* ├── queue.json # Execution queue
|
||||
* └── solutions/
|
||||
* ├── {issue-id}.jsonl # Solutions for issue (one per line)
|
||||
* └── ...
|
||||
*
|
||||
* API Endpoints (8 total):
|
||||
* - GET /api/issues - List all issues
|
||||
* - POST /api/issues - Create new issue
|
||||
* - GET /api/issues/:id - Get issue detail
|
||||
* - PATCH /api/issues/:id - Update issue (includes binding logic)
|
||||
* - DELETE /api/issues/:id - Delete issue
|
||||
* - POST /api/issues/:id/solutions - Add solution
|
||||
* - PATCH /api/issues/:id/tasks/:taskId - Update task
|
||||
* - GET /api/queue - Get execution queue
|
||||
* - POST /api/queue/reorder - Reorder queue items
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface RouteContext {
|
||||
pathname: string;
|
||||
url: URL;
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
initialPath: string;
|
||||
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
|
||||
broadcastToClients: (data: unknown) => void;
|
||||
}
|
||||
|
||||
// ========== JSONL Helper Functions ==========
|
||||
|
||||
function readIssuesJsonl(issuesDir: string): any[] {
|
||||
const issuesPath = join(issuesDir, 'issues.jsonl');
|
||||
if (!existsSync(issuesPath)) return [];
|
||||
try {
|
||||
const content = readFileSync(issuesPath, 'utf8');
|
||||
return content.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeIssuesJsonl(issuesDir: string, issues: any[]) {
|
||||
if (!existsSync(issuesDir)) mkdirSync(issuesDir, { recursive: true });
|
||||
const issuesPath = join(issuesDir, 'issues.jsonl');
|
||||
writeFileSync(issuesPath, issues.map(i => JSON.stringify(i)).join('\n'));
|
||||
}
|
||||
|
||||
function readSolutionsJsonl(issuesDir: string, issueId: string): any[] {
|
||||
const solutionsPath = join(issuesDir, 'solutions', `${issueId}.jsonl`);
|
||||
if (!existsSync(solutionsPath)) return [];
|
||||
try {
|
||||
const content = readFileSync(solutionsPath, 'utf8');
|
||||
return content.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeSolutionsJsonl(issuesDir: string, issueId: string, solutions: any[]) {
|
||||
const solutionsDir = join(issuesDir, 'solutions');
|
||||
if (!existsSync(solutionsDir)) mkdirSync(solutionsDir, { recursive: true });
|
||||
writeFileSync(join(solutionsDir, `${issueId}.jsonl`), solutions.map(s => JSON.stringify(s)).join('\n'));
|
||||
}
|
||||
|
||||
function readQueue(issuesDir: string) {
|
||||
const queuePath = join(issuesDir, 'queue.json');
|
||||
if (!existsSync(queuePath)) {
|
||||
return { queue: [], conflicts: [], execution_groups: [], _metadata: { version: '1.0', total_tasks: 0 } };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(queuePath, 'utf8'));
|
||||
} catch {
|
||||
return { queue: [], conflicts: [], execution_groups: [], _metadata: { version: '1.0', total_tasks: 0 } };
|
||||
}
|
||||
}
|
||||
|
||||
function writeQueue(issuesDir: string, queue: any) {
|
||||
if (!existsSync(issuesDir)) mkdirSync(issuesDir, { recursive: true });
|
||||
queue._metadata = { ...queue._metadata, last_updated: new Date().toISOString(), total_tasks: queue.queue?.length || 0 };
|
||||
writeFileSync(join(issuesDir, 'queue.json'), JSON.stringify(queue, null, 2));
|
||||
}
|
||||
|
||||
function getIssueDetail(issuesDir: string, issueId: string) {
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
if (!issue) return null;
|
||||
|
||||
const solutions = readSolutionsJsonl(issuesDir, issueId);
|
||||
let tasks: any[] = [];
|
||||
if (issue.bound_solution_id) {
|
||||
const boundSol = solutions.find(s => s.id === issue.bound_solution_id);
|
||||
if (boundSol?.tasks) tasks = boundSol.tasks;
|
||||
}
|
||||
return { ...issue, solutions, tasks };
|
||||
}
|
||||
|
||||
function enrichIssues(issues: any[], issuesDir: string) {
|
||||
return issues.map(issue => ({
|
||||
...issue,
|
||||
solution_count: readSolutionsJsonl(issuesDir, issue.id).length
|
||||
}));
|
||||
}
|
||||
|
||||
function groupQueueByExecutionGroup(queue: any) {
|
||||
const groups: { [key: string]: any[] } = {};
|
||||
for (const item of queue.queue || []) {
|
||||
const groupId = item.execution_group || 'ungrouped';
|
||||
if (!groups[groupId]) groups[groupId] = [];
|
||||
groups[groupId].push(item);
|
||||
}
|
||||
for (const groupId of Object.keys(groups)) {
|
||||
groups[groupId].sort((a, b) => (a.execution_order || 0) - (b.execution_order || 0));
|
||||
}
|
||||
const executionGroups = Object.entries(groups).map(([id, items]) => ({
|
||||
id,
|
||||
type: id.startsWith('P') ? 'parallel' : id.startsWith('S') ? 'sequential' : 'unknown',
|
||||
task_count: items.length,
|
||||
tasks: items.map(i => i.queue_id)
|
||||
})).sort((a, b) => {
|
||||
const aFirst = groups[a.id]?.[0]?.execution_order || 0;
|
||||
const bFirst = groups[b.id]?.[0]?.execution_order || 0;
|
||||
return aFirst - bFirst;
|
||||
});
|
||||
return { ...queue, execution_groups: executionGroups, grouped_items: groups };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind solution to issue with proper side effects
|
||||
*/
|
||||
function bindSolutionToIssue(issuesDir: string, issueId: string, solutionId: string, issues: any[], issueIndex: number) {
|
||||
const solutions = readSolutionsJsonl(issuesDir, issueId);
|
||||
const solIndex = solutions.findIndex(s => s.id === solutionId);
|
||||
|
||||
if (solIndex === -1) return { error: `Solution ${solutionId} not found` };
|
||||
|
||||
// Unbind all, bind new
|
||||
solutions.forEach(s => { s.is_bound = false; });
|
||||
solutions[solIndex].is_bound = true;
|
||||
solutions[solIndex].bound_at = new Date().toISOString();
|
||||
writeSolutionsJsonl(issuesDir, issueId, solutions);
|
||||
|
||||
// Update issue
|
||||
issues[issueIndex].bound_solution_id = solutionId;
|
||||
issues[issueIndex].status = 'planned';
|
||||
issues[issueIndex].planned_at = new Date().toISOString();
|
||||
|
||||
return { success: true, bound: solutionId };
|
||||
}
|
||||
|
||||
// ========== 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 issuesDir = join(projectPath, '.workflow', 'issues');
|
||||
|
||||
// ===== Queue Routes (top-level /api/queue) =====
|
||||
|
||||
// GET /api/queue - Get execution queue
|
||||
if (pathname === '/api/queue' && req.method === 'GET') {
|
||||
const queue = groupQueueByExecutionGroup(readQueue(issuesDir));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(queue));
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/reorder - Reorder queue items
|
||||
if (pathname === '/api/queue/reorder' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const { groupId, newOrder } = body;
|
||||
if (!groupId || !Array.isArray(newOrder)) {
|
||||
return { error: 'groupId and newOrder (array) required' };
|
||||
}
|
||||
|
||||
const queue = readQueue(issuesDir);
|
||||
const groupItems = queue.queue.filter((item: any) => item.execution_group === groupId);
|
||||
const otherItems = queue.queue.filter((item: any) => item.execution_group !== groupId);
|
||||
|
||||
if (groupItems.length === 0) return { error: `No items in group ${groupId}` };
|
||||
|
||||
const groupQueueIds = new Set(groupItems.map((i: any) => i.queue_id));
|
||||
if (groupQueueIds.size !== new Set(newOrder).size) {
|
||||
return { error: 'newOrder must contain all group items' };
|
||||
}
|
||||
for (const id of newOrder) {
|
||||
if (!groupQueueIds.has(id)) return { error: `Invalid queue_id: ${id}` };
|
||||
}
|
||||
|
||||
const itemMap = new Map(groupItems.map((i: any) => [i.queue_id, i]));
|
||||
const reorderedItems = newOrder.map((qid: string, idx: number) => ({ ...itemMap.get(qid), _idx: idx }));
|
||||
const newQueue = [...otherItems, ...reorderedItems].sort((a, b) => {
|
||||
const aGroup = parseInt(a.execution_group?.match(/\d+/)?.[0] || '999');
|
||||
const bGroup = parseInt(b.execution_group?.match(/\d+/)?.[0] || '999');
|
||||
if (aGroup !== bGroup) return aGroup - bGroup;
|
||||
if (a.execution_group === b.execution_group) {
|
||||
return (a._idx ?? a.execution_order ?? 999) - (b._idx ?? b.execution_order ?? 999);
|
||||
}
|
||||
return (a.execution_order || 0) - (b.execution_order || 0);
|
||||
});
|
||||
|
||||
newQueue.forEach((item, idx) => { item.execution_order = idx + 1; delete item._idx; });
|
||||
queue.queue = newQueue;
|
||||
writeQueue(issuesDir, queue);
|
||||
|
||||
return { success: true, groupId, reordered: newOrder.length };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy: GET /api/issues/queue (backward compat)
|
||||
if (pathname === '/api/issues/queue' && req.method === 'GET') {
|
||||
const queue = groupQueueByExecutionGroup(readQueue(issuesDir));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(queue));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== Issue Routes =====
|
||||
|
||||
// GET /api/issues - List all issues
|
||||
if (pathname === '/api/issues' && req.method === 'GET') {
|
||||
const issues = enrichIssues(readIssuesJsonl(issuesDir), issuesDir);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
issues,
|
||||
_metadata: { version: '2.0', storage: 'jsonl', total_issues: issues.length, last_updated: new Date().toISOString() }
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/issues - Create issue
|
||||
if (pathname === '/api/issues' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
if (!body.id || !body.title) return { error: 'id and title required' };
|
||||
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
if (issues.find(i => i.id === body.id)) return { error: `Issue ${body.id} exists` };
|
||||
|
||||
const newIssue = {
|
||||
id: body.id,
|
||||
title: body.title,
|
||||
status: body.status || 'registered',
|
||||
priority: body.priority || 3,
|
||||
context: body.context || '',
|
||||
source: body.source || 'text',
|
||||
source_url: body.source_url || null,
|
||||
labels: body.labels || [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
issues.push(newIssue);
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
return { success: true, issue: newIssue };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/issues/:id - Get issue detail
|
||||
const detailMatch = pathname.match(/^\/api\/issues\/([^/]+)$/);
|
||||
if (detailMatch && req.method === 'GET') {
|
||||
const issueId = decodeURIComponent(detailMatch[1]);
|
||||
if (issueId === 'queue') return false;
|
||||
|
||||
const detail = getIssueDetail(issuesDir, issueId);
|
||||
if (!detail) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(detail));
|
||||
return true;
|
||||
}
|
||||
|
||||
// PATCH /api/issues/:id - Update issue (with binding support)
|
||||
const updateMatch = pathname.match(/^\/api\/issues\/([^/]+)$/);
|
||||
if (updateMatch && req.method === 'PATCH') {
|
||||
const issueId = decodeURIComponent(updateMatch[1]);
|
||||
if (issueId === 'queue') return false;
|
||||
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issueIndex = issues.findIndex(i => i.id === issueId);
|
||||
if (issueIndex === -1) return { error: 'Issue not found' };
|
||||
|
||||
const updates: string[] = [];
|
||||
|
||||
// Handle binding if bound_solution_id provided
|
||||
if (body.bound_solution_id !== undefined) {
|
||||
if (body.bound_solution_id) {
|
||||
const bindResult = bindSolutionToIssue(issuesDir, issueId, body.bound_solution_id, issues, issueIndex);
|
||||
if (bindResult.error) return bindResult;
|
||||
updates.push('bound_solution_id');
|
||||
} else {
|
||||
// Unbind
|
||||
const solutions = readSolutionsJsonl(issuesDir, issueId);
|
||||
solutions.forEach(s => { s.is_bound = false; });
|
||||
writeSolutionsJsonl(issuesDir, issueId, solutions);
|
||||
issues[issueIndex].bound_solution_id = null;
|
||||
updates.push('bound_solution_id (unbound)');
|
||||
}
|
||||
}
|
||||
|
||||
// Update other fields
|
||||
for (const field of ['title', 'context', 'status', 'priority', 'labels']) {
|
||||
if (body[field] !== undefined) {
|
||||
issues[issueIndex][field] = body[field];
|
||||
updates.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
issues[issueIndex].updated_at = new Date().toISOString();
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
return { success: true, issueId, updated: updates };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// DELETE /api/issues/:id
|
||||
const deleteMatch = pathname.match(/^\/api\/issues\/([^/]+)$/);
|
||||
if (deleteMatch && req.method === 'DELETE') {
|
||||
const issueId = decodeURIComponent(deleteMatch[1]);
|
||||
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const filtered = issues.filter(i => i.id !== issueId);
|
||||
if (filtered.length === issues.length) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
writeIssuesJsonl(issuesDir, filtered);
|
||||
|
||||
// Clean up solutions file
|
||||
const solPath = join(issuesDir, 'solutions', `${issueId}.jsonl`);
|
||||
if (existsSync(solPath)) {
|
||||
try { unlinkSync(solPath); } catch {}
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, issueId }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/issues/:id/solutions - Add solution
|
||||
const addSolMatch = pathname.match(/^\/api\/issues\/([^/]+)\/solutions$/);
|
||||
if (addSolMatch && req.method === 'POST') {
|
||||
const issueId = decodeURIComponent(addSolMatch[1]);
|
||||
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
if (!body.id || !body.tasks) return { error: 'id and tasks required' };
|
||||
|
||||
const solutions = readSolutionsJsonl(issuesDir, issueId);
|
||||
if (solutions.find(s => s.id === body.id)) return { error: `Solution ${body.id} exists` };
|
||||
|
||||
const newSolution = {
|
||||
id: body.id,
|
||||
description: body.description || '',
|
||||
tasks: body.tasks,
|
||||
exploration_context: body.exploration_context || {},
|
||||
analysis: body.analysis || {},
|
||||
score: body.score || 0,
|
||||
is_bound: false,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
solutions.push(newSolution);
|
||||
writeSolutionsJsonl(issuesDir, issueId, solutions);
|
||||
|
||||
// Update issue solution_count
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const idx = issues.findIndex(i => i.id === issueId);
|
||||
if (idx !== -1) {
|
||||
issues[idx].solution_count = solutions.length;
|
||||
issues[idx].updated_at = new Date().toISOString();
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
}
|
||||
|
||||
return { success: true, solution: newSolution };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// PATCH /api/issues/:id/tasks/:taskId - Update task
|
||||
const taskMatch = pathname.match(/^\/api\/issues\/([^/]+)\/tasks\/([^/]+)$/);
|
||||
if (taskMatch && req.method === 'PATCH') {
|
||||
const issueId = decodeURIComponent(taskMatch[1]);
|
||||
const taskId = decodeURIComponent(taskMatch[2]);
|
||||
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
if (!issue?.bound_solution_id) return { error: 'Issue or bound solution not found' };
|
||||
|
||||
const solutions = readSolutionsJsonl(issuesDir, issueId);
|
||||
const solIdx = solutions.findIndex(s => s.id === issue.bound_solution_id);
|
||||
if (solIdx === -1) return { error: 'Bound solution not found' };
|
||||
|
||||
const taskIdx = solutions[solIdx].tasks?.findIndex((t: any) => t.id === taskId);
|
||||
if (taskIdx === -1 || taskIdx === undefined) return { error: 'Task not found' };
|
||||
|
||||
const updates: string[] = [];
|
||||
for (const field of ['status', 'priority', 'result', 'error']) {
|
||||
if (body[field] !== undefined) {
|
||||
solutions[solIdx].tasks[taskIdx][field] = body[field];
|
||||
updates.push(field);
|
||||
}
|
||||
}
|
||||
solutions[solIdx].tasks[taskIdx].updated_at = new Date().toISOString();
|
||||
writeSolutionsJsonl(issuesDir, issueId, solutions);
|
||||
|
||||
return { success: true, issueId, taskId, updated: updates };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy: PUT /api/issues/:id/task/:taskId (backward compat)
|
||||
const legacyTaskMatch = pathname.match(/^\/api\/issues\/([^/]+)\/task\/([^/]+)$/);
|
||||
if (legacyTaskMatch && req.method === 'PUT') {
|
||||
const issueId = decodeURIComponent(legacyTaskMatch[1]);
|
||||
const taskId = decodeURIComponent(legacyTaskMatch[2]);
|
||||
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
if (!issue?.bound_solution_id) return { error: 'Issue or bound solution not found' };
|
||||
|
||||
const solutions = readSolutionsJsonl(issuesDir, issueId);
|
||||
const solIdx = solutions.findIndex(s => s.id === issue.bound_solution_id);
|
||||
if (solIdx === -1) return { error: 'Bound solution not found' };
|
||||
|
||||
const taskIdx = solutions[solIdx].tasks?.findIndex((t: any) => t.id === taskId);
|
||||
if (taskIdx === -1 || taskIdx === undefined) return { error: 'Task not found' };
|
||||
|
||||
const updates: string[] = [];
|
||||
if (body.status !== undefined) { solutions[solIdx].tasks[taskIdx].status = body.status; updates.push('status'); }
|
||||
if (body.priority !== undefined) { solutions[solIdx].tasks[taskIdx].priority = body.priority; updates.push('priority'); }
|
||||
solutions[solIdx].tasks[taskIdx].updated_at = new Date().toISOString();
|
||||
writeSolutionsJsonl(issuesDir, issueId, solutions);
|
||||
|
||||
return { success: true, issueId, taskId, updated: updates };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy: PUT /api/issues/:id/bind/:solutionId (backward compat)
|
||||
const legacyBindMatch = pathname.match(/^\/api\/issues\/([^/]+)\/bind\/([^/]+)$/);
|
||||
if (legacyBindMatch && req.method === 'PUT') {
|
||||
const issueId = decodeURIComponent(legacyBindMatch[1]);
|
||||
const solutionId = decodeURIComponent(legacyBindMatch[2]);
|
||||
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issueIndex = issues.findIndex(i => i.id === issueId);
|
||||
if (issueIndex === -1) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = bindSolutionToIssue(issuesDir, issueId, solutionId, issues, issueIndex);
|
||||
if (result.error) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
return true;
|
||||
}
|
||||
|
||||
issues[issueIndex].updated_at = new Date().toISOString();
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, issueId, solutionId }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy: PUT /api/issues/:id (backward compat for PATCH)
|
||||
const legacyUpdateMatch = pathname.match(/^\/api\/issues\/([^/]+)$/);
|
||||
if (legacyUpdateMatch && req.method === 'PUT') {
|
||||
const issueId = decodeURIComponent(legacyUpdateMatch[1]);
|
||||
if (issueId === 'queue') return false;
|
||||
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issueIndex = issues.findIndex(i => i.id === issueId);
|
||||
if (issueIndex === -1) return { error: 'Issue not found' };
|
||||
|
||||
const updates: string[] = [];
|
||||
for (const field of ['title', 'context', 'status', 'priority', 'bound_solution_id', 'labels']) {
|
||||
if (body[field] !== undefined) {
|
||||
issues[issueIndex][field] = body[field];
|
||||
updates.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
issues[issueIndex].updated_at = new Date().toISOString();
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
return { success: true, issueId, updated: updates };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { handleGraphRoutes } from './routes/graph-routes.js';
|
||||
import { handleSystemRoutes } from './routes/system-routes.js';
|
||||
import { handleFilesRoutes } from './routes/files-routes.js';
|
||||
import { handleSkillsRoutes } from './routes/skills-routes.js';
|
||||
import { handleIssueRoutes } from './routes/issue-routes.js';
|
||||
import { handleRulesRoutes } from './routes/rules-routes.js';
|
||||
import { handleSessionRoutes } from './routes/session-routes.js';
|
||||
import { handleCcwRoutes } from './routes/ccw-routes.js';
|
||||
@@ -86,7 +87,8 @@ const MODULE_CSS_FILES = [
|
||||
'28-mcp-manager.css',
|
||||
'29-help.css',
|
||||
'30-core-memory.css',
|
||||
'31-api-settings.css'
|
||||
'31-api-settings.css',
|
||||
'32-issue-manager.css'
|
||||
];
|
||||
|
||||
// Modular JS files in dependency order
|
||||
@@ -142,6 +144,7 @@ const MODULE_FILES = [
|
||||
'views/claude-manager.js',
|
||||
'views/api-settings.js',
|
||||
'views/help.js',
|
||||
'views/issue-manager.js',
|
||||
'main.js'
|
||||
];
|
||||
|
||||
@@ -244,7 +247,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
|
||||
// CORS headers for API requests
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
@@ -340,6 +343,16 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleSkillsRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Queue routes (/api/queue*) - top-level queue API
|
||||
if (pathname.startsWith('/api/queue')) {
|
||||
if (await handleIssueRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Issue routes (/api/issues*)
|
||||
if (pathname.startsWith('/api/issues')) {
|
||||
if (await handleIssueRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Rules routes (/api/rules*)
|
||||
if (pathname.startsWith('/api/rules')) {
|
||||
if (await handleRulesRoutes(routeContext)) return;
|
||||
|
||||
979
ccw/src/templates/dashboard-css/32-issue-manager.css
Normal file
979
ccw/src/templates/dashboard-css/32-issue-manager.css
Normal file
@@ -0,0 +1,979 @@
|
||||
/* ==========================================
|
||||
ISSUE MANAGER STYLES
|
||||
========================================== */
|
||||
|
||||
/* Issue Manager Container */
|
||||
.issue-manager {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.issue-manager.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* View Toggle (Issues/Queue) */
|
||||
.issue-view-toggle {
|
||||
display: inline-flex;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.issue-view-toggle button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.issue-view-toggle button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.issue-view-toggle button.active {
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow: 0 1px 2px hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
/* Issues Grid */
|
||||
.issues-section {
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.issues-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.issues-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
/* Issue Card */
|
||||
.issue-card {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.issue-card:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.issue-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.issue-card-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.issue-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.issue-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.issue-card-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.issue-card-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Issue Status Badges */
|
||||
.issue-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.issue-status.registered {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.issue-status.planned {
|
||||
background: hsl(217 91% 60% / 0.15);
|
||||
color: hsl(217 91% 60%);
|
||||
}
|
||||
|
||||
.issue-status.queued {
|
||||
background: hsl(262 83% 58% / 0.15);
|
||||
color: hsl(262 83% 58%);
|
||||
}
|
||||
|
||||
.issue-status.executing {
|
||||
background: hsl(45 93% 47% / 0.15);
|
||||
color: hsl(45 93% 47%);
|
||||
}
|
||||
|
||||
.issue-status.completed {
|
||||
background: hsl(var(--success) / 0.15);
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.issue-status.failed {
|
||||
background: hsl(var(--destructive) / 0.15);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Priority Badges */
|
||||
.issue-priority {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.issue-priority.critical {
|
||||
background: hsl(0 84% 60% / 0.15);
|
||||
color: hsl(0 84% 60%);
|
||||
}
|
||||
|
||||
.issue-priority.high {
|
||||
background: hsl(25 95% 53% / 0.15);
|
||||
color: hsl(25 95% 53%);
|
||||
}
|
||||
|
||||
.issue-priority.medium {
|
||||
background: hsl(45 93% 47% / 0.15);
|
||||
color: hsl(45 93% 47%);
|
||||
}
|
||||
|
||||
.issue-priority.low {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
QUEUE VIEW STYLES
|
||||
========================================== */
|
||||
|
||||
.queue-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.queue-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.queue-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Execution Group */
|
||||
.queue-group {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.queue-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.queue-group-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.queue-group-badge.parallel {
|
||||
background: hsl(142 71% 45% / 0.15);
|
||||
color: hsl(142 71% 45%);
|
||||
}
|
||||
|
||||
.queue-group-badge.sequential {
|
||||
background: hsl(262 83% 58% / 0.15);
|
||||
color: hsl(262 83% 58%);
|
||||
}
|
||||
|
||||
.queue-group-count {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.queue-group-items {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Parallel group items display horizontally */
|
||||
.queue-group.parallel .queue-group-items {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Queue Item */
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 2px 4px hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
.queue-item[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.queue-item[draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.queue-item-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.queue-item-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.queue-item-order {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.queue-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-item-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.queue-item-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-item-issue {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.queue-item-priority {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 0.25rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Queue Item Status */
|
||||
.queue-item-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.queue-item-status.pending {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.queue-item-status.running {
|
||||
background: hsl(217 91% 60% / 0.15);
|
||||
color: hsl(217 91% 60%);
|
||||
}
|
||||
|
||||
.queue-item-status.completed {
|
||||
background: hsl(var(--success) / 0.15);
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.queue-item-status.failed {
|
||||
background: hsl(var(--destructive) / 0.15);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Drag and Drop States */
|
||||
.queue-item.dragging {
|
||||
opacity: 0.5;
|
||||
border: 2px dashed hsl(var(--primary));
|
||||
}
|
||||
|
||||
.queue-item.drag-over {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.05);
|
||||
}
|
||||
|
||||
.queue-group-items.drag-over {
|
||||
background: hsl(var(--primary) / 0.03);
|
||||
}
|
||||
|
||||
/* Arrow connector between sequential items */
|
||||
.queue-group.sequential .queue-item:not(:last-child)::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 8px solid hsl(var(--muted-foreground) / 0.3);
|
||||
position: absolute;
|
||||
bottom: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.queue-group.sequential .queue-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
ISSUE DETAIL PANEL
|
||||
========================================== */
|
||||
|
||||
.issue-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: hsl(var(--foreground) / 0.4);
|
||||
z-index: 999;
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.issue-detail-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
background: hsl(var(--background));
|
||||
border-left: 1px solid hsl(var(--border));
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideInPanel 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInPanel {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.issue-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.issue-detail-header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.issue-detail-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.issue-detail-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.issue-detail-title.editable {
|
||||
cursor: text;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.25rem -0.5rem 0;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.issue-detail-title.editable:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.issue-detail-title.editable:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.issue-detail-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.issue-detail-close:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.issue-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.issue-detail-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.issue-detail-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.issue-detail-section-title button {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.issue-detail-section-title button:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Context / Description */
|
||||
.issue-detail-context {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.issue-detail-context.editable {
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
cursor: text;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.issue-detail-context.editable:hover {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.issue-detail-context.editable:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
/* Solutions List */
|
||||
.solution-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.solution-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.solution-item:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.solution-item.bound {
|
||||
border-color: hsl(var(--success));
|
||||
background: hsl(var(--success) / 0.05);
|
||||
}
|
||||
|
||||
.solution-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.solution-item.bound .solution-item-icon {
|
||||
background: hsl(var(--success) / 0.15);
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.solution-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.solution-item-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.solution-item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Task List */
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.task-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-item-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.task-item-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.task-item-scope {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.task-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Task Action Badge (Create, Update, etc) */
|
||||
.task-action-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-action-badge.create {
|
||||
background: hsl(142 71% 45% / 0.15);
|
||||
color: hsl(142 71% 45%);
|
||||
}
|
||||
|
||||
.task-action-badge.update {
|
||||
background: hsl(217 91% 60% / 0.15);
|
||||
color: hsl(217 91% 60%);
|
||||
}
|
||||
|
||||
.task-action-badge.implement {
|
||||
background: hsl(262 83% 58% / 0.15);
|
||||
color: hsl(262 83% 58%);
|
||||
}
|
||||
|
||||
.task-action-badge.configure {
|
||||
background: hsl(45 93% 47% / 0.15);
|
||||
color: hsl(45 93% 47%);
|
||||
}
|
||||
|
||||
.task-action-badge.refactor {
|
||||
background: hsl(25 95% 53% / 0.15);
|
||||
color: hsl(25 95% 53%);
|
||||
}
|
||||
|
||||
.task-action-badge.test {
|
||||
background: hsl(199 89% 48% / 0.15);
|
||||
color: hsl(199 89% 48%);
|
||||
}
|
||||
|
||||
.task-action-badge.delete {
|
||||
background: hsl(var(--destructive) / 0.15);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Task Status Dropdown */
|
||||
.task-status-select {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-status-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Modification Points */
|
||||
.modification-points {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.modification-point {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.modification-point-file {
|
||||
font-family: var(--font-mono);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.modification-point-change {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Implementation Steps */
|
||||
.implementation-steps {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.implementation-steps li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Acceptance Criteria */
|
||||
.acceptance-criteria {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.acceptance-criteria li {
|
||||
margin: 0.25rem 0;
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
CONFLICTS SECTION
|
||||
========================================== */
|
||||
|
||||
.conflicts-section {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.conflict-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(45 93% 47% / 0.1);
|
||||
border: 1px solid hsl(45 93% 47% / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.conflict-item.resolved {
|
||||
background: hsl(var(--success) / 0.05);
|
||||
border-color: hsl(var(--success) / 0.3);
|
||||
}
|
||||
|
||||
.conflict-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(45 93% 47% / 0.2);
|
||||
color: hsl(45 93% 47%);
|
||||
}
|
||||
|
||||
.conflict-item.resolved .conflict-icon {
|
||||
background: hsl(var(--success) / 0.2);
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.conflict-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conflict-file {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conflict-tasks {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.conflict-resolution {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
FILTER BAR
|
||||
========================================== */
|
||||
|
||||
.issue-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.issue-filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.issue-filter-group label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.issue-filter-select {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.issue-filter-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
========================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.issues-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.issue-detail-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.queue-group.parallel .queue-group-items {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.issue-filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.issue-view-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.issue-view-toggle button {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.queue-item-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
ANIMATIONS
|
||||
========================================== */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.issue-card .badge,
|
||||
.queue-item .badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
467
ccw/src/templates/dashboard-css/33-cli-stream-viewer.css
Normal file
467
ccw/src/templates/dashboard-css/33-cli-stream-viewer.css
Normal file
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* CLI Stream Viewer Styles
|
||||
* Right-side popup panel for viewing CLI streaming output
|
||||
*/
|
||||
|
||||
/* ===== Overlay ===== */
|
||||
.cli-stream-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 0.3);
|
||||
z-index: 1050;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cli-stream-overlay.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* ===== Main Panel ===== */
|
||||
.cli-stream-viewer {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 16px;
|
||||
width: 650px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 80px);
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgb(0 0 0 / 0.2);
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(calc(100% + 20px));
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.cli-stream-viewer.open {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.cli-stream-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.cli-stream-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cli-stream-title svg,
|
||||
.cli-stream-title i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.cli-stream-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-radius: 10px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cli-stream-count-badge.has-running {
|
||||
background: hsl(var(--warning));
|
||||
color: hsl(var(--warning-foreground, white));
|
||||
}
|
||||
|
||||
.cli-stream-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cli-stream-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cli-stream-action-btn:hover {
|
||||
background: hsl(var(--hover));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cli-stream-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1.25rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cli-stream-close-btn:hover {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* ===== Tab Bar ===== */
|
||||
.cli-stream-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.cli-stream-tabs::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.cli-stream-tabs::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cli-stream-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cli-stream-tab:hover {
|
||||
background: hsl(var(--hover));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cli-stream-tab.active {
|
||||
background: hsl(var(--card));
|
||||
border-color: hsl(var(--primary));
|
||||
color: hsl(var(--foreground));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.cli-stream-tab-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cli-stream-tab-status.running {
|
||||
background: hsl(var(--warning));
|
||||
animation: streamStatusPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cli-stream-tab-status.completed {
|
||||
background: hsl(var(--success));
|
||||
}
|
||||
|
||||
.cli-stream-tab-status.error {
|
||||
background: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
@keyframes streamStatusPulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.cli-stream-tab-tool {
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.cli-stream-tab-mode {
|
||||
font-size: 0.625rem;
|
||||
padding: 1px 4px;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 3px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.cli-stream-tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cli-stream-tab:hover .cli-stream-tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cli-stream-tab-close:hover {
|
||||
background: hsl(var(--destructive) / 0.2);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.cli-stream-tab-close.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3 !important;
|
||||
}
|
||||
|
||||
/* ===== Empty State ===== */
|
||||
.cli-stream-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cli-stream-empty svg,
|
||||
.cli-stream-empty i {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cli-stream-empty-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cli-stream-empty-hint {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ===== Terminal Content ===== */
|
||||
.cli-stream-content {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
background: hsl(220 13% 8%);
|
||||
font-family: var(--font-mono, 'Consolas', 'Monaco', 'Courier New', monospace);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.cli-stream-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.cli-stream-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cli-stream-content::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 40%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cli-stream-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cli-stream-line.stdout {
|
||||
color: hsl(0 0% 85%);
|
||||
}
|
||||
|
||||
.cli-stream-line.stderr {
|
||||
color: hsl(8 75% 65%);
|
||||
}
|
||||
|
||||
.cli-stream-line.system {
|
||||
color: hsl(210 80% 65%);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cli-stream-line.info {
|
||||
color: hsl(200 80% 70%);
|
||||
}
|
||||
|
||||
/* Auto-scroll indicator */
|
||||
.cli-stream-scroll-btn {
|
||||
position: sticky;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
background: hsl(var(--primary));
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 0.625rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.cli-stream-content.has-new-content .cli-stream-scroll-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ===== Status Bar ===== */
|
||||
.cli-stream-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.cli-stream-status-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cli-stream-status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cli-stream-status-item svg,
|
||||
.cli-stream-status-item i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.cli-stream-status-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cli-stream-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
font-size: 0.625rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.cli-stream-toggle-btn:hover {
|
||||
background: hsl(var(--hover));
|
||||
}
|
||||
|
||||
.cli-stream-toggle-btn.active {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* ===== Header Button & Badge ===== */
|
||||
.cli-stream-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cli-stream-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 4px;
|
||||
background: hsl(var(--warning));
|
||||
color: white;
|
||||
border-radius: 7px;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cli-stream-badge.has-running {
|
||||
display: flex;
|
||||
animation: streamBadgePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes streamBadgePulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.cli-stream-viewer {
|
||||
top: 56px;
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
width: auto;
|
||||
max-height: calc(100vh - 72px);
|
||||
}
|
||||
|
||||
.cli-stream-content {
|
||||
min-height: 200px;
|
||||
max-height: 350px;
|
||||
}
|
||||
}
|
||||
456
ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
Normal file
456
ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* CLI Stream Viewer Component
|
||||
* Real-time streaming output viewer for CLI executions
|
||||
*/
|
||||
|
||||
// ===== State Management =====
|
||||
let cliStreamExecutions = {}; // { executionId: { tool, mode, output, status, startTime, endTime } }
|
||||
let activeStreamTab = null;
|
||||
let autoScrollEnabled = true;
|
||||
let isCliStreamViewerOpen = false;
|
||||
|
||||
const MAX_OUTPUT_LINES = 5000; // Prevent memory issues
|
||||
|
||||
// ===== Initialization =====
|
||||
function initCliStreamViewer() {
|
||||
// Initialize keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && isCliStreamViewerOpen) {
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize scroll detection for auto-scroll
|
||||
const content = document.getElementById('cliStreamContent');
|
||||
if (content) {
|
||||
content.addEventListener('scroll', handleStreamContentScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Panel Control =====
|
||||
function toggleCliStreamViewer() {
|
||||
const viewer = document.getElementById('cliStreamViewer');
|
||||
const overlay = document.getElementById('cliStreamOverlay');
|
||||
|
||||
if (!viewer || !overlay) return;
|
||||
|
||||
isCliStreamViewerOpen = !isCliStreamViewerOpen;
|
||||
|
||||
if (isCliStreamViewerOpen) {
|
||||
viewer.classList.add('open');
|
||||
overlay.classList.add('open');
|
||||
|
||||
// If no active tab but have executions, select the first one
|
||||
if (!activeStreamTab && Object.keys(cliStreamExecutions).length > 0) {
|
||||
const firstId = Object.keys(cliStreamExecutions)[0];
|
||||
switchStreamTab(firstId);
|
||||
} else {
|
||||
renderStreamContent(activeStreamTab);
|
||||
}
|
||||
|
||||
// Re-init lucide icons
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
} else {
|
||||
viewer.classList.remove('open');
|
||||
overlay.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== WebSocket Event Handlers =====
|
||||
function handleCliStreamStarted(payload) {
|
||||
const { executionId, tool, mode, timestamp } = payload;
|
||||
|
||||
// Create new execution record
|
||||
cliStreamExecutions[executionId] = {
|
||||
tool: tool || 'cli',
|
||||
mode: mode || 'analysis',
|
||||
output: [],
|
||||
status: 'running',
|
||||
startTime: timestamp ? new Date(timestamp).getTime() : Date.now(),
|
||||
endTime: null
|
||||
};
|
||||
|
||||
// Add system message
|
||||
cliStreamExecutions[executionId].output.push({
|
||||
type: 'system',
|
||||
content: `[${new Date().toLocaleTimeString()}] CLI execution started: ${tool} (${mode} mode)`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// If this is the first execution or panel is open, select it
|
||||
if (!activeStreamTab || isCliStreamViewerOpen) {
|
||||
activeStreamTab = executionId;
|
||||
}
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(activeStreamTab);
|
||||
updateStreamBadge();
|
||||
|
||||
// Auto-open panel if configured (optional)
|
||||
// if (!isCliStreamViewerOpen) toggleCliStreamViewer();
|
||||
}
|
||||
|
||||
function handleCliStreamOutput(payload) {
|
||||
const { executionId, chunkType, data } = payload;
|
||||
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec) return;
|
||||
|
||||
// Parse and add output lines
|
||||
const content = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.trim() || lines.length === 1) { // Keep empty lines if it's the only content
|
||||
exec.output.push({
|
||||
type: chunkType || 'stdout',
|
||||
content: line,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Trim if too long
|
||||
if (exec.output.length > MAX_OUTPUT_LINES) {
|
||||
exec.output = exec.output.slice(-MAX_OUTPUT_LINES);
|
||||
}
|
||||
|
||||
// Update UI if this is the active tab
|
||||
if (activeStreamTab === executionId && isCliStreamViewerOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
renderStreamContent(executionId);
|
||||
});
|
||||
}
|
||||
|
||||
// Update badge to show activity
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
function handleCliStreamCompleted(payload) {
|
||||
const { executionId, success, duration, timestamp } = payload;
|
||||
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec) return;
|
||||
|
||||
exec.status = success ? 'completed' : 'error';
|
||||
exec.endTime = timestamp ? new Date(timestamp).getTime() : Date.now();
|
||||
|
||||
// Add completion message
|
||||
const durationText = duration ? ` (${formatDuration(duration)})` : '';
|
||||
const statusText = success ? 'completed successfully' : 'failed';
|
||||
exec.output.push({
|
||||
type: 'system',
|
||||
content: `[${new Date().toLocaleTimeString()}] CLI execution ${statusText}${durationText}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
renderStreamTabs();
|
||||
if (activeStreamTab === executionId) {
|
||||
renderStreamContent(executionId);
|
||||
}
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
function handleCliStreamError(payload) {
|
||||
const { executionId, error, timestamp } = payload;
|
||||
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec) return;
|
||||
|
||||
exec.status = 'error';
|
||||
exec.endTime = timestamp ? new Date(timestamp).getTime() : Date.now();
|
||||
|
||||
// Add error message
|
||||
exec.output.push({
|
||||
type: 'stderr',
|
||||
content: `[ERROR] ${error || 'Unknown error occurred'}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
renderStreamTabs();
|
||||
if (activeStreamTab === executionId) {
|
||||
renderStreamContent(executionId);
|
||||
}
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
// ===== UI Rendering =====
|
||||
function renderStreamTabs() {
|
||||
const tabsContainer = document.getElementById('cliStreamTabs');
|
||||
if (!tabsContainer) return;
|
||||
|
||||
const execIds = Object.keys(cliStreamExecutions);
|
||||
|
||||
if (execIds.length === 0) {
|
||||
tabsContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: running first, then by start time (newest first)
|
||||
execIds.sort((a, b) => {
|
||||
const execA = cliStreamExecutions[a];
|
||||
const execB = cliStreamExecutions[b];
|
||||
|
||||
if (execA.status === 'running' && execB.status !== 'running') return -1;
|
||||
if (execA.status !== 'running' && execB.status === 'running') return 1;
|
||||
return execB.startTime - execA.startTime;
|
||||
});
|
||||
|
||||
tabsContainer.innerHTML = execIds.map(id => {
|
||||
const exec = cliStreamExecutions[id];
|
||||
const isActive = id === activeStreamTab;
|
||||
const canClose = exec.status !== 'running';
|
||||
|
||||
return `
|
||||
<div class="cli-stream-tab ${isActive ? 'active' : ''}"
|
||||
onclick="switchStreamTab('${id}')"
|
||||
data-execution-id="${id}">
|
||||
<span class="cli-stream-tab-status ${exec.status}"></span>
|
||||
<span class="cli-stream-tab-tool">${escapeHtml(exec.tool)}</span>
|
||||
<span class="cli-stream-tab-mode">${exec.mode}</span>
|
||||
<button class="cli-stream-tab-close ${canClose ? '' : 'disabled'}"
|
||||
onclick="event.stopPropagation(); closeStream('${id}')"
|
||||
title="${canClose ? t('cliStream.close') : t('cliStream.cannotCloseRunning')}"
|
||||
${canClose ? '' : 'disabled'}>×</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Update count badge
|
||||
const countBadge = document.getElementById('cliStreamCountBadge');
|
||||
if (countBadge) {
|
||||
const runningCount = execIds.filter(id => cliStreamExecutions[id].status === 'running').length;
|
||||
countBadge.textContent = execIds.length;
|
||||
countBadge.classList.toggle('has-running', runningCount > 0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStreamContent(executionId) {
|
||||
const contentContainer = document.getElementById('cliStreamContent');
|
||||
if (!contentContainer) return;
|
||||
|
||||
const exec = executionId ? cliStreamExecutions[executionId] : null;
|
||||
|
||||
if (!exec) {
|
||||
// Show empty state
|
||||
contentContainer.innerHTML = `
|
||||
<div class="cli-stream-empty">
|
||||
<i data-lucide="terminal"></i>
|
||||
<div class="cli-stream-empty-title" data-i18n="cliStream.noStreams">${t('cliStream.noStreams')}</div>
|
||||
<div class="cli-stream-empty-hint" data-i18n="cliStream.noStreamsHint">${t('cliStream.noStreamsHint')}</div>
|
||||
</div>
|
||||
`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if should auto-scroll
|
||||
const wasAtBottom = contentContainer.scrollHeight - contentContainer.scrollTop <= contentContainer.clientHeight + 50;
|
||||
|
||||
// Render output lines
|
||||
contentContainer.innerHTML = exec.output.map(line =>
|
||||
`<div class="cli-stream-line ${line.type}">${escapeHtml(line.content)}</div>`
|
||||
).join('');
|
||||
|
||||
// Auto-scroll if enabled and was at bottom
|
||||
if (autoScrollEnabled && wasAtBottom) {
|
||||
contentContainer.scrollTop = contentContainer.scrollHeight;
|
||||
}
|
||||
|
||||
// Update status bar
|
||||
renderStreamStatus(executionId);
|
||||
}
|
||||
|
||||
function renderStreamStatus(executionId) {
|
||||
const statusContainer = document.getElementById('cliStreamStatus');
|
||||
if (!statusContainer) return;
|
||||
|
||||
const exec = executionId ? cliStreamExecutions[executionId] : null;
|
||||
|
||||
if (!exec) {
|
||||
statusContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = exec.endTime
|
||||
? formatDuration(exec.endTime - exec.startTime)
|
||||
: formatDuration(Date.now() - exec.startTime);
|
||||
|
||||
const statusLabel = exec.status === 'running'
|
||||
? t('cliStream.running')
|
||||
: exec.status === 'completed'
|
||||
? t('cliStream.completed')
|
||||
: t('cliStream.error');
|
||||
|
||||
statusContainer.innerHTML = `
|
||||
<div class="cli-stream-status-info">
|
||||
<div class="cli-stream-status-item">
|
||||
<span class="cli-stream-tab-status ${exec.status}"></span>
|
||||
<span>${statusLabel}</span>
|
||||
</div>
|
||||
<div class="cli-stream-status-item">
|
||||
<i data-lucide="clock"></i>
|
||||
<span>${duration}</span>
|
||||
</div>
|
||||
<div class="cli-stream-status-item">
|
||||
<i data-lucide="file-text"></i>
|
||||
<span>${exec.output.length} ${t('cliStream.lines') || 'lines'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-stream-status-actions">
|
||||
<button class="cli-stream-toggle-btn ${autoScrollEnabled ? 'active' : ''}"
|
||||
onclick="toggleAutoScroll()"
|
||||
title="${t('cliStream.autoScroll')}">
|
||||
<i data-lucide="arrow-down-to-line"></i>
|
||||
<span data-i18n="cliStream.autoScroll">${t('cliStream.autoScroll')}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
// Update duration periodically for running executions
|
||||
if (exec.status === 'running') {
|
||||
setTimeout(() => {
|
||||
if (activeStreamTab === executionId && cliStreamExecutions[executionId]?.status === 'running') {
|
||||
renderStreamStatus(executionId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function switchStreamTab(executionId) {
|
||||
if (!cliStreamExecutions[executionId]) return;
|
||||
|
||||
activeStreamTab = executionId;
|
||||
renderStreamTabs();
|
||||
renderStreamContent(executionId);
|
||||
}
|
||||
|
||||
function updateStreamBadge() {
|
||||
const badge = document.getElementById('cliStreamBadge');
|
||||
if (!badge) return;
|
||||
|
||||
const runningCount = Object.values(cliStreamExecutions).filter(e => e.status === 'running').length;
|
||||
|
||||
if (runningCount > 0) {
|
||||
badge.textContent = runningCount;
|
||||
badge.classList.add('has-running');
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
badge.classList.remove('has-running');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== User Actions =====
|
||||
function closeStream(executionId) {
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec || exec.status === 'running') return;
|
||||
|
||||
delete cliStreamExecutions[executionId];
|
||||
|
||||
// Switch to another tab if this was active
|
||||
if (activeStreamTab === executionId) {
|
||||
const remaining = Object.keys(cliStreamExecutions);
|
||||
activeStreamTab = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(activeStreamTab);
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
function clearCompletedStreams() {
|
||||
const toRemove = Object.keys(cliStreamExecutions).filter(
|
||||
id => cliStreamExecutions[id].status !== 'running'
|
||||
);
|
||||
|
||||
toRemove.forEach(id => delete cliStreamExecutions[id]);
|
||||
|
||||
// Update active tab if needed
|
||||
if (activeStreamTab && !cliStreamExecutions[activeStreamTab]) {
|
||||
const remaining = Object.keys(cliStreamExecutions);
|
||||
activeStreamTab = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(activeStreamTab);
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScrollEnabled = !autoScrollEnabled;
|
||||
|
||||
if (autoScrollEnabled && activeStreamTab) {
|
||||
const content = document.getElementById('cliStreamContent');
|
||||
if (content) {
|
||||
content.scrollTop = content.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
renderStreamStatus(activeStreamTab);
|
||||
}
|
||||
|
||||
function handleStreamContentScroll() {
|
||||
const content = document.getElementById('cliStreamContent');
|
||||
if (!content) return;
|
||||
|
||||
// If user scrolls up, disable auto-scroll
|
||||
const isAtBottom = content.scrollHeight - content.scrollTop <= content.clientHeight + 50;
|
||||
if (!isAtBottom && autoScrollEnabled) {
|
||||
autoScrollEnabled = false;
|
||||
renderStreamStatus(activeStreamTab);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Helper Functions =====
|
||||
function formatDuration(ms) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Translation helper with fallback
|
||||
function t(key) {
|
||||
if (typeof window.t === 'function') {
|
||||
return window.t(key);
|
||||
}
|
||||
// Fallback values
|
||||
const fallbacks = {
|
||||
'cliStream.noStreams': 'No active CLI executions',
|
||||
'cliStream.noStreamsHint': 'Start a CLI command to see streaming output',
|
||||
'cliStream.running': 'Running',
|
||||
'cliStream.completed': 'Completed',
|
||||
'cliStream.error': 'Error',
|
||||
'cliStream.autoScroll': 'Auto-scroll',
|
||||
'cliStream.close': 'Close',
|
||||
'cliStream.cannotCloseRunning': 'Cannot close running execution',
|
||||
'cliStream.lines': 'lines'
|
||||
};
|
||||
return fallbacks[key] || key;
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCliStreamViewer);
|
||||
} else {
|
||||
initCliStreamViewer();
|
||||
}
|
||||
@@ -155,6 +155,12 @@ function initNavigation() {
|
||||
} else {
|
||||
console.error('renderApiSettings not defined - please refresh the page');
|
||||
}
|
||||
} else if (currentView === 'issue-manager') {
|
||||
if (typeof renderIssueManager === 'function') {
|
||||
renderIssueManager();
|
||||
} else {
|
||||
console.error('renderIssueManager not defined - please refresh the page');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -199,6 +205,8 @@ function updateContentTitle() {
|
||||
titleEl.textContent = t('title.codexLensManager');
|
||||
} else if (currentView === 'api-settings') {
|
||||
titleEl.textContent = t('title.apiSettings');
|
||||
} else if (currentView === 'issue-manager') {
|
||||
titleEl.textContent = t('title.issueManager');
|
||||
} else if (currentView === 'liteTasks') {
|
||||
const names = { 'lite-plan': t('title.litePlanSessions'), 'lite-fix': t('title.liteFixSessions') };
|
||||
titleEl.textContent = names[currentLiteType] || t('title.liteTasks');
|
||||
|
||||
@@ -217,24 +217,40 @@ function handleNotification(data) {
|
||||
if (typeof handleCliExecutionStarted === 'function') {
|
||||
handleCliExecutionStarted(payload);
|
||||
}
|
||||
// Route to CLI Stream Viewer
|
||||
if (typeof handleCliStreamStarted === 'function') {
|
||||
handleCliStreamStarted(payload);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'CLI_OUTPUT':
|
||||
if (typeof handleCliOutput === 'function') {
|
||||
handleCliOutput(payload);
|
||||
}
|
||||
// Route to CLI Stream Viewer
|
||||
if (typeof handleCliStreamOutput === 'function') {
|
||||
handleCliStreamOutput(payload);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'CLI_EXECUTION_COMPLETED':
|
||||
if (typeof handleCliExecutionCompleted === 'function') {
|
||||
handleCliExecutionCompleted(payload);
|
||||
}
|
||||
// Route to CLI Stream Viewer
|
||||
if (typeof handleCliStreamCompleted === 'function') {
|
||||
handleCliStreamCompleted(payload);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'CLI_EXECUTION_ERROR':
|
||||
if (typeof handleCliExecutionError === 'function') {
|
||||
handleCliExecutionError(payload);
|
||||
}
|
||||
// Route to CLI Stream Viewer
|
||||
if (typeof handleCliStreamError === 'function') {
|
||||
handleCliStreamError(payload);
|
||||
}
|
||||
break;
|
||||
|
||||
// CLI Review Events
|
||||
|
||||
@@ -39,7 +39,21 @@ const i18n = {
|
||||
'header.refreshWorkspace': 'Refresh workspace',
|
||||
'header.toggleTheme': 'Toggle theme',
|
||||
'header.language': 'Language',
|
||||
|
||||
'header.cliStream': 'CLI Stream Viewer',
|
||||
|
||||
// CLI Stream Viewer
|
||||
'cliStream.title': 'CLI Stream',
|
||||
'cliStream.clearCompleted': 'Clear Completed',
|
||||
'cliStream.noStreams': 'No active CLI executions',
|
||||
'cliStream.noStreamsHint': 'Start a CLI command to see streaming output',
|
||||
'cliStream.running': 'Running',
|
||||
'cliStream.completed': 'Completed',
|
||||
'cliStream.error': 'Error',
|
||||
'cliStream.autoScroll': 'Auto-scroll',
|
||||
'cliStream.close': 'Close',
|
||||
'cliStream.cannotCloseRunning': 'Cannot close running execution',
|
||||
'cliStream.lines': 'lines',
|
||||
|
||||
// Sidebar - Project section
|
||||
'nav.project': 'Project',
|
||||
'nav.overview': 'Overview',
|
||||
@@ -1711,6 +1725,53 @@ const i18n = {
|
||||
'coreMemory.belongsToClusters': 'Belongs to Clusters',
|
||||
'coreMemory.relationsError': 'Failed to load relations',
|
||||
|
||||
// Issue Manager
|
||||
'nav.issues': 'Issues',
|
||||
'nav.issueManager': 'Manager',
|
||||
'title.issueManager': 'Issue Manager',
|
||||
'issue.viewIssues': 'Issues',
|
||||
'issue.viewQueue': 'Queue',
|
||||
'issue.filterAll': 'All',
|
||||
'issue.filterStatus': 'Status',
|
||||
'issue.filterPriority': 'Priority',
|
||||
'issue.noIssues': 'No issues found',
|
||||
'issue.noIssuesHint': 'Issues will appear here when created via /issue:plan command',
|
||||
'issue.noQueue': 'No tasks in queue',
|
||||
'issue.noQueueHint': 'Run /issue:queue to form execution queue from bound solutions',
|
||||
'issue.tasks': 'tasks',
|
||||
'issue.solutions': 'solutions',
|
||||
'issue.parallel': 'Parallel',
|
||||
'issue.sequential': 'Sequential',
|
||||
'issue.status.registered': 'Registered',
|
||||
'issue.status.planned': 'Planned',
|
||||
'issue.status.queued': 'Queued',
|
||||
'issue.status.executing': 'Executing',
|
||||
'issue.status.completed': 'Completed',
|
||||
'issue.status.failed': 'Failed',
|
||||
'issue.priority.critical': 'Critical',
|
||||
'issue.priority.high': 'High',
|
||||
'issue.priority.medium': 'Medium',
|
||||
'issue.priority.low': 'Low',
|
||||
'issue.detail.context': 'Context',
|
||||
'issue.detail.solutions': 'Solutions',
|
||||
'issue.detail.tasks': 'Tasks',
|
||||
'issue.detail.noSolutions': 'No solutions available',
|
||||
'issue.detail.noTasks': 'No tasks available',
|
||||
'issue.detail.bound': 'Bound',
|
||||
'issue.detail.modificationPoints': 'Modification Points',
|
||||
'issue.detail.implementation': 'Implementation Steps',
|
||||
'issue.detail.acceptance': 'Acceptance Criteria',
|
||||
'issue.queue.reordered': 'Queue reordered',
|
||||
'issue.queue.reorderFailed': 'Failed to reorder queue',
|
||||
'issue.saved': 'Issue saved',
|
||||
'issue.saveFailed': 'Failed to save issue',
|
||||
'issue.taskUpdated': 'Task updated',
|
||||
'issue.taskUpdateFailed': 'Failed to update task',
|
||||
'issue.conflicts': 'Conflicts',
|
||||
'issue.noConflicts': 'No conflicts detected',
|
||||
'issue.conflict.resolved': 'Resolved',
|
||||
'issue.conflict.pending': 'Pending',
|
||||
|
||||
// Common additions
|
||||
'common.copyId': 'Copy ID',
|
||||
'common.copied': 'Copied!',
|
||||
@@ -1748,7 +1809,21 @@ const i18n = {
|
||||
'header.refreshWorkspace': '刷新工作区',
|
||||
'header.toggleTheme': '切换主题',
|
||||
'header.language': '语言',
|
||||
|
||||
'header.cliStream': 'CLI 流式输出',
|
||||
|
||||
// CLI Stream Viewer
|
||||
'cliStream.title': 'CLI 流式输出',
|
||||
'cliStream.clearCompleted': '清除已完成',
|
||||
'cliStream.noStreams': '没有活动的 CLI 执行',
|
||||
'cliStream.noStreamsHint': '启动 CLI 命令以查看流式输出',
|
||||
'cliStream.running': '运行中',
|
||||
'cliStream.completed': '已完成',
|
||||
'cliStream.error': '错误',
|
||||
'cliStream.autoScroll': '自动滚动',
|
||||
'cliStream.close': '关闭',
|
||||
'cliStream.cannotCloseRunning': '无法关闭运行中的执行',
|
||||
'cliStream.lines': '行',
|
||||
|
||||
// Sidebar - Project section
|
||||
'nav.project': '项目',
|
||||
'nav.overview': '概览',
|
||||
@@ -3429,6 +3504,53 @@ const i18n = {
|
||||
'coreMemory.belongsToClusters': '所属聚类',
|
||||
'coreMemory.relationsError': '加载关联失败',
|
||||
|
||||
// Issue Manager
|
||||
'nav.issues': '议题',
|
||||
'nav.issueManager': '管理器',
|
||||
'title.issueManager': '议题管理器',
|
||||
'issue.viewIssues': '议题',
|
||||
'issue.viewQueue': '队列',
|
||||
'issue.filterAll': '全部',
|
||||
'issue.filterStatus': '状态',
|
||||
'issue.filterPriority': '优先级',
|
||||
'issue.noIssues': '暂无议题',
|
||||
'issue.noIssuesHint': '通过 /issue:plan 命令创建的议题将显示在此处',
|
||||
'issue.noQueue': '队列中暂无任务',
|
||||
'issue.noQueueHint': '运行 /issue:queue 从绑定的解决方案生成执行队列',
|
||||
'issue.tasks': '任务',
|
||||
'issue.solutions': '解决方案',
|
||||
'issue.parallel': '并行',
|
||||
'issue.sequential': '顺序',
|
||||
'issue.status.registered': '已注册',
|
||||
'issue.status.planned': '已规划',
|
||||
'issue.status.queued': '已入队',
|
||||
'issue.status.executing': '执行中',
|
||||
'issue.status.completed': '已完成',
|
||||
'issue.status.failed': '失败',
|
||||
'issue.priority.critical': '紧急',
|
||||
'issue.priority.high': '高',
|
||||
'issue.priority.medium': '中',
|
||||
'issue.priority.low': '低',
|
||||
'issue.detail.context': '上下文',
|
||||
'issue.detail.solutions': '解决方案',
|
||||
'issue.detail.tasks': '任务',
|
||||
'issue.detail.noSolutions': '暂无解决方案',
|
||||
'issue.detail.noTasks': '暂无任务',
|
||||
'issue.detail.bound': '已绑定',
|
||||
'issue.detail.modificationPoints': '修改点',
|
||||
'issue.detail.implementation': '实现步骤',
|
||||
'issue.detail.acceptance': '验收标准',
|
||||
'issue.queue.reordered': '队列已重排',
|
||||
'issue.queue.reorderFailed': '队列重排失败',
|
||||
'issue.saved': '议题已保存',
|
||||
'issue.saveFailed': '保存议题失败',
|
||||
'issue.taskUpdated': '任务已更新',
|
||||
'issue.taskUpdateFailed': '更新任务失败',
|
||||
'issue.conflicts': '冲突',
|
||||
'issue.noConflicts': '未检测到冲突',
|
||||
'issue.conflict.resolved': '已解决',
|
||||
'issue.conflict.pending': '待处理',
|
||||
|
||||
// Common additions
|
||||
'common.copyId': '复制 ID',
|
||||
'common.copied': '已复制!',
|
||||
|
||||
704
ccw/src/templates/dashboard-js/views/issue-manager.js
Normal file
704
ccw/src/templates/dashboard-js/views/issue-manager.js
Normal file
@@ -0,0 +1,704 @@
|
||||
// ==========================================
|
||||
// ISSUE MANAGER VIEW
|
||||
// Manages issues, solutions, and execution queue
|
||||
// ==========================================
|
||||
|
||||
// ========== Issue State ==========
|
||||
var issueData = {
|
||||
issues: [],
|
||||
queue: { queue: [], conflicts: [], execution_groups: [], grouped_items: {} },
|
||||
selectedIssue: null,
|
||||
selectedSolution: null,
|
||||
statusFilter: 'all',
|
||||
viewMode: 'issues' // 'issues' | 'queue'
|
||||
};
|
||||
var issueLoading = false;
|
||||
var issueDragState = {
|
||||
dragging: null,
|
||||
groupId: null
|
||||
};
|
||||
|
||||
// ========== Main Render Function ==========
|
||||
async function renderIssueManager() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
// Hide stats grid and search
|
||||
hideStatsAndCarousel();
|
||||
|
||||
// Show loading state
|
||||
container.innerHTML = '<div class="issue-manager loading">' +
|
||||
'<div class="loading-spinner"><i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i></div>' +
|
||||
'<p>' + t('common.loading') + '</p>' +
|
||||
'</div>';
|
||||
|
||||
// Load data
|
||||
await Promise.all([loadIssueData(), loadQueueData()]);
|
||||
|
||||
// Render the main view
|
||||
renderIssueView();
|
||||
}
|
||||
|
||||
// ========== Data Loading ==========
|
||||
async function loadIssueData() {
|
||||
issueLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/issues?path=' + encodeURIComponent(projectPath));
|
||||
if (!response.ok) throw new Error('Failed to load issues');
|
||||
const data = await response.json();
|
||||
issueData.issues = data.issues || [];
|
||||
updateIssueBadge();
|
||||
} catch (err) {
|
||||
console.error('Failed to load issues:', err);
|
||||
issueData.issues = [];
|
||||
} finally {
|
||||
issueLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueueData() {
|
||||
try {
|
||||
const response = await fetch('/api/queue?path=' + encodeURIComponent(projectPath));
|
||||
if (!response.ok) throw new Error('Failed to load queue');
|
||||
issueData.queue = await response.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to load queue:', err);
|
||||
issueData.queue = { queue: [], conflicts: [], execution_groups: [], grouped_items: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIssueDetail(issueId) {
|
||||
try {
|
||||
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '?path=' + encodeURIComponent(projectPath));
|
||||
if (!response.ok) throw new Error('Failed to load issue detail');
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to load issue detail:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateIssueBadge() {
|
||||
const badge = document.getElementById('badgeIssues');
|
||||
if (badge) {
|
||||
badge.textContent = issueData.issues.length;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Main View Render ==========
|
||||
function renderIssueView() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
const issues = issueData.issues || [];
|
||||
const filteredIssues = issueData.statusFilter === 'all'
|
||||
? issues
|
||||
: issues.filter(i => i.status === issueData.statusFilter);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="issue-manager">
|
||||
<!-- Header -->
|
||||
<div class="issue-header mb-6">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="clipboard-list" class="w-5 h-5 text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-foreground">${t('issues.title') || 'Issue Manager'}</h2>
|
||||
<p class="text-sm text-muted-foreground">${t('issues.description') || 'Manage issues, solutions, and execution queue'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="issue-view-toggle">
|
||||
<button class="${issueData.viewMode === 'issues' ? 'active' : ''}" onclick="switchIssueView('issues')">
|
||||
<i data-lucide="list" class="w-4 h-4 mr-1"></i>
|
||||
${t('issues.viewIssues') || 'Issues'}
|
||||
</button>
|
||||
<button class="${issueData.viewMode === 'queue' ? 'active' : ''}" onclick="switchIssueView('queue')">
|
||||
<i data-lucide="git-branch" class="w-4 h-4 mr-1"></i>
|
||||
${t('issues.viewQueue') || 'Queue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${issueData.viewMode === 'issues' ? renderIssueListSection(filteredIssues) : renderQueueSection()}
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<div id="issueDetailPanel" class="issue-detail-panel hidden"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
lucide.createIcons();
|
||||
|
||||
// Initialize drag-drop if in queue view
|
||||
if (issueData.viewMode === 'queue') {
|
||||
initQueueDragDrop();
|
||||
}
|
||||
}
|
||||
|
||||
function switchIssueView(mode) {
|
||||
issueData.viewMode = mode;
|
||||
renderIssueView();
|
||||
}
|
||||
|
||||
// ========== Issue List Section ==========
|
||||
function renderIssueListSection(issues) {
|
||||
const statuses = ['all', 'registered', 'planning', 'planned', 'queued', 'executing', 'completed', 'failed'];
|
||||
|
||||
return `
|
||||
<!-- Filters -->
|
||||
<div class="issue-filters mb-4">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm text-muted-foreground">${t('issues.filterStatus') || 'Status'}:</span>
|
||||
${statuses.map(status => `
|
||||
<button class="issue-filter-btn ${issueData.statusFilter === status ? 'active' : ''}"
|
||||
onclick="filterIssuesByStatus('${status}')">
|
||||
${status === 'all' ? (t('issues.filterAll') || 'All') : status}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Issues Grid -->
|
||||
<div class="issues-grid">
|
||||
${issues.length === 0 ? `
|
||||
<div class="issue-empty">
|
||||
<i data-lucide="inbox" class="w-12 h-12 text-muted-foreground mb-4"></i>
|
||||
<p class="text-muted-foreground">${t('issues.noIssues') || 'No issues found'}</p>
|
||||
<p class="text-sm text-muted-foreground mt-2">${t('issues.createHint') || 'Create issues using: ccw issue init <id>'}</p>
|
||||
</div>
|
||||
` : issues.map(issue => renderIssueCard(issue)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderIssueCard(issue) {
|
||||
const statusColors = {
|
||||
registered: 'registered',
|
||||
planning: 'planning',
|
||||
planned: 'planned',
|
||||
queued: 'queued',
|
||||
executing: 'executing',
|
||||
completed: 'completed',
|
||||
failed: 'failed'
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="issue-card" onclick="openIssueDetail('${issue.id}')">
|
||||
<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-status ${statusColors[issue.status] || ''}">${issue.status || 'unknown'}</span>
|
||||
</div>
|
||||
<span class="issue-priority" title="${t('issues.priority') || 'Priority'}: ${issue.priority || 3}">
|
||||
${renderPriorityStars(issue.priority || 3)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="issue-title text-foreground font-medium mb-2">${issue.title || issue.id}</h3>
|
||||
|
||||
<div class="issue-meta flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="file-text" class="w-3.5 h-3.5"></i>
|
||||
${issue.task_count || 0} ${t('issues.tasks') || 'tasks'}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<i data-lucide="lightbulb" class="w-3.5 h-3.5"></i>
|
||||
${issue.solution_count || 0} ${t('issues.solutions') || 'solutions'}
|
||||
</span>
|
||||
${issue.bound_solution_id ? `
|
||||
<span class="flex items-center gap-1 text-primary">
|
||||
<i data-lucide="link" class="w-3.5 h-3.5"></i>
|
||||
${t('issues.boundSolution') || 'Bound'}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPriorityStars(priority) {
|
||||
const maxStars = 5;
|
||||
let stars = '';
|
||||
for (let i = 1; i <= maxStars; i++) {
|
||||
stars += `<i data-lucide="star" class="w-3 h-3 ${i <= priority ? 'text-warning fill-warning' : 'text-muted'}"></i>`;
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
|
||||
function filterIssuesByStatus(status) {
|
||||
issueData.statusFilter = status;
|
||||
renderIssueView();
|
||||
}
|
||||
|
||||
// ========== Queue Section ==========
|
||||
function renderQueueSection() {
|
||||
const queue = issueData.queue;
|
||||
const groups = queue.execution_groups || [];
|
||||
const groupedItems = queue.grouped_items || {};
|
||||
|
||||
if (groups.length === 0 && (!queue.queue || queue.queue.length === 0)) {
|
||||
return `
|
||||
<div class="queue-empty">
|
||||
<i data-lucide="git-branch" class="w-12 h-12 text-muted-foreground mb-4"></i>
|
||||
<p class="text-muted-foreground">${t('issues.queueEmpty') || 'Queue is empty'}</p>
|
||||
<p class="text-sm text-muted-foreground mt-2">Run /issue:queue to form execution queue</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<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'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="queue-timeline">
|
||||
${groups.map(group => renderQueueGroup(group, groupedItems[group.id] || [])).join('')}
|
||||
</div>
|
||||
|
||||
${queue.conflicts && queue.conflicts.length > 0 ? renderConflictsSection(queue.conflicts) : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderQueueGroup(group, items) {
|
||||
const isParallel = group.type === 'parallel';
|
||||
|
||||
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 ? t('issues.parallelGroup') || 'Parallel' : t('issues.sequentialGroup') || 'Sequential'})
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${group.task_count} tasks</span>
|
||||
</div>
|
||||
<div class="queue-items ${isParallel ? 'parallel' : 'sequential'}">
|
||||
${items.map((item, idx) => renderQueueItem(item, idx, items.length)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderQueueItem(item, index, total) {
|
||||
const statusColors = {
|
||||
pending: '',
|
||||
ready: 'ready',
|
||||
executing: 'executing',
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
blocked: 'blocked'
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="queue-item ${statusColors[item.status] || ''}"
|
||||
draggable="true"
|
||||
data-queue-id="${item.queue_id}"
|
||||
data-group-id="${item.execution_group}"
|
||||
onclick="openQueueItemDetail('${item.queue_id}')">
|
||||
<span class="queue-item-id font-mono text-xs">${item.queue_id}</span>
|
||||
<span class="queue-item-issue text-xs text-muted-foreground">${item.issue_id}</span>
|
||||
<span class="queue-item-task text-sm">${item.task_id}</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="${t('issues.dependsOn') || 'Depends on'}: ${item.depends_on.join(', ')}">
|
||||
<i data-lucide="link" class="w-3 h-3"></i>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderConflictsSection(conflicts) {
|
||||
return `
|
||||
<div class="conflicts-section mt-6">
|
||||
<h3 class="text-sm font-semibold text-foreground mb-3">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 inline text-warning mr-1"></i>
|
||||
Conflicts (${conflicts.length})
|
||||
</h3>
|
||||
<div class="conflicts-list">
|
||||
${conflicts.map(c => `
|
||||
<div class="conflict-item">
|
||||
<span class="conflict-file font-mono text-xs">${c.file}</span>
|
||||
<span class="conflict-tasks text-xs text-muted-foreground">${c.tasks.join(' → ')}</span>
|
||||
<span class="conflict-status ${c.resolved ? 'resolved' : 'pending'}">
|
||||
${c.resolved ? 'Resolved' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ========== Drag-Drop for Queue ==========
|
||||
function initQueueDragDrop() {
|
||||
const items = document.querySelectorAll('.queue-item[draggable="true"]');
|
||||
|
||||
items.forEach(item => {
|
||||
item.addEventListener('dragstart', handleIssueDragStart);
|
||||
item.addEventListener('dragend', handleIssueDragEnd);
|
||||
item.addEventListener('dragover', handleIssueDragOver);
|
||||
item.addEventListener('drop', handleIssueDrop);
|
||||
});
|
||||
}
|
||||
|
||||
function handleIssueDragStart(e) {
|
||||
const item = e.target.closest('.queue-item');
|
||||
if (!item) return;
|
||||
|
||||
issueDragState.dragging = item.dataset.queueId;
|
||||
issueDragState.groupId = item.dataset.groupId;
|
||||
|
||||
item.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', item.dataset.queueId);
|
||||
}
|
||||
|
||||
function handleIssueDragEnd(e) {
|
||||
const item = e.target.closest('.queue-item');
|
||||
if (item) {
|
||||
item.classList.remove('dragging');
|
||||
}
|
||||
issueDragState.dragging = null;
|
||||
issueDragState.groupId = null;
|
||||
|
||||
// Remove all placeholders
|
||||
document.querySelectorAll('.queue-drop-placeholder').forEach(p => p.remove());
|
||||
}
|
||||
|
||||
function handleIssueDragOver(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target.closest('.queue-item');
|
||||
if (!target || target.dataset.queueId === issueDragState.dragging) return;
|
||||
|
||||
// Only allow drag within same group
|
||||
if (target.dataset.groupId !== issueDragState.groupId) {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
function handleIssueDrop(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target.closest('.queue-item');
|
||||
if (!target || !issueDragState.dragging) return;
|
||||
|
||||
// Only allow drop within same group
|
||||
if (target.dataset.groupId !== issueDragState.groupId) return;
|
||||
|
||||
const container = target.closest('.queue-items');
|
||||
if (!container) return;
|
||||
|
||||
// Get new order
|
||||
const items = Array.from(container.querySelectorAll('.queue-item'));
|
||||
const draggedItem = items.find(i => i.dataset.queueId === issueDragState.dragging);
|
||||
const targetIndex = items.indexOf(target);
|
||||
const draggedIndex = items.indexOf(draggedItem);
|
||||
|
||||
if (draggedIndex === targetIndex) return;
|
||||
|
||||
// Reorder in DOM
|
||||
if (draggedIndex < targetIndex) {
|
||||
target.after(draggedItem);
|
||||
} else {
|
||||
target.before(draggedItem);
|
||||
}
|
||||
|
||||
// Get new order and save
|
||||
const newOrder = Array.from(container.querySelectorAll('.queue-item')).map(i => i.dataset.queueId);
|
||||
saveQueueOrder(issueDragState.groupId, newOrder);
|
||||
}
|
||||
|
||||
async function saveQueueOrder(groupId, newOrder) {
|
||||
try {
|
||||
const response = await fetch('/api/queue/reorder?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ groupId, newOrder })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save queue order');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.error) {
|
||||
showNotification(result.error, 'error');
|
||||
} else {
|
||||
showNotification('Queue reordered', 'success');
|
||||
// Reload queue data
|
||||
await loadQueueData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save queue order:', err);
|
||||
showNotification('Failed to save queue order', 'error');
|
||||
// Reload to restore original order
|
||||
await loadQueueData();
|
||||
renderIssueView();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Detail Panel ==========
|
||||
async function openIssueDetail(issueId) {
|
||||
const panel = document.getElementById('issueDetailPanel');
|
||||
if (!panel) return;
|
||||
|
||||
panel.innerHTML = '<div class="p-8 text-center"><i data-lucide="loader-2" class="w-8 h-8 animate-spin mx-auto"></i></div>';
|
||||
panel.classList.remove('hidden');
|
||||
lucide.createIcons();
|
||||
|
||||
const detail = await loadIssueDetail(issueId);
|
||||
if (!detail) {
|
||||
panel.innerHTML = '<div class="p-8 text-center text-destructive">Failed to load issue</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
issueData.selectedIssue = detail;
|
||||
renderIssueDetailPanel(detail);
|
||||
}
|
||||
|
||||
function renderIssueDetailPanel(issue) {
|
||||
const panel = document.getElementById('issueDetailPanel');
|
||||
if (!panel) return;
|
||||
|
||||
const boundSolution = issue.solutions?.find(s => s.is_bound);
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="issue-detail-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">${issue.id}</h3>
|
||||
<button class="btn-icon" onclick="closeIssueDetail()">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="issue-status ${issue.status || ''}">${issue.status || 'unknown'}</span>
|
||||
</div>
|
||||
|
||||
<div class="issue-detail-content">
|
||||
<!-- Title (editable) -->
|
||||
<div class="detail-section">
|
||||
<label class="detail-label">Title</label>
|
||||
<div class="detail-editable" id="issueTitle">
|
||||
<span class="detail-value">${issue.title || issue.id}</span>
|
||||
<button class="btn-edit" onclick="startEditField('${issue.id}', 'title', '${(issue.title || issue.id).replace(/'/g, "\\'")}')">
|
||||
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context (editable) -->
|
||||
<div class="detail-section">
|
||||
<label class="detail-label">Context</label>
|
||||
<div class="detail-context" id="issueContext">
|
||||
<pre class="detail-pre">${issue.context || 'No context'}</pre>
|
||||
<button class="btn-edit" onclick="startEditContext('${issue.id}')">
|
||||
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solutions -->
|
||||
<div class="detail-section">
|
||||
<label class="detail-label">${t('issues.solutions') || 'Solutions'} (${issue.solutions?.length || 0})</label>
|
||||
<div class="solutions-list">
|
||||
${(issue.solutions || []).map(sol => `
|
||||
<div class="solution-item ${sol.is_bound ? 'bound' : ''}" onclick="toggleSolutionExpand('${sol.id}')">
|
||||
<div class="solution-header">
|
||||
<span class="solution-id font-mono text-xs">${sol.id}</span>
|
||||
${sol.is_bound ? '<span class="solution-bound-badge">Bound</span>' : ''}
|
||||
<span class="solution-tasks text-xs">${sol.tasks?.length || 0} tasks</span>
|
||||
</div>
|
||||
<div class="solution-tasks-list hidden" id="solution-${sol.id}">
|
||||
${(sol.tasks || []).map(task => `
|
||||
<div class="task-item">
|
||||
<span class="task-id font-mono">${task.id}</span>
|
||||
<span class="task-action ${task.action?.toLowerCase() || ''}">${task.action || 'Unknown'}</span>
|
||||
<span class="task-title">${task.title || ''}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<p class="text-sm text-muted-foreground">No solutions</p>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks (from tasks.jsonl) -->
|
||||
<div class="detail-section">
|
||||
<label class="detail-label">${t('issues.tasks') || 'Tasks'} (${issue.tasks?.length || 0})</label>
|
||||
<div class="tasks-list">
|
||||
${(issue.tasks || []).map(task => `
|
||||
<div class="task-item-detail">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono text-sm">${task.id}</span>
|
||||
<select class="task-status-select" onchange="updateTaskStatus('${issue.id}', '${task.id}', this.value)">
|
||||
${['pending', 'ready', 'in_progress', 'completed', 'failed', 'paused', 'skipped'].map(s =>
|
||||
`<option value="${s}" ${task.status === s ? 'selected' : ''}>${s}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<p class="task-title-detail">${task.title || task.description || ''}</p>
|
||||
</div>
|
||||
`).join('') || '<p class="text-sm text-muted-foreground">No tasks</p>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function closeIssueDetail() {
|
||||
const panel = document.getElementById('issueDetailPanel');
|
||||
if (panel) {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
issueData.selectedIssue = null;
|
||||
}
|
||||
|
||||
function toggleSolutionExpand(solId) {
|
||||
const el = document.getElementById('solution-' + solId);
|
||||
if (el) {
|
||||
el.classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function openQueueItemDetail(queueId) {
|
||||
const item = issueData.queue.queue?.find(q => q.queue_id === queueId);
|
||||
if (item) {
|
||||
openIssueDetail(item.issue_id);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Edit Functions ==========
|
||||
function startEditField(issueId, field, currentValue) {
|
||||
const container = document.getElementById('issueTitle');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<input type="text" class="edit-input" id="editField" value="${currentValue}" />
|
||||
<div class="edit-actions">
|
||||
<button class="btn-save" onclick="saveFieldEdit('${issueId}', '${field}')">
|
||||
<i data-lucide="check" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<button class="btn-cancel" onclick="cancelEdit()">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
document.getElementById('editField')?.focus();
|
||||
}
|
||||
|
||||
function startEditContext(issueId) {
|
||||
const container = document.getElementById('issueContext');
|
||||
const currentValue = issueData.selectedIssue?.context || '';
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<textarea class="edit-textarea" id="editContext" rows="8">${currentValue}</textarea>
|
||||
<div class="edit-actions">
|
||||
<button class="btn-save" onclick="saveContextEdit('${issueId}')">
|
||||
<i data-lucide="check" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<button class="btn-cancel" onclick="cancelEdit()">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
document.getElementById('editContext')?.focus();
|
||||
}
|
||||
|
||||
async function saveFieldEdit(issueId, field) {
|
||||
const input = document.getElementById('editField');
|
||||
if (!input) return;
|
||||
|
||||
const value = input.value.trim();
|
||||
if (!value) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update');
|
||||
|
||||
showNotification('Updated ' + field, 'success');
|
||||
|
||||
// Refresh data
|
||||
await loadIssueData();
|
||||
const detail = await loadIssueDetail(issueId);
|
||||
if (detail) {
|
||||
issueData.selectedIssue = detail;
|
||||
renderIssueDetailPanel(detail);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification('Failed to update', 'error');
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveContextEdit(issueId) {
|
||||
const textarea = document.getElementById('editContext');
|
||||
if (!textarea) return;
|
||||
|
||||
const value = textarea.value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ context: value })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update');
|
||||
|
||||
showNotification('Context updated', 'success');
|
||||
|
||||
// Refresh detail
|
||||
const detail = await loadIssueDetail(issueId);
|
||||
if (detail) {
|
||||
issueData.selectedIssue = detail;
|
||||
renderIssueDetailPanel(detail);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification('Failed to update context', 'error');
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
if (issueData.selectedIssue) {
|
||||
renderIssueDetailPanel(issueData.selectedIssue);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskStatus(issueId, taskId, status) {
|
||||
try {
|
||||
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '/tasks/' + encodeURIComponent(taskId) + '?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update task');
|
||||
|
||||
showNotification('Task status updated', 'success');
|
||||
} catch (err) {
|
||||
showNotification('Failed to update task status', 'error');
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CLI Stream Viewer Button -->
|
||||
<button class="cli-stream-btn p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded relative"
|
||||
id="cliStreamBtn"
|
||||
onclick="toggleCliStreamViewer()"
|
||||
data-i18n-title="header.cliStream"
|
||||
title="CLI Stream Viewer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
<span class="cli-stream-badge" id="cliStreamBadge"></span>
|
||||
</button>
|
||||
<!-- Refresh Button -->
|
||||
<button class="refresh-btn p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded" id="refreshWorkspace" data-i18n-title="header.refreshWorkspace" title="Refresh workspace">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -394,6 +406,21 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Issues Section -->
|
||||
<div class="mb-2" id="issuesNav">
|
||||
<div class="flex items-center px-4 py-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<i data-lucide="clipboard-list" class="nav-section-icon mr-2"></i>
|
||||
<span class="nav-section-title" data-i18n="nav.issues">Issues</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li class="nav-item flex items-center gap-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="issue-manager" data-tooltip="Issue Manager">
|
||||
<i data-lucide="list-checks" class="nav-icon"></i>
|
||||
<span class="nav-text flex-1" data-i18n="nav.issueManager">Manager</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeIssues">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- MCP Servers Section -->
|
||||
<div class="mb-2" id="mcpServersNav">
|
||||
<div class="flex items-center px-4 py-2 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
@@ -578,6 +605,34 @@
|
||||
<div class="drawer-overlay hidden fixed inset-0 bg-black/50 z-40" id="drawerOverlay" onclick="closeTaskDrawer()"></div>
|
||||
</div>
|
||||
|
||||
<!-- CLI Stream Viewer Panel -->
|
||||
<div class="cli-stream-viewer" id="cliStreamViewer">
|
||||
<div class="cli-stream-header">
|
||||
<div class="cli-stream-title">
|
||||
<i data-lucide="terminal"></i>
|
||||
<span data-i18n="cliStream.title">CLI Stream</span>
|
||||
<span class="cli-stream-count-badge" id="cliStreamCountBadge">0</span>
|
||||
</div>
|
||||
<div class="cli-stream-actions">
|
||||
<button class="cli-stream-action-btn" onclick="clearCompletedStreams()" data-i18n="cliStream.clearCompleted">
|
||||
<i data-lucide="trash-2"></i>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
<button class="cli-stream-close-btn" onclick="toggleCliStreamViewer()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-stream-tabs" id="cliStreamTabs">
|
||||
<!-- Dynamic tabs -->
|
||||
</div>
|
||||
<div class="cli-stream-content" id="cliStreamContent">
|
||||
<!-- Terminal output -->
|
||||
</div>
|
||||
<div class="cli-stream-status" id="cliStreamStatus">
|
||||
<!-- Status bar -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-stream-overlay" id="cliStreamOverlay" onclick="toggleCliStreamViewer()"></div>
|
||||
|
||||
<!-- Markdown Preview Modal -->
|
||||
<div id="markdownModal" class="markdown-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div class="markdown-modal-backdrop absolute inset-0 bg-black/60" onclick="closeMarkdownModal()"></div>
|
||||
|
||||
Reference in New Issue
Block a user