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:
catlog22
2025-12-27 09:46:12 +08:00
parent cdf4833977
commit 0157e36344
23 changed files with 6843 additions and 1293 deletions

View File

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

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

View File

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

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

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

View 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();
}

View File

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

View File

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

View File

@@ -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': '已复制!',

View 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');
}
}

View File

@@ -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">&times;</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>