diff --git a/ccw/src/cli.ts b/ccw/src/cli.ts index 1273b76e..c460671d 100644 --- a/ccw/src/cli.ts +++ b/ccw/src/cli.ts @@ -300,6 +300,10 @@ export function run(argv: string[]): void { .option('--fail', 'Mark task as failed') .option('--from-queue [queue-id]', 'Sync issue statuses from queue (default: active queue)') .option('--queue ', 'Target queue ID for multi-queue operations') + // GitHub pull options + .option('--state ', 'GitHub issue state: open, closed, or all') + .option('--limit ', 'Maximum number of issues to pull from GitHub') + .option('--labels ', 'Filter by GitHub labels (comma-separated)') .action((subcommand, args, options) => issueCommand(subcommand, args, options)); // Loop command - Loop management for multi-CLI orchestration diff --git a/ccw/src/commands/issue.ts b/ccw/src/commands/issue.ts index 4ad3a5d3..b4f9c724 100644 --- a/ccw/src/commands/issue.ts +++ b/ccw/src/commands/issue.ts @@ -223,6 +223,10 @@ interface IssueOptions { data?: string; // JSON data for create fromQueue?: boolean | string; // Sync statuses from queue (true=active, string=specific queue ID) queue?: string; // Target queue ID for multi-queue operations + // GitHub pull options + state?: string; // Issue state: open, closed, all + limit?: number; // Maximum number of issues to pull + labels?: string; // Filter by labels (comma-separated) } const ISSUES_DIR = '.workflow/issues'; @@ -1003,6 +1007,113 @@ async function createAction(options: IssueOptions): Promise { } } +/** + * pull - Pull issues from GitHub + * Usage: ccw issue pull [--state open|closed|all] [--limit N] [--labels label1,label2] + */ +async function pullAction(options: IssueOptions): Promise { + try { + // Check if gh CLI is available + try { + execSync('gh --version', { stdio: 'ignore', timeout: EXEC_TIMEOUTS.GIT_QUICK }); + } catch { + console.error(chalk.red('GitHub CLI (gh) is not installed or not in PATH')); + console.error(chalk.gray('Install from: https://cli.github.com/')); + process.exit(1); + } + + // Build gh command with options + const state = options.state || 'open'; + const limit = options.limit || 100; + let ghCommand = `gh issue list --state ${state} --limit ${limit} --json number,title,body,labels,url,state`; + + if (options.labels) { + ghCommand += ` --label "${options.labels}"`; + } + + console.log(chalk.cyan(`Fetching issues from GitHub (state: ${state}, limit: ${limit})...`)); + + // Fetch issues from GitHub + const ghOutput = execSync(ghCommand, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: EXEC_TIMEOUTS.PROCESS_SPAWN, + }).trim(); + + if (!ghOutput) { + console.log(chalk.yellow('No issues found on GitHub')); + return; + } + + const ghIssues = JSON.parse(ghOutput); + const existingIssues = readIssues(); + + let imported = 0; + let skipped = 0; + let updated = 0; + + for (const ghIssue of ghIssues) { + const issueId = `GH-${ghIssue.number}`; + const existingIssue = existingIssues.find(i => i.id === issueId); + + // Prepare issue data + const issueData: Partial = { + id: issueId, + title: ghIssue.title, + status: ghIssue.state === 'OPEN' ? 'registered' : 'completed', + priority: 3, // Default priority + context: ghIssue.body?.substring(0, 500) || ghIssue.title, + source: 'github', + source_url: ghIssue.url, + tags: ghIssue.labels?.map((l: any) => l.name) || [], + }; + + if (existingIssue) { + // Update existing issue if state changed + if (existingIssue.source_url === ghIssue.url) { + // Check if status needs updating + const newStatus = ghIssue.state === 'OPEN' ? 'registered' : 'completed'; + if (existingIssue.status !== newStatus || existingIssue.title !== ghIssue.title) { + existingIssue.title = ghIssue.title; + existingIssue.status = newStatus; + existingIssue.updated_at = new Date().toISOString(); + updated++; + } else { + skipped++; + } + } else { + skipped++; + } + } else { + // Create new issue + try { + createIssue(issueData); + imported++; + } catch (err) { + console.error(chalk.red(`Failed to import issue #${ghIssue.number}: ${(err as Error).message}`)); + } + } + } + + // Save updates if any + if (updated > 0) { + writeIssues(existingIssues); + } + + console.log(chalk.green(`\n✓ GitHub sync complete:`)); + console.log(chalk.gray(` - Imported: ${imported} new issues`)); + console.log(chalk.gray(` - Updated: ${updated} existing issues`)); + console.log(chalk.gray(` - Skipped: ${skipped} unchanged issues`)); + + if (options.json) { + console.log(JSON.stringify({ imported, updated, skipped, total: ghIssues.length })); + } + } catch (err) { + console.error(chalk.red(`Failed to pull issues from GitHub: ${(err as Error).message}`)); + process.exit(1); + } +} + /** * solution - Create or read solutions * Create: ccw issue solution --data '{"tasks":[...]}' @@ -2715,6 +2826,9 @@ export async function issueCommand( case 'create': await createAction(options); break; + case 'pull': + await pullAction(options); + break; case 'solution': await solutionAction(argsArray[0], options); break; @@ -2769,6 +2883,8 @@ export async function issueCommand( console.log(chalk.bold.cyan('\nCCW Issue Management (v3.0 - Multi-Queue + Lifecycle)\n')); console.log(chalk.bold('Core Commands:')); console.log(chalk.gray(' create --data \'{"title":"..."}\' Create issue (auto-generates ID)')); + console.log(chalk.gray(' pull [--state open|closed|all] Pull issues from GitHub')); + console.log(chalk.gray(' [--limit N] [--labels label1,label2]')); console.log(chalk.gray(' init Initialize new issue (manual ID)')); console.log(chalk.gray(' list [issue-id] List issues or tasks')); console.log(chalk.gray(' history List completed issues (from history)')); @@ -2809,6 +2925,9 @@ export async function issueCommand( console.log(chalk.gray(' --priority Queue priority (lower = higher)')); console.log(chalk.gray(' --json JSON output')); console.log(chalk.gray(' --force Force operation')); + console.log(chalk.gray(' --state GitHub issue state (open/closed/all)')); + console.log(chalk.gray(' --limit Max issues to pull from GitHub')); + console.log(chalk.gray(' --labels Filter by GitHub labels (comma-separated)')); console.log(); console.log(chalk.bold('Storage:')); console.log(chalk.gray(' .workflow/issues/issues.jsonl Active issues')); diff --git a/ccw/src/commands/upgrade.ts b/ccw/src/commands/upgrade.ts index c30b8216..47f351ee 100644 --- a/ccw/src/commands/upgrade.ts +++ b/ccw/src/commands/upgrade.ts @@ -124,7 +124,7 @@ export async function upgradeCommand(options: UpgradeOptions): Promise { info('All installations are up to date.'); console.log(''); info('To upgrade ccw itself, run:'); - console.log(chalk.cyan(' npm update -g ccw')); + console.log(chalk.cyan(' npm update -g claude-code-workflow')); console.log(''); return; } diff --git a/ccw/src/core/memory-embedder-bridge.ts b/ccw/src/core/memory-embedder-bridge.ts index 66a7931b..f8c7596b 100644 --- a/ccw/src/core/memory-embedder-bridge.ts +++ b/ccw/src/core/memory-embedder-bridge.ts @@ -13,20 +13,16 @@ import { spawn } from 'child_process'; import { join, dirname } from 'path'; -import { homedir } from 'os'; import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; +import { getCodexLensPython } from '../utils/codexlens-path.js'; // Get directory of this module const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Venv paths (reuse CodexLens venv) -const CODEXLENS_VENV = join(homedir(), '.codexlens', 'venv'); -const VENV_PYTHON = - process.platform === 'win32' - ? join(CODEXLENS_VENV, 'Scripts', 'python.exe') - : join(CODEXLENS_VENV, 'bin', 'python'); +const VENV_PYTHON = getCodexLensPython(); // Script path const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'memory_embedder.py'); diff --git a/ccw/src/core/routes/codexlens/config-handlers.ts b/ccw/src/core/routes/codexlens/config-handlers.ts index 0e43cb49..936912cc 100644 --- a/ccw/src/core/routes/codexlens/config-handlers.ts +++ b/ccw/src/core/routes/codexlens/config-handlers.ts @@ -16,6 +16,7 @@ import type { RouteContext } from '../types.js'; import { EXEC_TIMEOUTS } from '../../../utils/exec-constants.js'; import { extractJSON } from './utils.js'; import { stopWatcherForUninstall } from './watcher-handlers.js'; +import { getCodexLensDataDir } from '../../../utils/codexlens-path.js'; export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise { const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx; @@ -777,7 +778,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise = {}; try { const settingsContent = await readFile(settingsPath, 'utf-8'); @@ -943,7 +944,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise = {}; try { const settingsContent = await readFile(settingsPath, 'utf-8'); @@ -1145,7 +1146,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise = {}; try { const content = await readFile(settingsPath, 'utf-8'); @@ -1214,7 +1215,7 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise { const { pathname, url, req, res, initialPath, handlePostRequest } = ctx; @@ -445,9 +446,8 @@ export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise< // Write to CodexLens .env file for persistence const { writeFileSync, existsSync, readFileSync } = await import('fs'); const { join } = await import('path'); - const { homedir } = await import('os'); - const codexlensDir = join(homedir(), '.codexlens'); + const codexlensDir = getCodexLensDataDir(); const envFile = join(codexlensDir, '.env'); // Read existing .env content diff --git a/ccw/src/core/routes/graph-routes.ts b/ccw/src/core/routes/graph-routes.ts index 6c070721..783d980e 100644 --- a/ccw/src/core/routes/graph-routes.ts +++ b/ccw/src/core/routes/graph-routes.ts @@ -2,13 +2,29 @@ * Graph Routes Module * Handles graph visualization API endpoints for codex-lens data */ -import { homedir } from 'os'; import { join, resolve, normalize } from 'path'; import { existsSync, readdirSync } from 'fs'; import Database from 'better-sqlite3'; import { validatePath as validateAllowedPath } from '../../utils/path-validator.js'; import type { RouteContext } from './types.js'; +/** + * Get the index root directory from CodexLens config or default. + * Matches Python implementation priority: + * 1. CODEXLENS_INDEX_DIR environment variable + * 2. index_dir from ~/.codexlens/config.json + * 3. Default: ~/.codexlens/indexes + */ +function getIndexRoot(): string { + const envOverride = process.env.CODEXLENS_INDEX_DIR; + if (envOverride) { + return envOverride; + } + // Default: use CodexLens data directory + indexes + const { getCodexLensDataDir } = require('../../utils/codexlens-path.js'); + return join(getCodexLensDataDir(), 'indexes'); +} + /** * PathMapper utility class (simplified from codex-lens Python implementation) * Maps source paths to index database paths @@ -17,7 +33,7 @@ class PathMapper { private indexRoot: string; constructor(indexRoot?: string) { - this.indexRoot = indexRoot || join(homedir(), '.codexlens', 'indexes'); + this.indexRoot = indexRoot || getIndexRoot(); } /** diff --git a/ccw/src/core/routes/issue-routes.ts b/ccw/src/core/routes/issue-routes.ts index c773a0fb..09c41bdd 100644 --- a/ccw/src/core/routes/issue-routes.ts +++ b/ccw/src/core/routes/issue-routes.ts @@ -992,6 +992,188 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise { return true; } + // POST /api/issues/pull - Pull issues from GitHub + if (pathname === '/api/issues/pull' && req.method === 'POST') { + const state = url.searchParams.get('state') || 'open'; + const limit = parseInt(url.searchParams.get('limit') || '100'); + const labels = url.searchParams.get('labels') || ''; + const downloadImages = url.searchParams.get('downloadImages') === 'true'; + + try { + const { execSync } = await import('child_process'); + const https = await import('https'); + const http = await import('http'); + + // Check if gh CLI is available + try { + execSync('gh --version', { stdio: 'ignore', timeout: 5000 }); + } catch { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'GitHub CLI (gh) is not installed or not in PATH' })); + return true; + } + + // Build gh command + let ghCommand = `gh issue list --state ${state} --limit ${limit} --json number,title,body,labels,url,state`; + if (labels) ghCommand += ` --label "${labels}"`; + + // Execute gh command from project root + const ghOutput = execSync(ghCommand, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 60000, + cwd: issuesDir.replace(/[\\/]\.workflow[\\/]issues$/, '') + }).trim(); + + if (!ghOutput) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ imported: 0, updated: 0, skipped: 0, images_downloaded: 0 })); + return true; + } + + const ghIssues = JSON.parse(ghOutput); + const existingIssues = readIssuesJsonl(issuesDir); + + let imported = 0; + let skipped = 0; + let updated = 0; + let imagesDownloaded = 0; + + // Create images directory if needed + const imagesDir = join(issuesDir, 'images'); + if (downloadImages && !existsSync(imagesDir)) { + mkdirSync(imagesDir, { recursive: true }); + } + + // Helper function to download image + const downloadImage = async (imageUrl: string, issueNumber: number, imageIndex: number): Promise => { + return new Promise((resolveDownload) => { + try { + const ext = imageUrl.match(/\.(png|jpg|jpeg|gif|webp|svg)/i)?.[1] || 'png'; + const filename = `GH-${issueNumber}-${imageIndex}.${ext}`; + const filePath = join(imagesDir, filename); + + // Skip if already downloaded + if (existsSync(filePath)) { + resolveDownload(`.workflow/issues/images/${filename}`); + return; + } + + const protocol = imageUrl.startsWith('https') ? https : http; + protocol.get(imageUrl, { timeout: 30000 }, (response: any) => { + // Handle redirect + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + downloadImage(redirectUrl, issueNumber, imageIndex).then(resolveDownload); + return; + } + } + if (response.statusCode !== 200) { + resolveDownload(null); + return; + } + + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => { + try { + writeFileSync(filePath, Buffer.concat(chunks)); + resolveDownload(`.workflow/issues/images/${filename}`); + } catch { + resolveDownload(null); + } + }); + response.on('error', () => resolveDownload(null)); + }).on('error', () => resolveDownload(null)); + } catch { + resolveDownload(null); + } + }); + }; + + // Process issues + for (const ghIssue of ghIssues) { + const issueId = `GH-${ghIssue.number}`; + const existingIssue = existingIssues.find((i: any) => i.id === issueId); + + let context = ghIssue.body || ghIssue.title; + + // Extract and download images if enabled + if (downloadImages && ghIssue.body) { + // Find all image URLs in the body + const imgPattern = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)|]+src=["'](https?:\/\/[^"']+)["']/gi; + const imageUrls: string[] = []; + let match; + while ((match = imgPattern.exec(ghIssue.body)) !== null) { + imageUrls.push(match[1] || match[2]); + } + + // Download images and build reference list + if (imageUrls.length > 0) { + const downloadedImages: string[] = []; + for (let i = 0; i < imageUrls.length; i++) { + const localPath = await downloadImage(imageUrls[i], ghIssue.number, i + 1); + if (localPath) { + downloadedImages.push(localPath); + imagesDownloaded++; + } + } + + // Append image references to context + if (downloadedImages.length > 0) { + context += '\n\n---\n**Downloaded Images:**\n'; + downloadedImages.forEach((path, idx) => { + context += `- Image ${idx + 1}: \`${path}\`\n`; + }); + } + } + } + + // Prepare issue data (truncate context to 2000 chars max) + const issueData = { + id: issueId, + title: ghIssue.title, + status: ghIssue.state === 'OPEN' ? 'registered' : 'completed', + priority: 3, + context: context.substring(0, 2000), + source: 'github', + source_url: ghIssue.url, + tags: ghIssue.labels?.map((l: any) => l.name) || [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + + if (existingIssue) { + // Update if changed + const newStatus = ghIssue.state === 'OPEN' ? 'registered' : 'completed'; + if (existingIssue.status !== newStatus || existingIssue.title !== ghIssue.title) { + existingIssue.title = ghIssue.title; + existingIssue.status = newStatus; + existingIssue.context = issueData.context; + existingIssue.updated_at = new Date().toISOString(); + updated++; + } else { + skipped++; + } + } else { + existingIssues.push(issueData); + imported++; + } + } + + // Save all issues + writeIssuesJsonl(issuesDir, existingIssues); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ imported, updated, skipped, images_downloaded: imagesDownloaded, total: ghIssues.length })); + } catch (err: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message || 'Failed to pull issues from GitHub' })); + } + return true; + } + // GET /api/issues/:id - Get issue detail const detailMatch = pathname.match(/^\/api\/issues\/([^/]+)$/); if (detailMatch && req.method === 'GET') { diff --git a/ccw/src/core/routes/loop-v2-routes.ts b/ccw/src/core/routes/loop-v2-routes.ts index a3bd4e1e..58600d48 100644 --- a/ccw/src/core/routes/loop-v2-routes.ts +++ b/ccw/src/core/routes/loop-v2-routes.ts @@ -37,6 +37,38 @@ import { TaskStorageManager, type TaskCreateRequest, type TaskUpdateRequest, typ import { executeCliTool } from '../../tools/cli-executor.js'; import { loadClaudeCliTools } from '../../tools/claude-cli-tools.js'; +/** + * Module-level cache for CLI tools configuration + * Loaded once at server startup to avoid repeated file I/O + */ +let cachedEnabledTools: string[] | null = null; + +/** + * Initialize CLI tools cache at server startup + * Should be called once when the server starts + */ +export function initializeCliToolsCache(): void { + try { + const cliToolsConfig = loadClaudeCliTools(os.homedir()); + const enabledTools = Object.entries(cliToolsConfig.tools || {}) + .filter(([_, config]) => config.enabled === true) + .map(([name]) => name); + cachedEnabledTools = ['bash', ...enabledTools]; + console.log('[Loop V2] CLI tools cache initialized:', cachedEnabledTools); + } catch (err) { + console.error('[Loop V2] Failed to initialize CLI tools cache:', err); + // Fallback to basic tools if config loading fails + cachedEnabledTools = ['bash', 'gemini', 'qwen', 'codex', 'claude']; + } +} + +/** + * Clear CLI tools cache (for testing or config reload) + */ +export function clearCliToolsCache(): void { + cachedEnabledTools = null; +} + /** * V2 Loop Create Request */ @@ -1314,14 +1346,19 @@ function isValidId(id: string): boolean { } /** - * Get enabled tools list + * Get enabled tools list from cache + * If cache is not initialized, it will load from config (fallback for lazy initialization) */ function getEnabledToolsList(): string[] { - const cliToolsConfig = loadClaudeCliTools(os.homedir()); - const enabledTools = Object.entries(cliToolsConfig.tools || {}) - .filter(([_, config]) => config.enabled === true) - .map(([name]) => name); - return ['bash', ...enabledTools]; + // Return cached value if available + if (cachedEnabledTools) { + return cachedEnabledTools; + } + + // Fallback: lazy initialization if cache not initialized (shouldn't happen in normal operation) + console.warn('[Loop V2] CLI tools cache not initialized, performing lazy load'); + initializeCliToolsCache(); + return cachedEnabledTools || ['bash', 'gemini', 'qwen', 'codex', 'claude']; } /** diff --git a/ccw/src/templates/dashboard-css/32-issue-manager.css b/ccw/src/templates/dashboard-css/32-issue-manager.css index e7b8d6f0..c8058da7 100644 --- a/ccw/src/templates/dashboard-css/32-issue-manager.css +++ b/ccw/src/templates/dashboard-css/32-issue-manager.css @@ -1508,6 +1508,38 @@ transform: translateY(0); } +.issue-pull-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + background: #1a1a1a; + color: #ffffff; + border: 1px solid #2d2d2d; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.issue-pull-btn:hover { + background: #2d2d2d; + border-color: #404040; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.issue-pull-btn:active { + transform: translateY(0); + background: #1a1a1a; +} + +.issue-pull-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ========================================== ISSUE STATS ========================================== */ diff --git a/ccw/src/templates/dashboard-js/components/navigation.js b/ccw/src/templates/dashboard-js/components/navigation.js index 2d66f683..0b269c06 100644 --- a/ccw/src/templates/dashboard-js/components/navigation.js +++ b/ccw/src/templates/dashboard-js/components/navigation.js @@ -303,6 +303,12 @@ async function refreshWorkspace() { liteTaskDataStore[sessionKey] = s; }); + // Populate multiCliPlan sessions + (data.liteTasks?.multiCliPlan || []).forEach(s => { + const sessionKey = `multi-cli-${s.id}`.replace(/[^a-zA-Z0-9-]/g, '-'); + liteTaskDataStore[sessionKey] = s; + }); + // Update global data window.workflowData = data; diff --git a/ccw/src/templates/dashboard-js/components/notifications.js b/ccw/src/templates/dashboard-js/components/notifications.js index 34030d61..dde3caf6 100644 --- a/ccw/src/templates/dashboard-js/components/notifications.js +++ b/ccw/src/templates/dashboard-js/components/notifications.js @@ -827,6 +827,12 @@ async function refreshWorkspaceData(newData) { liteTaskDataStore[key] = s; }); + // Populate multiCliPlan sessions + (newData.liteTasks?.multiCliPlan || []).forEach(s => { + const key = `multi-cli-${s.id}`.replace(/[^a-zA-Z0-9-]/g, '-'); + liteTaskDataStore[key] = s; + }); + // Update UI silently updateStats(); updateBadges(); diff --git a/ccw/src/templates/dashboard-js/components/version-check.js b/ccw/src/templates/dashboard-js/components/version-check.js index 59b16696..7425797c 100644 --- a/ccw/src/templates/dashboard-js/components/version-check.js +++ b/ccw/src/templates/dashboard-js/components/version-check.js @@ -60,6 +60,9 @@ async function checkForUpdatesNow() { btn.disabled = true; } + // Show checking state on badge + updateVersionBadge('checking'); + // Show checking notification console.log('[Version Check] Starting update check...'); if (typeof addGlobalNotification === 'function') { @@ -83,6 +86,9 @@ async function checkForUpdatesNow() { versionCheckData = data; console.log('[Version Check] Result:', data); + // Update badge based on result + updateVersionBadge(data.hasUpdate ? 'has-update' : 'none'); + if (data.hasUpdate) { // New version available console.log('[Version Check] Update available:', data.latestVersion); @@ -109,6 +115,8 @@ async function checkForUpdatesNow() { } } catch (err) { console.error('[Version Check] Error:', err); + // Clear badge on error + updateVersionBadge('none'); if (typeof addGlobalNotification === 'function') { addGlobalNotification( 'error', @@ -154,6 +162,9 @@ async function checkForUpdates() { versionCheckData = await res.json(); + // Update badge + updateVersionBadge(versionCheckData.hasUpdate ? 'has-update' : 'none'); + if (versionCheckData.hasUpdate && !versionBannerDismissed) { showUpdateBanner(versionCheckData); addGlobalNotification( @@ -299,3 +310,30 @@ function getVersionInfo() { function isAutoUpdateEnabled() { return autoUpdateEnabled; } + +/** + * Update version badge state + * @param {string} state - 'checking', 'has-update', 'none' + */ +function updateVersionBadge(state) { + const badge = document.getElementById('versionBadge'); + if (!badge) return; + + // Remove all state classes + badge.classList.remove('has-update', 'checking'); + badge.textContent = ''; + + switch (state) { + case 'checking': + badge.classList.add('checking'); + break; + case 'has-update': + badge.classList.add('has-update'); + badge.textContent = '!'; + break; + case 'none': + default: + // Hide badge + break; + } +} diff --git a/ccw/src/templates/dashboard-js/views/issue-manager.js b/ccw/src/templates/dashboard-js/views/issue-manager.js index e0a3d677..15d536ab 100644 --- a/ccw/src/templates/dashboard-js/views/issue-manager.js +++ b/ccw/src/templates/dashboard-js/views/issue-manager.js @@ -185,6 +185,14 @@ function renderIssueView() {
+ + +
+ + + `; @@ -2627,6 +2688,127 @@ function hideCreateIssueModal() { } } +// ========== Pull Issues Modal ========== +function showPullIssuesModal() { + const modal = document.getElementById('pullIssuesModal'); + if (modal) { + modal.classList.remove('hidden'); + // Reset result area + const resultDiv = document.getElementById('pullIssueResult'); + if (resultDiv) { + resultDiv.classList.add('hidden'); + resultDiv.innerHTML = ''; + } + lucide.createIcons(); + } +} + +function hidePullIssuesModal() { + const modal = document.getElementById('pullIssuesModal'); + if (modal) { + modal.classList.add('hidden'); + // Clear form + const stateSelect = document.getElementById('pullIssueState'); + const limitInput = document.getElementById('pullIssueLimit'); + const labelsInput = document.getElementById('pullIssueLabels'); + const downloadImagesCheck = document.getElementById('pullDownloadImages'); + if (stateSelect) stateSelect.value = 'open'; + if (limitInput) limitInput.value = '20'; + if (labelsInput) labelsInput.value = ''; + if (downloadImagesCheck) downloadImagesCheck.checked = true; + } +} + +async function pullGitHubIssues() { + const stateSelect = document.getElementById('pullIssueState'); + const limitInput = document.getElementById('pullIssueLimit'); + const labelsInput = document.getElementById('pullIssueLabels'); + const downloadImagesCheck = document.getElementById('pullDownloadImages'); + const resultDiv = document.getElementById('pullIssueResult'); + const pullBtn = document.getElementById('pullIssuesBtn'); + + const state = stateSelect?.value || 'open'; + const limit = parseInt(limitInput?.value || '20'); + const labels = labelsInput?.value?.trim(); + const downloadImages = downloadImagesCheck?.checked || false; + + // Disable button and show loading + if (pullBtn) { + pullBtn.disabled = true; + pullBtn.innerHTML = '' + (t('common.loading') || 'Loading...'); + lucide.createIcons(); + } + + try { + const params = new URLSearchParams({ + path: projectPath, + state: state, + limit: limit.toString(), + downloadImages: downloadImages.toString() + }); + if (labels) params.set('labels', labels); + + const response = await fetch('/api/issues/pull?' + params.toString(), { + method: 'POST' + }); + + const result = await response.json(); + + if (!response.ok || result.error) { + showNotification(result.error || 'Failed to pull issues', 'error'); + if (resultDiv) { + resultDiv.classList.remove('hidden'); + resultDiv.innerHTML = `

${result.error || 'Failed to pull issues'}

`; + } + return; + } + + // Show results + if (resultDiv) { + resultDiv.classList.remove('hidden'); + resultDiv.innerHTML = ` +
+ +
+

${t('issues.pullSuccess') || 'GitHub Issues Pulled Successfully'}

+
+

✓ Imported: ${result.imported || 0} new issues

+

✓ Updated: ${result.updated || 0} existing issues

+

✓ Skipped: ${result.skipped || 0} unchanged issues

+ ${result.images_downloaded > 0 ? `

✓ Downloaded: ${result.images_downloaded} images

` : ''} +
+
+
+ `; + lucide.createIcons(); + } + + showNotification(`Pulled ${result.imported + result.updated} issues from GitHub`, 'success'); + + // Reload data after 1 second + setTimeout(async () => { + await loadIssueData(); + renderIssueView(); + hidePullIssuesModal(); + }, 1500); + + } catch (err) { + console.error('Failed to pull issues:', err); + showNotification('Failed to pull issues', 'error'); + if (resultDiv) { + resultDiv.classList.remove('hidden'); + resultDiv.innerHTML = `

${err.message || 'Unknown error occurred'}

`; + } + } finally { + // Re-enable button + if (pullBtn) { + pullBtn.disabled = false; + pullBtn.innerHTML = '' + (t('issues.pull') || 'Pull'); + lucide.createIcons(); + } + } +} + async function createIssue() { const idInput = document.getElementById('newIssueId'); const titleInput = document.getElementById('newIssueTitle'); diff --git a/ccw/src/templates/dashboard-js/views/loop-monitor.js b/ccw/src/templates/dashboard-js/views/loop-monitor.js index f9bcdd95..22ad16da 100644 --- a/ccw/src/templates/dashboard-js/views/loop-monitor.js +++ b/ccw/src/templates/dashboard-js/views/loop-monitor.js @@ -1012,15 +1012,24 @@ async function saveTaskOrder(loopId, newOrder) { /** * Show add task modal + * Loads enabled tools before displaying modal to prevent race conditions */ async function showAddTaskModal(loopId) { - // Get enabled tools - const enabledTools = await getEnabledTools(); + // Find and disable the "Add Task" button to prevent multiple clicks during loading + const addTaskButton = event?.target; + if (addTaskButton) { + addTaskButton.disabled = true; + const originalText = addTaskButton.innerHTML; + addTaskButton.innerHTML = ' ' + (t('common.loading') || 'Loading...'); - // Build tool options HTML - const toolOptions = enabledTools.map(tool => - `` - ).join(''); + try { + // Get enabled tools (this ensures tools are loaded before modal opens) + const enabledTools = await getEnabledTools(); + + // Build tool options HTML + const toolOptions = enabledTools.map(tool => + `` + ).join(''); const modal = document.createElement('div'); modal.id = 'addTaskModal'; @@ -1075,11 +1084,103 @@ async function showAddTaskModal(loopId) { `; - document.body.appendChild(modal); - if (typeof lucide !== 'undefined') lucide.createIcons(); + document.body.appendChild(modal); + if (typeof lucide !== 'undefined') lucide.createIcons(); - // Focus on description field - setTimeout(() => document.getElementById('taskDescription').focus(), 100); + // Focus on description field + setTimeout(() => document.getElementById('taskDescription').focus(), 100); + + } catch (err) { + console.error('Failed to show add task modal:', err); + alert(t('loop.loadToolsError') || 'Failed to load available tools. Please try again.'); + } finally { + // Restore button state + if (addTaskButton) { + addTaskButton.disabled = false; + addTaskButton.innerHTML = originalText; + } + } + } else { + // Fallback if event is not available (shouldn't happen normally) + const enabledTools = await getEnabledTools(); + const toolOptions = enabledTools.map(tool => + `` + ).join(''); + + const modal = document.createElement('div'); + modal.id = 'addTaskModal'; + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + if (typeof lucide !== 'undefined') lucide.createIcons(); + setTimeout(() => document.getElementById('taskDescription').focus(), 100); + } } /** diff --git a/ccw/src/templates/dashboard.html b/ccw/src/templates/dashboard.html index 4d247d46..c28b3cc1 100644 --- a/ccw/src/templates/dashboard.html +++ b/ccw/src/templates/dashboard.html @@ -278,6 +278,50 @@ display: block; animation: spin 1s linear infinite; } + .check-icon-loading { + display: none; + } + + /* Version Badge */ + .version-badge { + display: none; + position: absolute; + top: -2px; + right: -2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + background: hsl(var(--destructive)); + color: white; + border-radius: 8px; + font-size: 0.625rem; + font-weight: 600; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 10; + } + .version-badge.has-update { + display: flex; + animation: badgePulse 2s ease-in-out infinite; + } + .version-badge.checking { + display: flex; + background: hsl(var(--muted) / 0.8); + } + .version-badge.checking::after { + content: '...'; + animation: dots 1.5s steps(4, end) infinite; + } + @keyframes badgePulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.15); } + } + @keyframes dots { + 0%, 20% { content: '.'; } + 40% { content: '..'; } + 60%, 100% { content: '...'; } + } /* Auto-Update Toggle Switch */ .auto-update-switch { @@ -390,7 +434,7 @@
-