feat: Enhance issue and solution management with new UI components and functionality

- Added internationalization support for new issue and solution-related strings in i18n.js.
- Implemented a solution detail modal in issue-manager.js to display solution information and bind/unbind actions.
- Enhanced the skill loading function to combine project and user skills in hook-manager.js.
- Improved queue rendering logic to handle empty states and display queue statistics in issue-manager.js.
- Introduced command modals for queue operations, allowing users to generate execution queues via CLI commands.
- Added functionality to auto-generate issue IDs and regenerate them in the create issue modal.
- Implemented detailed rendering of solution tasks, including acceptance criteria and modification points.
This commit is contained in:
catlog22
2025-12-27 11:27:45 +08:00
parent 8f310339df
commit 4da06864f8
11 changed files with 2490 additions and 169 deletions

View File

@@ -36,6 +36,26 @@ interface Issue {
completed_at?: string;
}
interface TaskTest {
unit?: string[]; // Unit test requirements
integration?: string[]; // Integration test requirements
commands?: string[]; // Test commands to run
coverage_target?: number; // Minimum coverage % (optional)
}
interface TaskAcceptance {
criteria: string[]; // Acceptance criteria (testable)
verification: string[]; // How to verify each criterion
manual_checks?: string[]; // Manual verification steps if needed
}
interface TaskCommit {
type: 'feat' | 'fix' | 'refactor' | 'test' | 'docs' | 'chore';
scope: string; // Commit scope (e.g., "auth", "api")
message_template: string; // Commit message template
breaking?: boolean; // Breaking change flag
}
interface SolutionTask {
id: string;
title: string;
@@ -43,11 +63,26 @@ interface SolutionTask {
action: string;
description?: string;
modification_points?: { file: string; target: string; change: string }[];
implementation: string[];
acceptance: string[];
// Lifecycle phases (closed-loop)
implementation: string[]; // Implementation steps
test: TaskTest; // Test requirements
regression: string[]; // Regression check points
acceptance: TaskAcceptance; // Acceptance criteria & verification
commit: TaskCommit; // Commit specification
depends_on: string[];
estimated_minutes?: number;
executor: 'codex' | 'gemini' | 'agent' | 'auto';
// Lifecycle status tracking
lifecycle_status?: {
implemented: boolean;
tested: boolean;
regression_passed: boolean;
accepted: boolean;
committed: boolean;
};
status?: string;
priority?: number;
}
@@ -83,8 +118,13 @@ interface QueueItem {
}
interface Queue {
id: string; // Queue unique ID: QUE-YYYYMMDD-HHMMSS
name?: string; // Optional queue name
status: 'active' | 'completed' | 'archived' | 'failed';
issue_ids: string[]; // Issues in this queue
queue: QueueItem[];
conflicts: any[];
execution_groups?: any[];
_metadata: {
version: string;
total_tasks: number;
@@ -92,10 +132,24 @@ interface Queue {
executing_count: number;
completed_count: number;
failed_count: number;
last_updated: string;
created_at: string;
updated_at: string;
};
}
interface QueueIndex {
active_queue_id: string | null;
queues: {
id: string;
status: string;
issue_ids: string[];
total_tasks: number;
completed_tasks: number;
created_at: string;
completed_at?: string;
}[];
}
interface IssueOptions {
status?: string;
title?: string;
@@ -208,40 +262,121 @@ function generateSolutionId(): string {
return `SOL-${ts}`;
}
// ============ Queue JSON ============
// ============ Queue Management (Multi-Queue) ============
function readQueue(): Queue {
const path = join(getIssuesDir(), 'queue.json');
function getQueuesDir(): string {
return join(getIssuesDir(), 'queues');
}
function ensureQueuesDir(): void {
const dir = getQueuesDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
function readQueueIndex(): QueueIndex {
const path = join(getQueuesDir(), 'index.json');
if (!existsSync(path)) {
return {
queue: [],
conflicts: [],
_metadata: {
version: '2.0',
total_tasks: 0,
pending_count: 0,
executing_count: 0,
completed_count: 0,
failed_count: 0,
last_updated: new Date().toISOString()
}
};
return { active_queue_id: null, queues: [] };
}
return JSON.parse(readFileSync(path, 'utf-8'));
}
function writeQueueIndex(index: QueueIndex): void {
ensureQueuesDir();
writeFileSync(join(getQueuesDir(), 'index.json'), JSON.stringify(index, null, 2), 'utf-8');
}
function generateQueueFileId(): string {
const now = new Date();
const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
return `QUE-${ts}`;
}
function readQueue(queueId?: string): Queue | null {
const index = readQueueIndex();
const targetId = queueId || index.active_queue_id;
if (!targetId) return null;
const path = join(getQueuesDir(), `${targetId}.json`);
if (!existsSync(path)) return null;
return JSON.parse(readFileSync(path, 'utf-8'));
}
function readActiveQueue(): Queue {
const queue = readQueue();
if (queue) return queue;
// Return empty queue structure if no active queue
return createEmptyQueue();
}
function createEmptyQueue(): Queue {
return {
id: generateQueueFileId(),
status: 'active',
issue_ids: [],
queue: [],
conflicts: [],
_metadata: {
version: '2.0',
total_tasks: 0,
pending_count: 0,
executing_count: 0,
completed_count: 0,
failed_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
};
}
function writeQueue(queue: Queue): void {
ensureIssuesDir();
ensureQueuesDir();
// Update metadata counts
queue._metadata.total_tasks = queue.queue.length;
queue._metadata.pending_count = queue.queue.filter(q => q.status === 'pending').length;
queue._metadata.executing_count = queue.queue.filter(q => q.status === 'executing').length;
queue._metadata.completed_count = queue.queue.filter(q => q.status === 'completed').length;
queue._metadata.failed_count = queue.queue.filter(q => q.status === 'failed').length;
queue._metadata.last_updated = new Date().toISOString();
writeFileSync(join(getIssuesDir(), 'queue.json'), JSON.stringify(queue, null, 2), 'utf-8');
queue._metadata.updated_at = new Date().toISOString();
// Write queue file
const path = join(getQueuesDir(), `${queue.id}.json`);
writeFileSync(path, JSON.stringify(queue, null, 2), 'utf-8');
// Update index
const index = readQueueIndex();
const existingIdx = index.queues.findIndex(q => q.id === queue.id);
const indexEntry = {
id: queue.id,
status: queue.status,
issue_ids: queue.issue_ids,
total_tasks: queue._metadata.total_tasks,
completed_tasks: queue._metadata.completed_count,
created_at: queue._metadata.created_at,
completed_at: queue.status === 'completed' ? new Date().toISOString() : undefined
};
if (existingIdx >= 0) {
index.queues[existingIdx] = indexEntry;
} else {
index.queues.unshift(indexEntry);
}
if (queue.status === 'active') {
index.active_queue_id = queue.id;
}
writeQueueIndex(index);
}
function generateQueueId(queue: Queue): string {
function generateQueueItemId(queue: Queue): string {
const maxNum = queue.queue.reduce((max, q) => {
const match = q.queue_id.match(/^Q-(\d+)$/);
return match ? Math.max(max, parseInt(match[1])) : max;
@@ -379,17 +514,19 @@ async function listAction(issueId: string | undefined, options: IssueOptions): P
async function statusAction(issueId: string | undefined, options: IssueOptions): Promise<void> {
if (!issueId) {
// Show queue status
const queue = readQueue();
const queue = readActiveQueue();
const issues = readIssues();
const index = readQueueIndex();
if (options.json) {
console.log(JSON.stringify({ queue: queue._metadata, issues: issues.length }, null, 2));
console.log(JSON.stringify({ queue: queue._metadata, issues: issues.length, queues: index.queues.length }, null, 2));
return;
}
console.log(chalk.bold.cyan('\nSystem Status\n'));
console.log(`Issues: ${issues.length}`);
console.log(`Queue: ${queue._metadata.total_tasks} tasks`);
console.log(`Queues: ${index.queues.length} (Active: ${index.active_queue_id || 'none'})`);
console.log(`Active Queue: ${queue._metadata.total_tasks} tasks`);
console.log(` Pending: ${queue._metadata.pending_count}`);
console.log(` Executing: ${queue._metadata.executing_count}`);
console.log(` Completed: ${queue._metadata.completed_count}`);
@@ -497,7 +634,20 @@ async function taskAction(issueId: string | undefined, taskId: string | undefine
action: 'Implement',
description: options.description || options.title,
implementation: [],
acceptance: ['Task completed successfully'],
test: {
unit: [],
commands: ['npm test']
},
regression: ['npm test'],
acceptance: {
criteria: ['Task completed successfully'],
verification: ['Manual verification']
},
commit: {
type: 'feat',
scope: 'core',
message_template: `feat(core): ${options.title}`
},
depends_on: [],
executor: (options.executor as any) || 'auto'
};
@@ -590,13 +740,90 @@ async function bindAction(issueId: string | undefined, solutionId: string | unde
}
/**
* queue - Queue management (list / add)
* queue - Queue management (list / add / history)
*/
async function queueAction(subAction: string | undefined, issueId: string | undefined, options: IssueOptions): Promise<void> {
const queue = readQueue();
// List all queues (history)
if (subAction === 'list' || subAction === 'history') {
const index = readQueueIndex();
if (options.json) {
console.log(JSON.stringify(index, null, 2));
return;
}
console.log(chalk.bold.cyan('\nQueue History\n'));
console.log(chalk.gray(`Active: ${index.active_queue_id || 'none'}`));
console.log();
if (index.queues.length === 0) {
console.log(chalk.yellow('No queues found'));
console.log(chalk.gray('Create one: ccw issue queue add <issue-id>'));
return;
}
console.log(chalk.gray('ID'.padEnd(22) + 'Status'.padEnd(12) + 'Tasks'.padEnd(10) + 'Issues'));
console.log(chalk.gray('-'.repeat(70)));
for (const q of index.queues) {
const statusColor = {
'active': chalk.green,
'completed': chalk.cyan,
'archived': chalk.gray,
'failed': chalk.red
}[q.status] || chalk.white;
const marker = q.id === index.active_queue_id ? '→ ' : ' ';
console.log(
marker +
q.id.padEnd(20) +
statusColor(q.status.padEnd(12)) +
`${q.completed_tasks}/${q.total_tasks}`.padEnd(10) +
q.issue_ids.join(', ')
);
}
return;
}
// Switch active queue
if (subAction === 'switch' && issueId) {
const queueId = issueId; // issueId is actually queue ID here
const targetQueue = readQueue(queueId);
if (!targetQueue) {
console.error(chalk.red(`Queue "${queueId}" not found`));
process.exit(1);
}
const index = readQueueIndex();
index.active_queue_id = queueId;
writeQueueIndex(index);
console.log(chalk.green(`✓ Switched to queue ${queueId}`));
return;
}
// Archive current queue
if (subAction === 'archive') {
const queue = readActiveQueue();
if (!queue.id || queue.queue.length === 0) {
console.log(chalk.yellow('No active queue to archive'));
return;
}
queue.status = 'archived';
writeQueue(queue);
const index = readQueueIndex();
index.active_queue_id = null;
writeQueueIndex(index);
console.log(chalk.green(`✓ Archived queue ${queue.id}`));
return;
}
// Add issue tasks to queue
if (subAction === 'add' && issueId) {
// Add issue tasks to queue
const issue = findIssue(issueId);
if (!issue) {
console.error(chalk.red(`Issue "${issueId}" not found`));
@@ -610,13 +837,27 @@ async function queueAction(subAction: string | undefined, issueId: string | unde
process.exit(1);
}
// Get or create active queue (create new if current is completed/archived)
let queue = readActiveQueue();
const isNewQueue = queue.queue.length === 0 || queue.status !== 'active';
if (queue.status !== 'active') {
// Create new queue if current is not active
queue = createEmptyQueue();
}
// Add issue to queue's issue list
if (!queue.issue_ids.includes(issueId)) {
queue.issue_ids.push(issueId);
}
let added = 0;
for (const task of solution.tasks) {
const exists = queue.queue.some(q => q.issue_id === issueId && q.task_id === task.id);
if (exists) continue;
queue.queue.push({
queue_id: generateQueueId(queue),
queue_id: generateQueueItemId(queue),
issue_id: issueId,
solution_id: solution.id,
task_id: task.id,
@@ -637,26 +878,35 @@ async function queueAction(subAction: string | undefined, issueId: string | unde
writeQueue(queue);
updateIssue(issueId, { status: 'queued', queued_at: new Date().toISOString() });
console.log(chalk.green(`✓ Added ${added} tasks to queue from ${solution.id}`));
if (isNewQueue) {
console.log(chalk.green(`✓ Created queue ${queue.id}`));
}
console.log(chalk.green(`✓ Added ${added} tasks from ${solution.id}`));
return;
}
// List queue
// Show current queue
const queue = readActiveQueue();
if (options.json) {
console.log(JSON.stringify(queue, null, 2));
return;
}
console.log(chalk.bold.cyan('\nExecution Queue\n'));
console.log(chalk.gray(`Total: ${queue._metadata.total_tasks} | Pending: ${queue._metadata.pending_count} | Executing: ${queue._metadata.executing_count} | Completed: ${queue._metadata.completed_count}`));
console.log();
console.log(chalk.bold.cyan('\nActive Queue\n'));
if (queue.queue.length === 0) {
console.log(chalk.yellow('Queue is empty'));
console.log(chalk.gray('Add tasks: ccw issue queue add <issue-id>'));
if (!queue.id || queue.queue.length === 0) {
console.log(chalk.yellow('No active queue'));
console.log(chalk.gray('Create one: ccw issue queue add <issue-id>'));
console.log(chalk.gray('Or list history: ccw issue queue list'));
return;
}
console.log(chalk.gray(`Queue: ${queue.id}`));
console.log(chalk.gray(`Issues: ${queue.issue_ids.join(', ')}`));
console.log(chalk.gray(`Total: ${queue._metadata.total_tasks} | Pending: ${queue._metadata.pending_count} | Executing: ${queue._metadata.executing_count} | Completed: ${queue._metadata.completed_count}`));
console.log();
console.log(chalk.gray('QueueID'.padEnd(10) + 'Issue'.padEnd(15) + 'Task'.padEnd(8) + 'Status'.padEnd(12) + 'Executor'));
console.log(chalk.gray('-'.repeat(60)));
@@ -684,7 +934,7 @@ async function queueAction(subAction: string | undefined, issueId: string | unde
* next - Get next ready task for execution (JSON output)
*/
async function nextAction(options: IssueOptions): Promise<void> {
const queue = readQueue();
const queue = readActiveQueue();
// Find ready tasks
const readyTasks = queue.queue.filter(item => {
@@ -749,7 +999,7 @@ async function doneAction(queueId: string | undefined, options: IssueOptions): P
process.exit(1);
}
const queue = readQueue();
const queue = readActiveQueue();
const idx = queue.queue.findIndex(q => q.queue_id === queueId);
if (idx === -1) {
@@ -771,31 +1021,49 @@ async function doneAction(queueId: string | undefined, options: IssueOptions): P
}
}
writeQueue(queue);
// Check if all issue tasks are complete
const issueId = queue.queue[idx].issue_id;
const issueTasks = queue.queue.filter(q => q.issue_id === issueId);
const allComplete = issueTasks.every(q => q.status === 'completed');
const anyFailed = issueTasks.some(q => q.status === 'failed');
const allIssueComplete = issueTasks.every(q => q.status === 'completed');
const anyIssueFailed = issueTasks.some(q => q.status === 'failed');
if (allComplete) {
if (allIssueComplete) {
updateIssue(issueId, { status: 'completed', completed_at: new Date().toISOString() });
console.log(chalk.green(`${queueId} completed`));
console.log(chalk.green(`✓ Issue ${issueId} completed (all tasks done)`));
} else if (anyFailed) {
} else if (anyIssueFailed) {
updateIssue(issueId, { status: 'failed' });
console.log(chalk.red(`${queueId} failed`));
} else {
console.log(isFail ? chalk.red(`${queueId} failed`) : chalk.green(`${queueId} completed`));
}
// Check if entire queue is complete
const allQueueComplete = queue.queue.every(q => q.status === 'completed');
const anyQueueFailed = queue.queue.some(q => q.status === 'failed');
if (allQueueComplete) {
queue.status = 'completed';
console.log(chalk.green(`\n✓ Queue ${queue.id} completed (all tasks done)`));
} else if (anyQueueFailed && queue.queue.every(q => q.status === 'completed' || q.status === 'failed')) {
queue.status = 'failed';
console.log(chalk.yellow(`\n⚠ Queue ${queue.id} has failed tasks`));
}
writeQueue(queue);
}
/**
* retry - Retry failed tasks
*/
async function retryAction(issueId: string | undefined, options: IssueOptions): Promise<void> {
const queue = readQueue();
const queue = readActiveQueue();
if (!queue.id || queue.queue.length === 0) {
console.log(chalk.yellow('No active queue'));
return;
}
let updated = 0;
for (const item of queue.queue) {
@@ -815,6 +1083,11 @@ async function retryAction(issueId: string | undefined, options: IssueOptions):
return;
}
// Reset queue status if it was failed
if (queue.status === 'failed') {
queue.status = 'active';
}
writeQueue(queue);
if (issueId) {
@@ -873,7 +1146,7 @@ export async function issueCommand(
await doneAction(argsArray[0], { ...options, fail: true });
break;
default:
console.log(chalk.bold.cyan('\nCCW Issue Management (v2.0 - Unified JSONL)\n'));
console.log(chalk.bold.cyan('\nCCW Issue Management (v3.0 - Multi-Queue + Lifecycle)\n'));
console.log(chalk.bold('Core Commands:'));
console.log(chalk.gray(' init <issue-id> Initialize new issue'));
console.log(chalk.gray(' list [issue-id] List issues or tasks'));
@@ -882,8 +1155,11 @@ export async function issueCommand(
console.log(chalk.gray(' bind <issue-id> [sol-id] Bind solution (--solution <path> to register)'));
console.log();
console.log(chalk.bold('Queue Commands:'));
console.log(chalk.gray(' queue [list] Show execution queue'));
console.log(chalk.gray(' queue add <issue-id> Add bound solution tasks to queue'));
console.log(chalk.gray(' queue Show active queue'));
console.log(chalk.gray(' queue list List all queues (history)'));
console.log(chalk.gray(' queue add <issue-id> Add issue to active queue (or create new)'));
console.log(chalk.gray(' queue switch <queue-id> Switch active queue'));
console.log(chalk.gray(' queue archive Archive current queue'));
console.log(chalk.gray(' retry [issue-id] Retry failed tasks'));
console.log();
console.log(chalk.bold('Execution Endpoints:'));
@@ -902,6 +1178,7 @@ export async function issueCommand(
console.log(chalk.bold('Storage:'));
console.log(chalk.gray(' .workflow/issues/issues.jsonl All issues'));
console.log(chalk.gray(' .workflow/issues/solutions/*.jsonl Solutions per issue'));
console.log(chalk.gray(' .workflow/issues/queue.json Execution queue'));
console.log(chalk.gray(' .workflow/issues/queues/ Queue files (multi-queue)'));
console.log(chalk.gray(' .workflow/issues/queues/index.json Queue index'));
}
}

View File

@@ -72,21 +72,68 @@ function writeSolutionsJsonl(issuesDir: string, issueId: string, solutions: any[
}
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 new multi-queue structure first
const queuesDir = join(issuesDir, 'queues');
const indexPath = join(queuesDir, 'index.json');
if (existsSync(indexPath)) {
try {
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
const activeQueueId = index.active_queue_id;
if (activeQueueId) {
const queueFilePath = join(queuesDir, `${activeQueueId}.json`);
if (existsSync(queueFilePath)) {
return JSON.parse(readFileSync(queueFilePath, 'utf8'));
}
}
} catch {
// Fall through to legacy check
}
}
try {
return JSON.parse(readFileSync(queuePath, 'utf8'));
} catch {
return { queue: [], conflicts: [], execution_groups: [], _metadata: { version: '1.0', total_tasks: 0 } };
// Fallback to legacy queue.json
const legacyQueuePath = join(issuesDir, 'queue.json');
if (existsSync(legacyQueuePath)) {
try {
return JSON.parse(readFileSync(legacyQueuePath, 'utf8'));
} catch {
// Return empty queue
}
}
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));
queue._metadata = { ...queue._metadata, updated_at: new Date().toISOString(), total_tasks: queue.queue?.length || 0 };
// Check if using new multi-queue structure
const queuesDir = join(issuesDir, 'queues');
const indexPath = join(queuesDir, 'index.json');
if (existsSync(indexPath) && queue.id) {
// Write to new structure
const queueFilePath = join(queuesDir, `${queue.id}.json`);
writeFileSync(queueFilePath, JSON.stringify(queue, null, 2));
// Update index metadata
try {
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
const queueEntry = index.queues?.find((q: any) => q.id === queue.id);
if (queueEntry) {
queueEntry.total_tasks = queue.queue?.length || 0;
queueEntry.completed_tasks = queue.queue?.filter((i: any) => i.status === 'completed').length || 0;
writeFileSync(indexPath, JSON.stringify(index, null, 2));
}
} catch {
// Ignore index update errors
}
} else {
// Fallback to legacy queue.json
writeFileSync(join(issuesDir, 'queue.json'), JSON.stringify(queue, null, 2));
}
}
function getIssueDetail(issuesDir: string, issueId: string) {

View File

@@ -276,9 +276,105 @@
color: hsl(var(--muted-foreground));
}
.queue-empty svg {
.queue-empty svg,
.queue-empty > i {
margin-bottom: 1rem;
opacity: 0.5;
color: hsl(var(--muted-foreground));
}
.queue-empty-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
.queue-empty-title {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.queue-empty-hint {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-bottom: 1.5rem;
}
.queue-create-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.queue-create-btn:hover {
background: hsl(var(--primary) / 0.9);
transform: translateY(-1px);
}
/* Queue Toolbar */
.queue-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
}
.queue-stats {
display: flex;
align-items: center;
gap: 0.5rem;
}
.queue-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Command Box */
.command-option {
margin-bottom: 0.75rem;
}
.command-box {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
}
.command-text {
flex: 1;
font-family: var(--font-mono);
font-size: 0.875rem;
color: hsl(var(--foreground));
word-break: break-all;
}
.command-info {
padding: 0.75rem;
background: hsl(var(--primary) / 0.05);
border-radius: 0.375rem;
border-left: 3px solid hsl(var(--primary));
}
/* Issue ID */
@@ -1349,6 +1445,20 @@
cursor: pointer;
}
/* Input with action button */
.input-with-action {
display: flex;
gap: 0.5rem;
}
.input-with-action input {
flex: 1;
}
.input-with-action .btn-icon {
flex-shrink: 0;
}
/* ==========================================
BUTTON STYLES
========================================== */
@@ -1759,3 +1869,676 @@
-webkit-overflow-scrolling: touch;
}
}
/* ==========================================
SOLUTION DETAIL MODAL
========================================== */
.solution-modal {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
}
.solution-modal.hidden {
display: none;
}
.solution-modal-backdrop {
position: absolute;
inset: 0;
background: hsl(var(--foreground) / 0.6);
animation: fadeIn 0.15s ease-out;
}
.solution-modal-content {
position: relative;
width: 90%;
max-width: 720px;
max-height: 85vh;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
box-shadow: 0 25px 50px hsl(var(--foreground) / 0.2);
display: flex;
flex-direction: column;
animation: modalSlideIn 0.2s ease-out;
}
.solution-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid hsl(var(--border));
flex-shrink: 0;
}
.solution-modal-title h3 {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-top: 0.25rem;
}
.solution-modal-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.solution-modal-body {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
/* Solution Overview Stats */
.solution-overview {
display: flex;
gap: 1.5rem;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.solution-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.solution-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.solution-stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Solution Detail Section */
.solution-detail-section {
margin-bottom: 1.5rem;
}
.solution-detail-section:last-child {
margin-bottom: 0;
}
.solution-detail-section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.75rem;
}
/* Solution Tasks Detail */
.solution-tasks-detail {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.solution-task-card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
overflow: hidden;
}
.solution-task-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
cursor: pointer;
transition: background 0.15s ease;
}
.solution-task-header:hover {
background: hsl(var(--muted) / 0.5);
}
.solution-task-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.solution-task-index {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
min-width: 1.5rem;
}
.solution-task-id {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.task-expand-icon {
transition: transform 0.2s ease;
color: hsl(var(--muted-foreground));
}
.solution-task-title {
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
border-top: 1px solid hsl(var(--border) / 0.5);
}
.solution-task-details {
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.15);
}
.solution-task-scope {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding: 0.5rem;
background: hsl(var(--primary) / 0.1);
border-radius: 0.375rem;
}
.solution-task-scope-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.solution-task-subtitle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.solution-task-mod-points,
.solution-task-impl-steps,
.solution-task-acceptance,
.solution-task-deps {
margin-bottom: 0.75rem;
}
.solution-task-list,
.solution-impl-list,
.solution-acceptance-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: hsl(var(--foreground));
}
.solution-task-list li,
.solution-impl-list li,
.solution-acceptance-list li {
margin: 0.25rem 0;
}
.solution-mod-point {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.mod-point-file {
color: hsl(var(--primary));
font-size: 0.8125rem;
}
.mod-point-change {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin-left: 0.5rem;
}
.solution-deps-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.solution-dep-tag {
padding: 0.125rem 0.5rem;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
border-radius: 0.25rem;
font-size: 0.75rem;
}
/* ==========================================
LIFECYCLE PHASE BADGES
========================================== */
.phase-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
margin-right: 0.25rem;
}
.phase-badge.phase-1 {
background: hsl(217 91% 60% / 0.2);
color: hsl(217 91% 60%);
}
.phase-badge.phase-2 {
background: hsl(262 83% 58% / 0.2);
color: hsl(262 83% 58%);
}
.phase-badge.phase-3 {
background: hsl(25 95% 53% / 0.2);
color: hsl(25 95% 53%);
}
.phase-badge.phase-4 {
background: hsl(142 71% 45% / 0.2);
color: hsl(142 71% 45%);
}
.phase-badge.phase-5 {
background: hsl(199 89% 48% / 0.2);
color: hsl(199 89% 48%);
}
/* ==========================================
QUEUE STATS GRID
========================================== */
.queue-stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.75rem;
}
@media (max-width: 768px) {
.queue-stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.queue-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.queue-stat-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
text-align: center;
}
.queue-stat-card .queue-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
line-height: 1.2;
}
.queue-stat-card .queue-stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.025em;
margin-top: 0.25rem;
}
.queue-stat-card.pending {
border-color: hsl(var(--muted-foreground) / 0.3);
}
.queue-stat-card.pending .queue-stat-value {
color: hsl(var(--muted-foreground));
}
.queue-stat-card.executing {
border-color: hsl(45 93% 47% / 0.5);
background: hsl(45 93% 47% / 0.05);
}
.queue-stat-card.executing .queue-stat-value {
color: hsl(45 93% 47%);
}
.queue-stat-card.completed {
border-color: hsl(var(--success) / 0.5);
background: hsl(var(--success) / 0.05);
}
.queue-stat-card.completed .queue-stat-value {
color: hsl(var(--success));
}
.queue-stat-card.failed {
border-color: hsl(var(--destructive) / 0.5);
background: hsl(var(--destructive) / 0.05);
}
.queue-stat-card.failed .queue-stat-value {
color: hsl(var(--destructive));
}
/* ==========================================
QUEUE INFO CARDS
========================================== */
.queue-info-card {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.queue-info-label {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.queue-info-value {
font-size: 0.875rem;
color: hsl(var(--foreground));
}
/* Queue Status Badge */
.queue-status-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
.queue-status-badge.active {
background: hsl(217 91% 60% / 0.15);
color: hsl(217 91% 60%);
}
.queue-status-badge.completed {
background: hsl(var(--success) / 0.15);
color: hsl(var(--success));
}
.queue-status-badge.failed {
background: hsl(var(--destructive) / 0.15);
color: hsl(var(--destructive));
}
.queue-status-badge.archived {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
/* ==========================================
SOLUTION TASK SECTIONS
========================================== */
.solution-task-section {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid hsl(var(--border) / 0.3);
}
.solution-task-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
/* ==========================================
TEST SECTION STYLES
========================================== */
.test-subsection,
.acceptance-subsection {
margin-bottom: 0.5rem;
}
.test-subsection:last-child,
.acceptance-subsection:last-child {
margin-bottom: 0;
}
.test-label,
.acceptance-label {
display: block;
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.25rem;
}
.test-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: hsl(var(--foreground));
}
.test-list li {
margin: 0.125rem 0;
}
.test-commands,
.verification-commands {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.test-command,
.verification-command {
display: block;
padding: 0.375rem 0.625rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: hsl(var(--foreground));
word-break: break-all;
}
.coverage-target {
font-size: 0.6875rem;
font-weight: 400;
color: hsl(var(--muted-foreground));
margin-left: 0.25rem;
}
/* ==========================================
COMMIT INFO STYLES
========================================== */
.commit-info {
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
padding: 0.625rem;
}
.commit-type {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.commit-type-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: lowercase;
}
.commit-type-badge.feat {
background: hsl(142 71% 45% / 0.15);
color: hsl(142 71% 45%);
}
.commit-type-badge.fix {
background: hsl(0 84% 60% / 0.15);
color: hsl(0 84% 60%);
}
.commit-type-badge.refactor {
background: hsl(262 83% 58% / 0.15);
color: hsl(262 83% 58%);
}
.commit-type-badge.test {
background: hsl(199 89% 48% / 0.15);
color: hsl(199 89% 48%);
}
.commit-type-badge.docs {
background: hsl(45 93% 47% / 0.15);
color: hsl(45 93% 47%);
}
.commit-type-badge.chore {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
.commit-scope {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.commit-breaking {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.375rem;
background: hsl(var(--destructive) / 0.15);
color: hsl(var(--destructive));
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.05em;
}
.commit-message {
margin: 0;
padding: 0.5rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
font-family: var(--font-mono);
font-size: 0.75rem;
color: hsl(var(--foreground));
white-space: pre-wrap;
word-break: break-word;
}
/* Modification Point Target */
.mod-point-target {
font-size: 0.75rem;
color: hsl(var(--primary));
font-family: var(--font-mono);
}
/* JSON Toggle */
.solution-json-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
}
.solution-json-toggle:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.solution-json-content {
margin-top: 0.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
overflow: hidden;
}
.solution-json-pre {
margin: 0;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
color: hsl(var(--foreground));
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}
/* Responsive Solution Modal */
@media (max-width: 640px) {
.solution-modal-content {
max-height: 95vh;
margin: 0.5rem;
}
.solution-overview {
flex-wrap: wrap;
justify-content: center;
}
.solution-stat {
min-width: 80px;
}
}

View File

@@ -1772,6 +1772,45 @@ const i18n = {
'issues.created': 'Issue created successfully',
'issues.confirmDelete': 'Are you sure you want to delete this issue?',
'issues.deleted': 'Issue deleted',
'issues.idAutoGenerated': 'Auto-generated',
'issues.regenerateId': 'Regenerate ID',
// Solution detail
'issues.solutionDetail': 'Solution Details',
'issues.bind': 'Bind',
'issues.unbind': 'Unbind',
'issues.bound': 'Bound',
'issues.totalTasks': 'Total Tasks',
'issues.bindStatus': 'Bind Status',
'issues.createdAt': 'Created',
'issues.taskList': 'Task List',
'issues.noTasks': 'No tasks in this solution',
'issues.noSolutions': 'No solutions',
'issues.viewJson': 'View Raw JSON',
'issues.scope': 'Scope',
'issues.modificationPoints': 'Modification Points',
'issues.implementationSteps': 'Implementation Steps',
'issues.acceptanceCriteria': 'Acceptance Criteria',
'issues.dependencies': 'Dependencies',
'issues.solutionBound': 'Solution bound successfully',
'issues.solutionUnbound': 'Solution unbound',
// Queue operations
'issues.queueEmptyHint': 'Generate execution queue from bound solutions',
'issues.createQueue': 'Create Queue',
'issues.regenerate': 'Regenerate',
'issues.regenerateQueue': 'Regenerate Queue',
'issues.refreshQueue': 'Refresh',
'issues.executionGroups': 'groups',
'issues.totalItems': 'items',
'issues.queueRefreshed': 'Queue refreshed',
'issues.confirmCreateQueue': 'This will execute /issue:queue command via Claude Code CLI to generate execution queue from bound solutions.\n\nContinue?',
'issues.creatingQueue': 'Creating execution queue...',
'issues.queueExecutionStarted': 'Queue generation started',
'issues.queueCreated': 'Queue created successfully',
'issues.queueCreationFailed': 'Queue creation failed',
'issues.queueCommandHint': 'Run one of the following commands in your terminal to generate the execution queue from bound solutions:',
'issues.queueCommandInfo': 'After running the command, click "Refresh" to see the updated queue.',
'issues.alternative': 'Alternative',
'issues.refreshAfter': 'Refresh Queue',
// issue.* keys (legacy)
'issue.viewIssues': 'Issues',
'issue.viewQueue': 'Queue',
@@ -3595,6 +3634,45 @@ const i18n = {
'issues.created': '议题创建成功',
'issues.confirmDelete': '确定要删除此议题吗?',
'issues.deleted': '议题已删除',
'issues.idAutoGenerated': '自动生成',
'issues.regenerateId': '重新生成ID',
// Solution detail
'issues.solutionDetail': '解决方案详情',
'issues.bind': '绑定',
'issues.unbind': '解绑',
'issues.bound': '已绑定',
'issues.totalTasks': '任务总数',
'issues.bindStatus': '绑定状态',
'issues.createdAt': '创建时间',
'issues.taskList': '任务列表',
'issues.noTasks': '此解决方案无任务',
'issues.noSolutions': '暂无解决方案',
'issues.viewJson': '查看原始JSON',
'issues.scope': '作用域',
'issues.modificationPoints': '修改点',
'issues.implementationSteps': '实现步骤',
'issues.acceptanceCriteria': '验收标准',
'issues.dependencies': '依赖项',
'issues.solutionBound': '解决方案已绑定',
'issues.solutionUnbound': '解决方案已解绑',
// Queue operations
'issues.queueEmptyHint': '从绑定的解决方案生成执行队列',
'issues.createQueue': '创建队列',
'issues.regenerate': '重新生成',
'issues.regenerateQueue': '重新生成队列',
'issues.refreshQueue': '刷新',
'issues.executionGroups': '个执行组',
'issues.totalItems': '个任务',
'issues.queueRefreshed': '队列已刷新',
'issues.confirmCreateQueue': '这将通过 Claude Code CLI 执行 /issue:queue 命令,从绑定的解决方案生成执行队列。\n\n是否继续',
'issues.creatingQueue': '正在创建执行队列...',
'issues.queueExecutionStarted': '队列生成已启动',
'issues.queueCreated': '队列创建成功',
'issues.queueCreationFailed': '队列创建失败',
'issues.queueCommandHint': '在终端中运行以下命令之一,从绑定的解决方案生成执行队列:',
'issues.queueCommandInfo': '运行命令后,点击"刷新"查看更新后的队列。',
'issues.alternative': '或者',
'issues.refreshAfter': '刷新队列',
// issue.* keys (legacy)
'issue.viewIssues': '议题',
'issue.viewQueue': '队列',

View File

@@ -168,16 +168,22 @@ async function loadAvailableSkills() {
if (!response.ok) throw new Error('Failed to load skills');
const data = await response.json();
// Combine project and user skills (API returns { projectSkills: [], userSkills: [] })
const allSkills = [
...(data.projectSkills || []).map(s => ({ ...s, scope: 'project' })),
...(data.userSkills || []).map(s => ({ ...s, scope: 'user' }))
];
const container = document.getElementById('skill-discovery-skill-context');
if (container && data.skills) {
if (data.skills.length === 0) {
if (container) {
if (allSkills.length === 0) {
container.innerHTML = `
<span class="font-mono bg-muted px-1.5 py-0.5 rounded">${t('hook.wizard.availableSkills')}</span>
<span class="text-muted-foreground ml-2">${t('hook.wizard.noSkillsFound').split('.')[0]}</span>
`;
} else {
const skillBadges = data.skills.map(skill => `
<span class="px-2 py-0.5 bg-emerald-500/10 text-emerald-500 rounded" title="${escapeHtml(skill.description)}">${escapeHtml(skill.name)}</span>
const skillBadges = allSkills.map(skill => `
<span class="px-2 py-0.5 bg-emerald-500/10 text-emerald-500 rounded" title="${escapeHtml(skill.description || '')}">${escapeHtml(skill.name)}</span>
`).join('');
container.innerHTML = `
<span class="font-mono bg-muted px-1.5 py-0.5 rounded">${t('hook.wizard.availableSkills')}</span>
@@ -187,7 +193,7 @@ async function loadAvailableSkills() {
}
// Store skills for wizard use
window.availableSkills = data.skills || [];
window.availableSkills = allSkills;
} catch (err) {
console.error('Failed to load skills:', err);
const container = document.getElementById('skill-discovery-skill-context');

View File

@@ -9,6 +9,7 @@ var issueData = {
queue: { queue: [], conflicts: [], execution_groups: [], grouped_items: {} },
selectedIssue: null,
selectedSolution: null,
selectedSolutionIssueId: null,
statusFilter: 'all',
searchQuery: '',
viewMode: 'issues' // 'issues' | 'queue'
@@ -148,6 +149,31 @@ function renderIssueView() {
<!-- Detail Panel -->
<div id="issueDetailPanel" class="issue-detail-panel hidden"></div>
<!-- Solution Detail Modal -->
<div id="solutionDetailModal" class="solution-modal hidden">
<div class="solution-modal-backdrop" onclick="closeSolutionDetail()"></div>
<div class="solution-modal-content">
<div class="solution-modal-header">
<div class="solution-modal-title">
<span id="solutionDetailId" class="font-mono text-sm text-muted-foreground"></span>
<h3 id="solutionDetailTitle">${t('issues.solutionDetail') || 'Solution Details'}</h3>
</div>
<div class="solution-modal-actions">
<button id="solutionBindBtn" class="btn-secondary" onclick="toggleSolutionBind()">
<i data-lucide="link" class="w-4 h-4"></i>
<span>${t('issues.bind') || 'Bind'}</span>
</button>
<button class="btn-icon" onclick="closeSolutionDetail()">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
</div>
<div class="solution-modal-body" id="solutionDetailBody">
<!-- Content will be rendered dynamically -->
</div>
</div>
</div>
<!-- Create Issue Modal -->
<div id="createIssueModal" class="issue-modal hidden">
<div class="issue-modal-backdrop" onclick="hideCreateIssueModal()"></div>
@@ -161,7 +187,12 @@ function renderIssueView() {
<div class="issue-modal-body">
<div class="form-group">
<label>${t('issues.issueId') || 'Issue ID'}</label>
<input type="text" id="newIssueId" placeholder="e.g., GH-123 or TASK-001" />
<div class="input-with-action">
<input type="text" id="newIssueId" placeholder="${t('issues.idAutoGenerated') || 'Auto-generated'}" />
<button type="button" class="btn-icon" onclick="regenerateIssueId()" title="${t('issues.regenerateId') || 'Regenerate ID'}">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="form-group">
<label>${t('issues.issueTitle') || 'Title'}</label>
@@ -329,20 +360,129 @@ function filterIssuesByStatus(status) {
// ========== Queue Section ==========
function renderQueueSection() {
const queue = issueData.queue;
const groups = queue.execution_groups || [];
const groupedItems = queue.grouped_items || {};
const queueItems = queue.queue || [];
const metadata = queue._metadata || {};
if (groups.length === 0 && (!queue.queue || queue.queue.length === 0)) {
// Check if queue is empty
if (queueItems.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 class="queue-empty-container">
<div class="queue-empty">
<i data-lucide="git-branch" class="w-16 h-16"></i>
<p class="queue-empty-title">${t('issues.queueEmpty') || 'Queue is empty'}</p>
<p class="queue-empty-hint">${t('issues.queueEmptyHint') || 'Generate execution queue from bound solutions'}</p>
<button class="queue-create-btn" onclick="createExecutionQueue()">
<i data-lucide="play" class="w-4 h-4"></i>
<span>${t('issues.createQueue') || 'Create Queue'}</span>
</button>
</div>
</div>
`;
}
// Group items by execution_group or treat all as single group
const groups = queue.execution_groups || [];
let groupedItems = queue.grouped_items || {};
// If no execution_groups, create a default grouping from queue items
if (groups.length === 0 && queueItems.length > 0) {
const groupMap = {};
queueItems.forEach(item => {
const groupId = item.execution_group || 'default';
if (!groupMap[groupId]) {
groupMap[groupId] = [];
}
groupMap[groupId].push(item);
});
// Create synthetic groups
const syntheticGroups = Object.keys(groupMap).map(groupId => ({
id: groupId,
type: 'sequential',
task_count: groupMap[groupId].length
}));
return `
<!-- Queue Header -->
<div class="queue-toolbar mb-4">
<div class="queue-stats">
<div class="queue-info-card">
<span class="queue-info-label">${t('issues.queueId') || 'Queue ID'}</span>
<span class="queue-info-value font-mono text-sm">${queue.id || 'N/A'}</span>
</div>
<div class="queue-info-card">
<span class="queue-info-label">${t('issues.status') || 'Status'}</span>
<span class="queue-status-badge ${queue.status || ''}">${queue.status || 'unknown'}</span>
</div>
<div class="queue-info-card">
<span class="queue-info-label">${t('issues.issues') || 'Issues'}</span>
<span class="queue-info-value">${(queue.issue_ids || []).join(', ') || 'N/A'}</span>
</div>
</div>
<div class="queue-actions">
<button class="btn-secondary" onclick="refreshQueue()" title="${t('issues.refreshQueue') || 'Refresh'}">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
<button class="btn-secondary" onclick="createExecutionQueue()" title="${t('issues.regenerateQueue') || 'Regenerate Queue'}">
<i data-lucide="rotate-cw" class="w-4 h-4"></i>
<span>${t('issues.regenerate') || 'Regenerate'}</span>
</button>
</div>
</div>
<!-- Queue Stats -->
<div class="queue-stats-grid mb-4">
<div class="queue-stat-card">
<span class="queue-stat-value">${metadata.total_tasks || queueItems.length}</span>
<span class="queue-stat-label">${t('issues.totalTasks') || 'Total'}</span>
</div>
<div class="queue-stat-card pending">
<span class="queue-stat-value">${metadata.pending_count || queueItems.filter(i => i.status === 'pending').length}</span>
<span class="queue-stat-label">${t('issues.pending') || 'Pending'}</span>
</div>
<div class="queue-stat-card executing">
<span class="queue-stat-value">${metadata.executing_count || queueItems.filter(i => i.status === 'executing').length}</span>
<span class="queue-stat-label">${t('issues.executing') || 'Executing'}</span>
</div>
<div class="queue-stat-card completed">
<span class="queue-stat-value">${metadata.completed_count || queueItems.filter(i => i.status === 'completed').length}</span>
<span class="queue-stat-label">${t('issues.completed') || 'Completed'}</span>
</div>
<div class="queue-stat-card failed">
<span class="queue-stat-value">${metadata.failed_count || queueItems.filter(i => i.status === 'failed').length}</span>
<span class="queue-stat-label">${t('issues.failed') || 'Failed'}</span>
</div>
</div>
<!-- Queue Items -->
<div class="queue-timeline">
${syntheticGroups.map(group => renderQueueGroup(group, groupMap[group.id] || [])).join('')}
</div>
${queue.conflicts && queue.conflicts.length > 0 ? renderConflictsSection(queue.conflicts) : ''}
`;
}
return `
<!-- Queue Toolbar -->
<div class="queue-toolbar mb-4">
<div class="queue-stats">
<span class="text-sm text-muted-foreground">
${groups.length} ${t('issues.executionGroups') || 'groups'} ·
${queueItems.length} ${t('issues.totalItems') || 'items'}
</span>
</div>
<div class="queue-actions">
<button class="btn-secondary" onclick="refreshQueue()" title="${t('issues.refreshQueue') || 'Refresh'}">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
<button class="btn-secondary" onclick="createExecutionQueue()" title="${t('issues.regenerateQueue') || 'Regenerate Queue'}">
<i data-lucide="rotate-cw" class="w-4 h-4"></i>
<span>${t('issues.regenerate') || 'Regenerate'}</span>
</button>
</div>
</div>
<div class="queue-info mb-4">
<p class="text-sm text-muted-foreground">
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
@@ -605,24 +745,16 @@ function renderIssueDetailPanel(issue) {
<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}')">
${(issue.solutions || []).length > 0 ? (issue.solutions || []).map(sol => `
<div class="solution-item ${sol.is_bound ? 'bound' : ''}" onclick="openSolutionDetail('${issue.id}', '${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('')}
${sol.is_bound ? '<span class="solution-bound-badge">' + (t('issues.bound') || 'Bound') + '</span>' : ''}
<span class="solution-tasks text-xs">${sol.tasks?.length || 0} ${t('issues.tasks') || 'tasks'}</span>
<i data-lucide="chevron-right" class="w-4 h-4 ml-auto text-muted-foreground"></i>
</div>
</div>
`).join('') || '<p class="text-sm text-muted-foreground">No solutions</p>'}
`).join('') : '<p class="text-sm text-muted-foreground">' + (t('issues.noSolutions') || 'No solutions') + '</p>'}
</div>
</div>
@@ -630,7 +762,7 @@ function renderIssueDetailPanel(issue) {
<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 => `
${(issue.tasks || []).length > 0 ? (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>
@@ -642,7 +774,7 @@ function renderIssueDetailPanel(issue) {
</div>
<p class="task-title-detail">${task.title || task.description || ''}</p>
</div>
`).join('') || '<p class="text-sm text-muted-foreground">No tasks</p>'}
`).join('') : '<p class="text-sm text-muted-foreground">' + (t('issues.noTasks') || 'No tasks') + '</p>'}
</div>
</div>
</div>
@@ -666,6 +798,353 @@ function toggleSolutionExpand(solId) {
}
}
// ========== Solution Detail Modal ==========
function openSolutionDetail(issueId, solutionId) {
const issue = issueData.selectedIssue || issueData.issues.find(i => i.id === issueId);
if (!issue) return;
const solution = issue.solutions?.find(s => s.id === solutionId);
if (!solution) return;
issueData.selectedSolution = solution;
issueData.selectedSolutionIssueId = issueId;
const modal = document.getElementById('solutionDetailModal');
if (modal) {
modal.classList.remove('hidden');
renderSolutionDetail(solution);
lucide.createIcons();
}
}
function closeSolutionDetail() {
const modal = document.getElementById('solutionDetailModal');
if (modal) {
modal.classList.add('hidden');
}
issueData.selectedSolution = null;
issueData.selectedSolutionIssueId = null;
}
function renderSolutionDetail(solution) {
const idEl = document.getElementById('solutionDetailId');
const bodyEl = document.getElementById('solutionDetailBody');
const bindBtn = document.getElementById('solutionBindBtn');
if (idEl) {
idEl.textContent = solution.id;
}
// Update bind button state
if (bindBtn) {
if (solution.is_bound) {
bindBtn.innerHTML = `<i data-lucide="unlink" class="w-4 h-4"></i><span>${t('issues.unbind') || 'Unbind'}</span>`;
bindBtn.classList.remove('btn-secondary');
bindBtn.classList.add('btn-primary');
} else {
bindBtn.innerHTML = `<i data-lucide="link" class="w-4 h-4"></i><span>${t('issues.bind') || 'Bind'}</span>`;
bindBtn.classList.remove('btn-primary');
bindBtn.classList.add('btn-secondary');
}
}
if (!bodyEl) return;
const tasks = solution.tasks || [];
bodyEl.innerHTML = `
<!-- Solution Overview -->
<div class="solution-detail-section">
<div class="solution-overview">
<div class="solution-stat">
<span class="solution-stat-value">${tasks.length}</span>
<span class="solution-stat-label">${t('issues.totalTasks') || 'Total Tasks'}</span>
</div>
<div class="solution-stat">
<span class="solution-stat-value">${solution.is_bound ? '✓' : '—'}</span>
<span class="solution-stat-label">${t('issues.bindStatus') || 'Bind Status'}</span>
</div>
<div class="solution-stat">
<span class="solution-stat-value">${solution.created_at ? new Date(solution.created_at).toLocaleDateString() : '—'}</span>
<span class="solution-stat-label">${t('issues.createdAt') || 'Created'}</span>
</div>
</div>
</div>
<!-- Tasks List -->
<div class="solution-detail-section">
<h4 class="solution-detail-section-title">
<i data-lucide="list-checks" class="w-4 h-4"></i>
${t('issues.taskList') || 'Task List'}
</h4>
<div class="solution-tasks-detail">
${tasks.length === 0 ? `
<p class="text-sm text-muted-foreground text-center py-4">${t('issues.noTasks') || 'No tasks in this solution'}</p>
` : tasks.map((task, index) => renderSolutionTask(task, index)).join('')}
</div>
</div>
<!-- Raw JSON (collapsible) -->
<div class="solution-detail-section">
<button class="solution-json-toggle" onclick="toggleSolutionJson()">
<i data-lucide="code" class="w-4 h-4"></i>
<span>${t('issues.viewJson') || 'View Raw JSON'}</span>
<i data-lucide="chevron-down" class="w-4 h-4 ml-auto"></i>
</button>
<div id="solutionJsonContent" class="solution-json-content hidden">
<pre class="solution-json-pre">${escapeHtml(JSON.stringify(solution, null, 2))}</pre>
</div>
</div>
`;
lucide.createIcons();
}
function renderSolutionTask(task, index) {
const actionClass = (task.action || 'unknown').toLowerCase();
const modPoints = task.modification_points || [];
// Support both old and new field names
const implSteps = task.implementation || task.implementation_steps || [];
const acceptance = task.acceptance || task.acceptance_criteria || [];
const testInfo = task.test || {};
const regression = task.regression || [];
const commitInfo = task.commit || {};
const dependsOn = task.depends_on || task.dependencies || [];
// Handle acceptance as object or array
const acceptanceCriteria = Array.isArray(acceptance) ? acceptance : (acceptance.criteria || []);
const acceptanceVerification = acceptance.verification || [];
return `
<div class="solution-task-card">
<div class="solution-task-header" onclick="toggleTaskExpand(${index})">
<div class="solution-task-info">
<span class="solution-task-index">#${index + 1}</span>
<span class="solution-task-id font-mono">${task.id || ''}</span>
<span class="task-action-badge ${actionClass}">${task.action || 'Unknown'}</span>
</div>
<i data-lucide="chevron-down" class="w-4 h-4 task-expand-icon" id="taskExpandIcon${index}"></i>
</div>
<div class="solution-task-title">${task.title || task.description || 'No title'}</div>
<div class="solution-task-details hidden" id="taskDetails${index}">
${task.scope ? `
<div class="solution-task-scope">
<span class="solution-task-scope-label">${t('issues.scope') || 'Scope'}:</span>
<span class="font-mono text-sm">${task.scope}</span>
</div>
` : ''}
<!-- Phase 1: Implementation -->
${implSteps.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="code" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-1">1</span>
${t('issues.implementation') || 'Implementation'}
</h5>
<ol class="solution-impl-list">
${implSteps.map(step => `<li>${typeof step === 'string' ? step : step.description || JSON.stringify(step)}</li>`).join('')}
</ol>
</div>
` : ''}
${modPoints.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="file-edit" class="w-3.5 h-3.5"></i>
${t('issues.modificationPoints') || 'Modification Points'}
</h5>
<ul class="solution-task-list">
${modPoints.map(mp => `
<li class="solution-mod-point">
<span class="mod-point-file font-mono">${mp.file || mp}</span>
${mp.target ? `<span class="mod-point-target">→ ${mp.target}</span>` : ''}
${mp.change ? `<span class="mod-point-change">${mp.change}</span>` : ''}
</li>
`).join('')}
</ul>
</div>
` : ''}
<!-- Phase 2: Test -->
${(testInfo.unit?.length > 0 || testInfo.commands?.length > 0) ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="flask-conical" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-2">2</span>
${t('issues.test') || 'Test'}
${testInfo.coverage_target ? `<span class="coverage-target">(${testInfo.coverage_target}% coverage)</span>` : ''}
</h5>
${testInfo.unit?.length > 0 ? `
<div class="test-subsection">
<span class="test-label">${t('issues.unitTests') || 'Unit Tests'}:</span>
<ul class="test-list">
${testInfo.unit.map(t => `<li>${t}</li>`).join('')}
</ul>
</div>
` : ''}
${testInfo.integration?.length > 0 ? `
<div class="test-subsection">
<span class="test-label">${t('issues.integrationTests') || 'Integration'}:</span>
<ul class="test-list">
${testInfo.integration.map(t => `<li>${t}</li>`).join('')}
</ul>
</div>
` : ''}
${testInfo.commands?.length > 0 ? `
<div class="test-subsection">
<span class="test-label">${t('issues.commands') || 'Commands'}:</span>
<div class="test-commands">
${testInfo.commands.map(cmd => `<code class="test-command">${cmd}</code>`).join('')}
</div>
</div>
` : ''}
</div>
` : ''}
<!-- Phase 3: Regression -->
${regression.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-3">3</span>
${t('issues.regression') || 'Regression'}
</h5>
<div class="test-commands">
${regression.map(cmd => `<code class="test-command">${cmd}</code>`).join('')}
</div>
</div>
` : ''}
<!-- Phase 4: Acceptance -->
${acceptanceCriteria.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-4">4</span>
${t('issues.acceptance') || 'Acceptance'}
</h5>
<div class="acceptance-subsection">
<span class="acceptance-label">${t('issues.criteria') || 'Criteria'}:</span>
<ul class="solution-acceptance-list">
${acceptanceCriteria.map(ac => `<li>${typeof ac === 'string' ? ac : ac.description || JSON.stringify(ac)}</li>`).join('')}
</ul>
</div>
${acceptanceVerification.length > 0 ? `
<div class="acceptance-subsection">
<span class="acceptance-label">${t('issues.verification') || 'Verification'}:</span>
<div class="verification-commands">
${acceptanceVerification.map(v => `<code class="verification-command">${v}</code>`).join('')}
</div>
</div>
` : ''}
</div>
` : ''}
<!-- Phase 5: Commit -->
${commitInfo.type ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="git-commit" class="w-3.5 h-3.5"></i>
<span class="phase-badge phase-5">5</span>
${t('issues.commit') || 'Commit'}
</h5>
<div class="commit-info">
<div class="commit-type">
<span class="commit-type-badge ${commitInfo.type}">${commitInfo.type}</span>
<span class="commit-scope">(${commitInfo.scope || 'core'})</span>
${commitInfo.breaking ? '<span class="commit-breaking">BREAKING</span>' : ''}
</div>
${commitInfo.message_template ? `
<pre class="commit-message">${commitInfo.message_template}</pre>
` : ''}
</div>
</div>
` : ''}
<!-- Dependencies -->
${dependsOn.length > 0 ? `
<div class="solution-task-section">
<h5 class="solution-task-subtitle">
<i data-lucide="git-branch" class="w-3.5 h-3.5"></i>
${t('issues.dependencies') || 'Dependencies'}
</h5>
<div class="solution-deps-list">
${dependsOn.map(dep => `<span class="solution-dep-tag font-mono">${dep}</span>`).join('')}
</div>
</div>
` : ''}
</div>
</div>
`;
}
function toggleTaskExpand(index) {
const details = document.getElementById('taskDetails' + index);
const icon = document.getElementById('taskExpandIcon' + index);
if (details) {
details.classList.toggle('hidden');
}
if (icon) {
icon.style.transform = details?.classList.contains('hidden') ? '' : 'rotate(180deg)';
}
}
function toggleSolutionJson() {
const content = document.getElementById('solutionJsonContent');
if (content) {
content.classList.toggle('hidden');
}
}
async function toggleSolutionBind() {
const solution = issueData.selectedSolution;
const issueId = issueData.selectedSolutionIssueId;
if (!solution || !issueId) return;
const action = solution.is_bound ? 'unbind' : 'bind';
try {
const response = await fetch('/api/issues/' + encodeURIComponent(issueId) + '?path=' + encodeURIComponent(projectPath), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bound_solution_id: action === 'bind' ? solution.id : null
})
});
if (!response.ok) throw new Error('Failed to ' + action);
showNotification(action === 'bind' ? (t('issues.solutionBound') || 'Solution bound') : (t('issues.solutionUnbound') || 'Solution unbound'), 'success');
// Refresh data
await loadIssueData();
const detail = await loadIssueDetail(issueId);
if (detail) {
issueData.selectedIssue = detail;
// Update solution reference
const updatedSolution = detail.solutions?.find(s => s.id === solution.id);
if (updatedSolution) {
issueData.selectedSolution = updatedSolution;
renderSolutionDetail(updatedSolution);
}
renderIssueDetailPanel(detail);
}
} catch (err) {
console.error('Failed to ' + action + ' solution:', err);
showNotification('Failed to ' + action + ' solution', 'error');
}
}
// Helper: escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function openQueueItemDetail(queueId) {
const item = issueData.queue.queue?.find(q => q.queue_id === queueId);
if (item) {
@@ -807,18 +1286,58 @@ function clearIssueSearch() {
}
// ========== Create Issue Modal ==========
function generateIssueId() {
// Generate unique ID: ISSUE-YYYYMMDD-XXX format
const now = new Date();
const dateStr = now.getFullYear().toString() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0');
// Find existing IDs with same date prefix
const prefix = 'ISSUE-' + dateStr + '-';
const existingIds = (issueData.issues || [])
.map(i => i.id)
.filter(id => id.startsWith(prefix));
// Get next sequence number
let maxSeq = 0;
existingIds.forEach(id => {
const seqStr = id.replace(prefix, '');
const seq = parseInt(seqStr, 10);
if (!isNaN(seq) && seq > maxSeq) {
maxSeq = seq;
}
});
return prefix + String(maxSeq + 1).padStart(3, '0');
}
function showCreateIssueModal() {
const modal = document.getElementById('createIssueModal');
if (modal) {
modal.classList.remove('hidden');
// Auto-generate issue ID
const idInput = document.getElementById('newIssueId');
if (idInput) {
idInput.value = generateIssueId();
}
lucide.createIcons();
// Focus on first input
// Focus on title input instead of ID
setTimeout(() => {
document.getElementById('newIssueId')?.focus();
document.getElementById('newIssueTitle')?.focus();
}, 100);
}
}
function regenerateIssueId() {
const idInput = document.getElementById('newIssueId');
if (idInput) {
idInput.value = generateIssueId();
}
}
function hideCreateIssueModal() {
const modal = document.getElementById('createIssueModal');
if (modal) {
@@ -913,3 +1432,115 @@ async function deleteIssue(issueId) {
showNotification('Failed to delete issue', 'error');
}
}
// ========== Queue Operations ==========
async function refreshQueue() {
try {
await loadQueueData();
renderIssueView();
showNotification(t('issues.queueRefreshed') || 'Queue refreshed', 'success');
} catch (err) {
showNotification('Failed to refresh queue', 'error');
}
}
function createExecutionQueue() {
showQueueCommandModal();
}
function showQueueCommandModal() {
// Create modal if not exists
let modal = document.getElementById('queueCommandModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'queueCommandModal';
modal.className = 'issue-modal';
document.body.appendChild(modal);
}
const command = 'claude /issue:queue';
const altCommand = 'ccw issue queue';
modal.innerHTML = `
<div class="issue-modal-backdrop" onclick="hideQueueCommandModal()"></div>
<div class="issue-modal-content" style="max-width: 560px;">
<div class="issue-modal-header">
<h3>${t('issues.createQueue') || 'Create Execution Queue'}</h3>
<button class="btn-icon" onclick="hideQueueCommandModal()">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="issue-modal-body">
<p class="text-sm text-muted-foreground mb-4">
${t('issues.queueCommandHint') || 'Run one of the following commands in your terminal to generate the execution queue from bound solutions:'}
</p>
<div class="command-option mb-3">
<label class="text-xs font-medium text-muted-foreground mb-1 block">
<i data-lucide="terminal" class="w-3 h-3 inline mr-1"></i>
Claude Code CLI
</label>
<div class="command-box">
<code class="command-text">${command}</code>
<button class="btn-icon" onclick="copyCommand('${command}')" title="${t('common.copy') || 'Copy'}">
<i data-lucide="copy" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="command-option">
<label class="text-xs font-medium text-muted-foreground mb-1 block">
<i data-lucide="terminal" class="w-3 h-3 inline mr-1"></i>
CCW CLI (${t('issues.alternative') || 'Alternative'})
</label>
<div class="command-box">
<code class="command-text">${altCommand}</code>
<button class="btn-icon" onclick="copyCommand('${altCommand}')" title="${t('common.copy') || 'Copy'}">
<i data-lucide="copy" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="command-info mt-4">
<p class="text-xs text-muted-foreground">
<i data-lucide="info" class="w-3 h-3 inline mr-1"></i>
${t('issues.queueCommandInfo') || 'After running the command, click "Refresh" to see the updated queue.'}
</p>
</div>
</div>
<div class="issue-modal-footer">
<button class="btn-secondary" onclick="hideQueueCommandModal()">${t('common.close') || 'Close'}</button>
<button class="btn-primary" onclick="hideQueueCommandModal(); refreshQueue();">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
${t('issues.refreshAfter') || 'Refresh Queue'}
</button>
</div>
</div>
`;
modal.classList.remove('hidden');
lucide.createIcons();
}
function hideQueueCommandModal() {
const modal = document.getElementById('queueCommandModal');
if (modal) {
modal.classList.add('hidden');
}
}
function copyCommand(command) {
navigator.clipboard.writeText(command).then(() => {
showNotification(t('common.copied') || 'Copied to clipboard', 'success');
}).catch(err => {
console.error('Failed to copy:', err);
// Fallback: select text
const textArea = document.createElement('textarea');
textArea.value = command;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showNotification(t('common.copied') || 'Copied to clipboard', 'success');
});
}