diff --git a/.claude/workflows/intelligent-tools-strategy.md b/.claude/workflows/intelligent-tools-strategy.md index 9233c473..bbf98cf7 100644 --- a/.claude/workflows/intelligent-tools-strategy.md +++ b/.claude/workflows/intelligent-tools-strategy.md @@ -51,6 +51,7 @@ ccw cli exec "" --tool codex --mode auto --cd ./project --includeDirs ./ |---------|-------------| | `ccw cli status` | Check CLI tools availability | | `ccw cli exec ""` | Execute a CLI tool | +| `ccw cli resume [id]` | Resume a previous session | | `ccw cli history` | Show execution history | | `ccw cli detail ` | Show execution detail | @@ -121,22 +122,38 @@ ccw cli exec "" --tool codex --mode auto --cd ./project --includeDirs ./ **Default MODE**: No default, must be explicitly specified -**Session Management** (via native codex): -- `codex resume` - Resume previous session (picker) -- `codex resume --last` - Resume most recent session -- `codex -i ` - Attach image to prompt +### Session Management -### CCW Unified Parameter Mapping +**Resume Commands** (unified via CCW): +```bash +# Resume last session (any tool) +ccw cli resume --last -CCW automatically maps parameters to tool-specific syntax: +# Resume last session for specific tool +ccw cli resume --last --tool gemini +ccw cli resume --last --tool codex -| CCW Parameter | Gemini/Qwen | Codex | -|---------------|-------------|-------| -| `--cd ` | `cd &&` (prepend) | `-C ` | -| `--includeDirs ` | `--include-directories ` | `--add-dir ` (per dir) | -| `--mode write` | `--approval-mode yolo` | `--skip-git-repo-check -s danger-full-access` | -| `--mode auto` | N/A | `--skip-git-repo-check -s danger-full-access` | -| `--model ` | `-m ` | `-m ` | +# Resume specific session by ID +ccw cli resume + +# Resume with additional prompt +ccw cli resume --last --prompt "Continue with error handling" + +# Codex native interactive picker +ccw cli resume --tool codex +``` + +**Resume Options**: +| Option | Description | +|--------|-------------| +| `--last` | Resume most recent session | +| `--tool ` | Filter by tool (gemini, qwen, codex) | +| `--prompt ` | Additional prompt for continuation | +| `[id]` | Specific execution ID to resume | + +**Tool-Specific Behavior**: +- **Codex**: Uses native `codex resume` command (supports interactive picker) +- **Gemini/Qwen**: Loads previous conversation context and continues with new prompt --- diff --git a/ccw/src/cli.ts b/ccw/src/cli.ts index b661da68..b5f61a08 100644 --- a/ccw/src/cli.ts +++ b/ccw/src/cli.ts @@ -154,12 +154,14 @@ export function run(argv: string[]): void { .option('--tool ', 'CLI tool to use', 'gemini') .option('--mode ', 'Execution mode: analysis, write, auto', 'analysis') .option('--model ', 'Model override') - .option('--cd ', 'Working directory (-C for codex)') + .option('--cd ', 'Working directory') .option('--includeDirs ', 'Additional directories (--include-directories for gemini/qwen, --add-dir for codex)') .option('--timeout ', 'Timeout in milliseconds', '300000') .option('--no-stream', 'Disable streaming output') .option('--limit ', 'History limit') .option('--status ', 'Filter by status') + .option('--last', 'Resume most recent session') + .option('--prompt ', 'Additional prompt for resume continuation') .action((subcommand, args, options) => cliCommand(subcommand, args, options)); program.parse(argv); diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index 55969cc0..1e01330d 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -8,7 +8,8 @@ import { cliExecutorTool, getCliToolsStatus, getExecutionHistory, - getExecutionDetail + getExecutionDetail, + resumeCliSession } from '../tools/cli-executor.js'; interface CliExecOptions { @@ -27,6 +28,12 @@ interface HistoryOptions { status?: string; } +interface ResumeOptions { + tool?: string; + last?: boolean; + prompt?: string; +} + /** * Show CLI tool status */ @@ -75,23 +82,25 @@ async function execAction(prompt: string | undefined, options: CliExecOptions): prompt, mode, model, - dir: cd, - include: includeDirs, - timeout: timeout ? parseInt(timeout, 10) : 300000, - stream: !noStream - }); + cd, + includeDirs, + timeout: timeout ? parseInt(timeout, 10) : 300000 + }, onOutput); // If not streaming, print output now if (noStream && result.stdout) { console.log(result.stdout); } - // Print summary + // Print summary with execution ID for resume console.log(); if (result.success) { console.log(chalk.green(` ✓ Completed in ${(result.execution.duration_ms / 1000).toFixed(1)}s`)); + console.log(chalk.gray(` ID: ${result.execution.id}`)); + console.log(chalk.dim(` Resume: ccw cli resume ${result.execution.id}`)); } else { console.log(chalk.red(` ✗ Failed (${result.execution.status})`)); + console.log(chalk.gray(` ID: ${result.execution.id}`)); if (result.stderr) { console.error(chalk.red(result.stderr)); } @@ -185,6 +194,64 @@ async function detailAction(executionId: string | undefined): Promise { console.log(); } +/** + * Resume a CLI session + * @param {string | undefined} executionId - Optional execution ID to resume + * @param {Object} options - CLI options + */ +async function resumeAction(executionId: string | undefined, options: ResumeOptions): Promise { + const { tool, last, prompt } = options; + + // Determine resume mode + let resumeMode = ''; + if (executionId) { + resumeMode = `session ${executionId}`; + } else if (last) { + resumeMode = tool ? `last ${tool} session` : 'last session'; + } else if (tool === 'codex') { + resumeMode = 'codex (interactive picker)'; + } else { + console.error(chalk.red('Error: Please specify --last or provide an execution ID')); + console.error(chalk.gray('Usage: ccw cli resume [id] --last --tool gemini')); + process.exit(1); + } + + console.log(chalk.cyan(`\n Resuming ${resumeMode}...\n`)); + + try { + const result = await resumeCliSession( + process.cwd(), + { + tool, + executionId, + last, + prompt + }, + (chunk) => { + process.stdout.write(chunk.data); + } + ); + + console.log(); + if (result.success) { + console.log(chalk.green(` ✓ Completed in ${(result.execution.duration_ms / 1000).toFixed(1)}s`)); + console.log(chalk.gray(` ID: ${result.execution.id}`)); + console.log(chalk.dim(` Resume: ccw cli resume ${result.execution.id}`)); + } else { + console.log(chalk.red(` ✗ Failed (${result.execution.status})`)); + console.log(chalk.gray(` ID: ${result.execution.id}`)); + if (result.stderr) { + console.error(chalk.red(result.stderr)); + } + process.exit(1); + } + } catch (error) { + const err = error as Error; + console.error(chalk.red(` Error: ${err.message}`)); + process.exit(1); + } +} + /** * Get human-readable time ago string * @param {Date} date @@ -202,14 +269,14 @@ function getTimeAgo(date: Date): string { /** * CLI command entry point - * @param {string} subcommand - Subcommand (status, exec, history, detail) + * @param {string} subcommand - Subcommand (status, exec, history, detail, resume) * @param {string[]} args - Arguments array * @param {Object} options - CLI options */ export async function cliCommand( subcommand: string, args: string | string[], - options: CliExecOptions | HistoryOptions + options: CliExecOptions | HistoryOptions | ResumeOptions ): Promise { const argsArray = Array.isArray(args) ? args : (args ? [args] : []); @@ -230,12 +297,17 @@ export async function cliCommand( await detailAction(argsArray[0]); break; + case 'resume': + await resumeAction(argsArray[0], options as ResumeOptions); + break; + default: console.log(chalk.bold.cyan('\n CCW CLI Tool Executor\n')); console.log(' Unified interface for Gemini, Qwen, and Codex CLI tools.\n'); console.log(' Subcommands:'); console.log(chalk.gray(' status Check CLI tools availability')); console.log(chalk.gray(' exec Execute a CLI tool')); + console.log(chalk.gray(' resume [id] Resume a previous session')); console.log(chalk.gray(' history Show execution history')); console.log(chalk.gray(' detail Show execution detail')); console.log(); @@ -243,13 +315,19 @@ export async function cliCommand( console.log(chalk.gray(' --tool Tool to use: gemini, qwen, codex (default: gemini)')); console.log(chalk.gray(' --mode Mode: analysis, write, auto (default: analysis)')); console.log(chalk.gray(' --model Model override')); - console.log(chalk.gray(' --cd Working directory (-C for codex)')); + console.log(chalk.gray(' --cd Working directory')); console.log(chalk.gray(' --includeDirs Additional directories (comma-separated)')); console.log(chalk.gray(' → gemini/qwen: --include-directories')); console.log(chalk.gray(' → codex: --add-dir')); console.log(chalk.gray(' --timeout Timeout in milliseconds (default: 300000)')); console.log(chalk.gray(' --no-stream Disable streaming output')); console.log(); + console.log(' Resume Options:'); + console.log(chalk.gray(' --last Resume most recent session')); + console.log(chalk.gray(' --tool Tool filter (gemini, qwen, codex)')); + console.log(chalk.gray(' --prompt Additional prompt for continuation')); + console.log(chalk.gray(' [id] Specific execution ID to resume')); + console.log(); console.log(' History Options:'); console.log(chalk.gray(' --limit Number of results (default: 20)')); console.log(chalk.gray(' --tool Filter by tool')); @@ -260,6 +338,10 @@ export async function cliCommand( console.log(chalk.gray(' ccw cli exec "Analyze the auth module" --tool gemini')); console.log(chalk.gray(' ccw cli exec "Analyze with context" --tool gemini --includeDirs ../shared,../types')); console.log(chalk.gray(' ccw cli exec "Implement feature" --tool codex --mode auto --includeDirs ./lib')); + console.log(chalk.gray(' ccw cli resume --last # Resume last session')); + console.log(chalk.gray(' ccw cli resume --last --tool gemini # Resume last Gemini session')); + console.log(chalk.gray(' ccw cli resume --tool codex # Codex interactive picker')); + console.log(chalk.gray(' ccw cli resume --prompt "Continue..." # Resume specific session')); console.log(chalk.gray(' ccw cli history --tool gemini --limit 10')); console.log(); } diff --git a/ccw/src/core/dashboard-generator.ts b/ccw/src/core/dashboard-generator.ts index fd8965c4..f6c82b7a 100644 --- a/ccw/src/core/dashboard-generator.ts +++ b/ccw/src/core/dashboard-generator.ts @@ -44,6 +44,7 @@ const MODULE_FILES = [ 'components/carousel.js', 'components/notifications.js', 'components/global-notifications.js', + 'components/task-queue-sidebar.js', 'components/cli-status.js', 'components/cli-history.js', 'components/mcp-manager.js', diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index baceda85..a4ebb4f3 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -8,7 +8,7 @@ import { createHash } from 'crypto'; import { scanSessions } from './session-scanner.js'; import { aggregateData } from './data-aggregator.js'; import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js'; -import { getCliToolsStatus, getExecutionHistory, getExecutionDetail, deleteExecution, executeCliTool } from '../tools/cli-executor.js'; +import { getCliToolsStatus, getExecutionHistory, getExecutionDetail, deleteExecution, executeCliTool, resumeCliSession } from '../tools/cli-executor.js'; import { getAllManifests } from './manifest.js'; import { checkVenvStatus, bootstrapVenv, executeCodexLens, checkSemanticStatus, installSemantic } from '../tools/codex-lens.js'; import { listTools } from '../tools/index.js'; @@ -641,8 +641,9 @@ export async function startServer(options: ServerOptions = {}): Promise { + const { executionId, tool, last, prompt } = body as { + executionId?: string; + tool?: string; + last?: boolean; + prompt?: string; + }; + + if (!executionId && !last && tool !== 'codex') { + return { error: 'executionId or --last flag is required', status: 400 }; + } + + // Broadcast resume started + const resumeId = `${Date.now()}-resume`; + broadcastToClients({ + type: 'CLI_EXECUTION_STARTED', + payload: { + executionId: resumeId, + tool: tool || 'resume', + mode: 'resume', + resumeFrom: executionId, + timestamp: new Date().toISOString() + } + }); + + try { + const result = await resumeCliSession( + initialPath, + { tool, executionId, last, prompt }, + (chunk) => { + broadcastToClients({ + type: 'CLI_OUTPUT', + payload: { + executionId: resumeId, + chunkType: chunk.type, + data: chunk.data + } + }); + } + ); + + // Broadcast completion + broadcastToClients({ + type: 'CLI_EXECUTION_COMPLETED', + payload: { + executionId: resumeId, + success: result.success, + status: result.execution.status, + duration_ms: result.execution.duration_ms, + resumeFrom: executionId + } + }); + + return { + success: result.success, + execution: result.execution + }; + + } catch (error: unknown) { + broadcastToClients({ + type: 'CLI_EXECUTION_ERROR', + payload: { + executionId: resumeId, + error: (error as Error).message + } + }); + + return { error: (error as Error).message, status: 500 }; + } + }); + return; + } + // API: Update CLAUDE.md using CLI tools (Explorer view) if (pathname === '/api/update-claude-md' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { diff --git a/ccw/src/templates/dashboard-css/07-managers.css b/ccw/src/templates/dashboard-css/07-managers.css index e4f117b9..2c40f5b1 100644 --- a/ccw/src/templates/dashboard-css/07-managers.css +++ b/ccw/src/templates/dashboard-css/07-managers.css @@ -934,3 +934,856 @@ font-weight: 500; } +/* ========================================== + NOTIFICATION SIDEBAR (Right-Side Toolbar) + ========================================== */ + +/* Sidebar Container */ +.notif-sidebar { + position: fixed; + top: 0; + right: 0; + width: 380px; + max-width: 90vw; + height: 100vh; + background: hsl(var(--card)); + border-left: 1px solid hsl(var(--border)); + box-shadow: -4px 0 24px rgb(0 0 0 / 0.15); + z-index: 1100; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.notif-sidebar.open { + transform: translateX(0); +} + +/* Sidebar Header */ +.notif-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--card)); +} + +.notif-sidebar-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.notif-title-icon { + font-size: 1.25rem; +} + +.notif-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 6px; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-radius: 11px; + font-size: 0.75rem; + font-weight: 600; +} + +.notif-sidebar-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: none; + border-radius: 6px; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.15s; +} + +.notif-sidebar-close:hover { + background: hsl(var(--hover)); + color: hsl(var(--foreground)); +} + +/* Sidebar Actions */ +.notif-sidebar-actions { + display: flex; + gap: 8px; + padding: 12px 20px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.notif-action-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 6px; + font-size: 0.8125rem; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.15s; +} + +.notif-action-btn:hover { + background: hsl(var(--hover)); + color: hsl(var(--foreground)); + border-color: hsl(var(--primary) / 0.5); +} + +/* Sidebar Content */ +.notif-sidebar-content { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +/* Empty State */ +.notif-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; +} + +.notif-empty-icon { + font-size: 3rem; + opacity: 0.3; + margin-bottom: 16px; +} + +.notif-empty-text { + font-size: 1rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + margin-bottom: 8px; +} + +.notif-empty-hint { + font-size: 0.8125rem; + color: hsl(var(--muted-foreground) / 0.7); +} + +/* Notification Items */ +.notif-item { + padding: 12px 14px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + margin-bottom: 8px; + transition: all 0.15s; +} + +.notif-item:hover { + border-color: hsl(var(--primary) / 0.3); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); +} + +.notif-item.read { + opacity: 0.7; +} + +.notif-item.type-info { + border-left: 3px solid hsl(var(--primary)); +} + +.notif-item.type-success { + border-left: 3px solid hsl(var(--success)); +} + +.notif-item.type-warning { + border-left: 3px solid hsl(var(--warning)); +} + +.notif-item.type-error { + border-left: 3px solid hsl(var(--destructive)); +} + +.notif-item-header { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.notif-icon { + font-size: 1.125rem; + flex-shrink: 0; +} + +.notif-item-content { + flex: 1; + min-width: 0; +} + +.notif-message { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--foreground)); + line-height: 1.4; + word-break: break-word; +} + +.notif-source { + display: inline-block; + margin-top: 4px; + padding: 2px 8px; + background: hsl(var(--muted)); + border-radius: 4px; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + font-family: var(--font-mono); +} + +/* Notification Details */ +.notif-details { + margin-top: 10px; + padding: 10px 12px; + background: hsl(var(--muted) / 0.5); + border-radius: 6px; + font-size: 0.8125rem; + color: hsl(var(--muted-foreground)); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); +} + +/* JSON Formatted Details */ +.notif-details-json { + margin-top: 10px; + padding: 10px 12px; + background: hsl(var(--muted) / 0.5); + border-radius: 6px; + font-size: 0.8125rem; + line-height: 1.6; + font-family: var(--font-mono); +} + +.json-field { + padding: 2px 0; +} + +.json-key { + color: hsl(var(--primary)); + font-weight: 500; +} + +.json-value { + color: hsl(var(--foreground)); +} + +.json-string { + color: hsl(142 71% 45%); +} + +.json-number { + color: hsl(217 91% 60%); +} + +.json-bool { + color: hsl(280 65% 60%); +} + +.json-null { + color: hsl(var(--muted-foreground)); + font-style: italic; +} + +.json-object { + color: hsl(var(--muted-foreground)); +} + +.json-empty { + color: hsl(var(--muted-foreground)); + font-style: italic; +} + +.json-more { + color: hsl(var(--muted-foreground)); + font-style: italic; + padding-top: 4px; +} + +.json-array-item { + padding: 2px 0; +} + +.json-index { + color: hsl(var(--muted-foreground)); + font-size: 0.75rem; + margin-right: 6px; +} + +/* Notification Meta */ +.notif-meta { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid hsl(var(--border) / 0.5); +} + +.notif-time { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +/* Toggle Button (Right Edge) */ +.notif-sidebar-toggle { + position: fixed; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 40px; + height: 64px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-right: none; + border-radius: 8px 0 0 8px; + box-shadow: -2px 0 8px rgb(0 0 0 / 0.1); + cursor: pointer; + z-index: 1050; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + transition: all 0.2s; +} + +.notif-sidebar-toggle:hover { + width: 48px; + background: hsl(var(--hover)); +} + +.notif-sidebar-toggle.hidden { + transform: translateY(-50%) translateX(100%); + opacity: 0; + pointer-events: none; +} + +.toggle-icon { + font-size: 1.25rem; +} + +.toggle-badge { + display: none; + min-width: 18px; + height: 18px; + padding: 0 5px; + background: hsl(var(--destructive)); + color: white; + border-radius: 9px; + font-size: 0.6875rem; + font-weight: 600; + align-items: center; + justify-content: center; +} + +.toggle-badge[style*="display: flex"] { + display: flex; +} + +/* Overlay */ +.notif-sidebar-overlay { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 0.4); + z-index: 1090; + opacity: 0; + visibility: hidden; + transition: all 0.3s; +} + +.notif-sidebar-overlay.show { + opacity: 1; + visibility: visible; +} + +/* Toast Notification */ +.notif-toast { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 18px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.2); + z-index: 1200; + transform: translateY(100px); + opacity: 0; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.notif-toast.show { + transform: translateY(0); + opacity: 1; +} + +.notif-toast.type-info { + border-left: 3px solid hsl(var(--primary)); +} + +.notif-toast.type-success { + border-left: 3px solid hsl(var(--success)); +} + +.notif-toast.type-warning { + border-left: 3px solid hsl(var(--warning)); +} + +.notif-toast.type-error { + border-left: 3px solid hsl(var(--destructive)); +} + +.toast-icon { + font-size: 1.25rem; +} + +.toast-message { + font-size: 0.875rem; + color: hsl(var(--foreground)); + max-width: 280px; +} + +/* Responsive */ +@media (max-width: 480px) { + .notif-sidebar { + width: 100vw; + max-width: 100vw; + } + + .notif-sidebar-toggle { + top: auto; + bottom: 80px; + transform: translateY(0); + } + + .notif-sidebar-toggle.hidden { + transform: translateX(100%); + } + + .notif-toast { + left: 16px; + right: 16px; + bottom: 16px; + } + + .toast-message { + max-width: none; + } +} + +/* ========================================== + TASK QUEUE SIDEBAR (Right-Side Toolbar) + ========================================== */ + +/* Sidebar Container */ +.task-queue-sidebar { + position: fixed; + top: 0; + right: 0; + width: 400px; + max-width: 90vw; + height: 100vh; + background: hsl(var(--card)); + border-left: 1px solid hsl(var(--border)); + box-shadow: -4px 0 24px rgb(0 0 0 / 0.15); + z-index: 1100; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.task-queue-sidebar.open { + transform: translateX(0); +} + +/* Sidebar Header */ +.task-queue-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--card)); +} + +.task-queue-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.task-queue-title-icon { + font-size: 1.25rem; +} + +.task-queue-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 6px; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-radius: 11px; + font-size: 0.75rem; + font-weight: 600; +} + +.task-queue-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: none; + border-radius: 6px; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.15s; +} + +.task-queue-close:hover { + background: hsl(var(--hover)); + color: hsl(var(--foreground)); +} + +/* Filters */ +.task-queue-filters { + display: flex; + gap: 6px; + padding: 12px 20px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.task-filter-btn { + padding: 6px 12px; + background: transparent; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + font-size: 0.8125rem; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.15s; +} + +.task-filter-btn:hover { + background: hsl(var(--hover)); + color: hsl(var(--foreground)); +} + +.task-filter-btn.active { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-color: hsl(var(--primary)); +} + +/* Sidebar Content */ +.task-queue-content { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +/* Empty State */ +.task-queue-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; +} + +.task-queue-empty-icon { + font-size: 3rem; + opacity: 0.3; + margin-bottom: 16px; +} + +.task-queue-empty-text { + font-size: 1rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + margin-bottom: 8px; +} + +.task-queue-empty-hint { + font-size: 0.8125rem; + color: hsl(var(--muted-foreground) / 0.7); +} + +/* Task Queue Items */ +.task-queue-item { + padding: 14px 16px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 10px; + margin-bottom: 10px; + cursor: pointer; + transition: all 0.15s; +} + +.task-queue-item:hover { + border-color: hsl(var(--primary) / 0.4); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.08); + transform: translateX(-2px); +} + +.task-queue-item.status-in_progress { + border-left: 4px solid hsl(var(--warning)); + background: hsl(var(--warning) / 0.05); +} + +.task-queue-item.status-pending { + border-left: 4px solid hsl(var(--muted-foreground)); +} + +.task-queue-item.status-completed { + border-left: 4px solid hsl(var(--success)); + opacity: 0.7; +} + +.task-queue-item.status-skipped { + border-left: 4px solid hsl(var(--muted-foreground) / 0.5); + opacity: 0.5; +} + +.task-queue-item-header { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.task-queue-status-icon { + font-size: 1.25rem; + flex-shrink: 0; + margin-top: 2px; +} + +.task-queue-item-info { + flex: 1; + min-width: 0; +} + +.task-queue-item-title { + display: block; + font-size: 0.9rem; + font-weight: 500; + color: hsl(var(--foreground)); + line-height: 1.4; + word-break: break-word; +} + +.task-queue-item-id { + display: block; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + font-family: var(--font-mono); + margin-top: 4px; +} + +.task-queue-item-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +.task-queue-session-tag { + padding: 3px 8px; + background: hsl(var(--muted)); + border-radius: 4px; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + font-family: var(--font-mono); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-queue-type-badge { + padding: 3px 8px; + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 500; + text-transform: uppercase; +} + +.task-queue-type-badge.type-workflow { + background: hsl(var(--primary) / 0.15); + color: hsl(var(--primary)); +} + +.task-queue-type-badge.type-lite { + background: hsl(var(--success) / 0.15); + color: hsl(var(--success)); +} + +.task-queue-type-badge.type-tdd { + background: hsl(280 65% 60% / 0.15); + color: hsl(280 65% 60%); +} + +.task-queue-type-badge.type-test { + background: hsl(var(--warning) / 0.15); + color: hsl(var(--warning)); +} + +.task-queue-item-scope { + margin-top: 8px; + padding: 6px 10px; + background: hsl(var(--muted) / 0.5); + border-radius: 6px; +} + +.task-queue-item-scope code { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + font-family: var(--font-mono); + word-break: break-all; +} + +/* Toggle Button (Below Notification Toggle) */ +.task-queue-toggle { + position: fixed; + top: calc(50% + 40px); + right: 0; + transform: translateY(-50%); + width: 40px; + height: 64px; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-right: none; + border-radius: 8px 0 0 8px; + box-shadow: -2px 0 8px rgb(0 0 0 / 0.1); + cursor: pointer; + z-index: 1050; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + transition: all 0.2s; +} + +.task-queue-toggle:hover { + width: 48px; + background: hsl(var(--hover)); +} + +.task-queue-toggle.hidden { + transform: translateY(-50%) translateX(100%); + opacity: 0; + pointer-events: none; +} + +.task-queue-toggle .toggle-icon { + font-size: 1.25rem; +} + +.task-queue-toggle .toggle-badge { + display: none; + min-width: 18px; + height: 18px; + padding: 0 5px; + background: hsl(var(--warning)); + color: white; + border-radius: 9px; + font-size: 0.6875rem; + font-weight: 600; + align-items: center; + justify-content: center; +} + +.task-queue-toggle .toggle-badge[style*="display: flex"] { + display: flex; +} + +.task-queue-toggle .toggle-badge.has-active { + background: hsl(var(--warning)); + animation: taskPulse 2s ease-in-out infinite; +} + +@keyframes taskPulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +/* Overlay */ +.task-queue-overlay { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 0.4); + z-index: 1090; + opacity: 0; + visibility: hidden; + transition: all 0.3s; +} + +.task-queue-overlay.show { + opacity: 1; + visibility: visible; +} + +/* Adjust notification toggle position to be above task toggle */ +.notif-sidebar-toggle { + top: calc(50% - 40px); +} + +/* Responsive for Task Queue */ +@media (max-width: 480px) { + .task-queue-sidebar { + width: 100vw; + max-width: 100vw; + } + + .task-queue-toggle { + top: auto; + bottom: 80px; + transform: translateY(0); + } + + .task-queue-toggle.hidden { + transform: translateX(100%); + } + + .notif-sidebar-toggle { + bottom: 150px; + } +} + diff --git a/ccw/src/templates/dashboard-css/10-cli.css b/ccw/src/templates/dashboard-css/10-cli.css index 39410022..743cb194 100644 --- a/ccw/src/templates/dashboard-css/10-cli.css +++ b/ccw/src/templates/dashboard-css/10-cli.css @@ -469,6 +469,22 @@ border-radius: 0.25rem; } +.history-source-dir { + font-size: 0.625rem; + font-weight: 500; + padding: 0.1875rem 0.5rem; + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + border-radius: 0.25rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .history-status { display: flex; align-items: center; @@ -2028,3 +2044,85 @@ color: hsl(var(--foreground)); border-color: hsl(var(--primary) / 0.3); } + +/* ======================================== + * Resume Session Styles + * ======================================== */ + +/* Resume Badge */ +.history-resume-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.1875rem 0.375rem; + background: hsl(var(--primary) / 0.12); + color: hsl(var(--primary)); + border-radius: 0.25rem; + font-size: 0.625rem; +} + +/* Resume Item Highlight */ +.history-item-resume { + border-left: 3px solid hsl(var(--primary) / 0.5); +} + +.history-item-resume:hover { + border-left-color: hsl(var(--primary)); +} + +/* History ID Display */ +.history-id { + font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; + font-size: 0.625rem; + color: hsl(var(--muted-foreground) / 0.7); +} + +/* Resume Button */ +.btn-resume { + color: hsl(var(--primary)); +} + +.btn-resume:hover { + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); +} + +/* Resume Modal */ +.resume-modal p { + font-size: 0.875rem; + color: hsl(var(--muted-foreground)); + margin-bottom: 1rem; +} + +.resume-prompt-input { + width: 100%; + padding: 0.75rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 0.875rem; + font-family: inherit; + resize: vertical; + min-height: 80px; + transition: all 0.2s ease; +} + +.resume-prompt-input:focus { + outline: none; + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15); +} + +.resume-prompt-input::placeholder { + color: hsl(var(--muted-foreground) / 0.7); +} + +.resume-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid hsl(var(--border)); +} diff --git a/ccw/src/templates/dashboard-js/components/cli-history.js b/ccw/src/templates/dashboard-js/components/cli-history.js index c2bc4382..326553b9 100644 --- a/ccw/src/templates/dashboard-js/components/cli-history.js +++ b/ccw/src/templates/dashboard-js/components/cli-history.js @@ -28,9 +28,13 @@ async function loadCliHistory(options = {}) { } } -async function loadExecutionDetail(executionId) { +async function loadExecutionDetail(executionId, sourceDir) { try { - const url = `/api/cli/execution?path=${encodeURIComponent(projectPath)}&id=${encodeURIComponent(executionId)}`; + // If sourceDir provided, use it to build the correct path + const basePath = sourceDir && sourceDir !== '.' + ? projectPath + '/' + sourceDir + : projectPath; + const url = `/api/cli/execution?path=${encodeURIComponent(basePath)}&id=${encodeURIComponent(executionId)}`; const response = await fetch(url); if (!response.ok) throw new Error('Execution not found'); return await response.json(); @@ -158,8 +162,8 @@ function renderToolFilter() { } // ========== Execution Detail Modal ========== -async function showExecutionDetail(executionId) { - const detail = await loadExecutionDetail(executionId); +async function showExecutionDetail(executionId, sourceDir) { + const detail = await loadExecutionDetail(executionId, sourceDir); if (!detail) { showRefreshToast('Execution not found', 'error'); return; diff --git a/ccw/src/templates/dashboard-js/components/global-notifications.js b/ccw/src/templates/dashboard-js/components/global-notifications.js index 03c8b057..238d4afd 100644 --- a/ccw/src/templates/dashboard-js/components/global-notifications.js +++ b/ccw/src/templates/dashboard-js/components/global-notifications.js @@ -1,80 +1,132 @@ // ========================================== -// GLOBAL NOTIFICATION SYSTEM +// GLOBAL NOTIFICATION SYSTEM - Right Sidebar // ========================================== -// Floating notification panel accessible from any view +// Right-side slide-out toolbar for notifications and quick actions /** - * Initialize global notification panel + * Initialize global notification sidebar */ function initGlobalNotifications() { - // Create FAB and panel if not exists - if (!document.getElementById('globalNotificationFab')) { - const fabHtml = ` -
- 🔔 - 0 -
- -
-
- 🔔 Notifications - -
-
-
-
-
- No notifications -

System events and task updates will appear here

+ +
+ + +
+ +
+
+
🔔
+
No notifications
+
System events and task updates will appear here
+ +
+ 🔔 + +
+ +
`; - + const container = document.createElement('div'); - container.id = 'globalNotificationContainer'; - container.innerHTML = fabHtml; + container.id = 'notifSidebarContainer'; + container.innerHTML = sidebarHtml; document.body.appendChild(container); } - + renderGlobalNotifications(); + updateGlobalNotifBadge(); } /** - * Toggle notification panel visibility + * Toggle notification sidebar visibility */ -function toggleGlobalNotifications() { +function toggleNotifSidebar() { isNotificationPanelVisible = !isNotificationPanelVisible; - const panel = document.getElementById('globalNotificationPanel'); - const fab = document.getElementById('globalNotificationFab'); - - if (panel && fab) { + const sidebar = document.getElementById('notifSidebar'); + const overlay = document.getElementById('notifSidebarOverlay'); + const toggle = document.getElementById('notifSidebarToggle'); + + if (sidebar && overlay && toggle) { if (isNotificationPanelVisible) { - panel.classList.add('show'); - fab.classList.add('active'); + sidebar.classList.add('open'); + overlay.classList.add('show'); + toggle.classList.add('hidden'); + // Mark notifications as read when opened + markAllNotificationsRead(); } else { - panel.classList.remove('show'); - fab.classList.remove('active'); + sidebar.classList.remove('open'); + overlay.classList.remove('show'); + toggle.classList.remove('hidden'); } } } +// Backward compatibility alias +function toggleGlobalNotifications() { + toggleNotifSidebar(); +} + /** * Add a global notification * @param {string} type - 'info', 'success', 'warning', 'error' * @param {string} message - Main notification message - * @param {string} details - Optional details - * @param {string} source - Optional source identifier (e.g., 'explorer', 'mcp') + * @param {string|object} details - Optional details (string or object) + * @param {string} source - Optional source identifier */ function addGlobalNotification(type, message, details = null, source = null) { + // Format details if it's an object + let formattedDetails = details; + if (details && typeof details === 'object') { + formattedDetails = formatNotificationJson(details); + } else if (typeof details === 'string') { + // Try to parse and format if it looks like JSON + const trimmed = details.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + const parsed = JSON.parse(trimmed); + formattedDetails = formatNotificationJson(parsed); + } catch (e) { + // Not valid JSON, use as-is + formattedDetails = details; + } + } + } + const notification = { id: Date.now(), type, message, - details, + details: formattedDetails, source, timestamp: new Date().toISOString(), read: false @@ -101,6 +153,69 @@ function addGlobalNotification(type, message, details = null, source = null) { } } +/** + * Format JSON object for notification display + * @param {Object} obj - Object to format + * @returns {string} HTML formatted string + */ +function formatNotificationJson(obj) { + if (obj === null || obj === undefined) return ''; + if (typeof obj !== 'object') return String(obj); + + // Handle arrays + if (Array.isArray(obj)) { + if (obj.length === 0) return '(empty array)'; + const items = obj.slice(0, 5).map((item, i) => { + const itemStr = typeof item === 'object' ? JSON.stringify(item) : String(item); + const truncated = itemStr.length > 60 ? itemStr.substring(0, 57) + '...' : itemStr; + return `
[${i}] ${escapeHtml(truncated)}
`; + }); + if (obj.length > 5) { + items.push(`
... +${obj.length - 5} more items
`); + } + return items.join(''); + } + + // Handle objects + const entries = Object.entries(obj); + if (entries.length === 0) return '(empty object)'; + + const lines = entries.slice(0, 8).map(([key, val]) => { + let valStr; + let valClass = 'json-value'; + + if (val === null) { + valStr = 'null'; + valClass = 'json-null'; + } else if (val === undefined) { + valStr = 'undefined'; + valClass = 'json-null'; + } else if (typeof val === 'boolean') { + valStr = val ? 'true' : 'false'; + valClass = 'json-bool'; + } else if (typeof val === 'number') { + valStr = String(val); + valClass = 'json-number'; + } else if (typeof val === 'object') { + valStr = JSON.stringify(val); + if (valStr.length > 50) valStr = valStr.substring(0, 47) + '...'; + valClass = 'json-object'; + } else { + valStr = String(val); + if (valStr.length > 60) valStr = valStr.substring(0, 57) + '...'; + valClass = 'json-string'; + } + + return `
${escapeHtml(key)}: ${escapeHtml(valStr)}
`; + }); + + if (entries.length > 8) { + lines.push(`
... +${entries.length - 8} more fields
`); + } + + return lines.join(''); +} + /** * Show a brief toast notification */ @@ -111,11 +226,11 @@ function showNotificationToast(notification) { 'warning': '⚠️', 'error': '❌' }[notification.type] || 'ℹ️'; - + // Remove existing toast const existing = document.querySelector('.notif-toast'); if (existing) existing.remove(); - + const toast = document.createElement('div'); toast.className = `notif-toast type-${notification.type}`; toast.innerHTML = ` @@ -123,10 +238,10 @@ function showNotificationToast(notification) { ${escapeHtml(notification.message)} `; document.body.appendChild(toast); - + // Animate in requestAnimationFrame(() => toast.classList.add('show')); - + // Auto-remove setTimeout(() => { toast.classList.remove('show'); @@ -135,41 +250,55 @@ function showNotificationToast(notification) { } /** - * Render notification list + * Render notification list in sidebar */ function renderGlobalNotifications() { - const listEl = document.getElementById('globalNotificationList'); - if (!listEl) return; - + const contentEl = document.getElementById('notifSidebarContent'); + if (!contentEl) return; + if (globalNotificationQueue.length === 0) { - listEl.innerHTML = ` -
- No notifications -

System events and task updates will appear here

+ contentEl.innerHTML = ` +
+
🔔
+
No notifications
+
System events and task updates will appear here
`; return; } - - listEl.innerHTML = globalNotificationQueue.map(notif => { + + contentEl.innerHTML = globalNotificationQueue.map(notif => { const typeIcon = { 'info': 'ℹ️', 'success': '✅', 'warning': '⚠️', 'error': '❌' }[notif.type] || 'ℹ️'; - + const time = formatNotifTime(notif.timestamp); - const sourceLabel = notif.source ? `${notif.source}` : ''; - + const sourceLabel = notif.source ? `${escapeHtml(notif.source)}` : ''; + + // Details may already be HTML formatted or plain text + let detailsHtml = ''; + if (notif.details) { + // Check if details is already HTML formatted (contains our json-* classes) + if (typeof notif.details === 'string' && notif.details.includes('class="json-')) { + detailsHtml = `
${notif.details}
`; + } else { + detailsHtml = `
${escapeHtml(String(notif.details))}
`; + } + } + return ` -
+
${typeIcon} - ${escapeHtml(notif.message)} - ${sourceLabel} +
+ ${escapeHtml(notif.message)} + ${sourceLabel} +
- ${notif.details ? `
${escapeHtml(notif.details)}
` : ''} + ${detailsHtml}
${time}
@@ -185,7 +314,7 @@ function formatNotifTime(timestamp) { const date = new Date(timestamp); const now = new Date(); const diff = now - date; - + if (diff < 60000) return 'Just now'; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; @@ -193,14 +322,22 @@ function formatNotifTime(timestamp) { } /** - * Update notification badge + * Update notification badge counts */ function updateGlobalNotifBadge() { - const badge = document.getElementById('globalNotifBadge'); - if (badge) { - const unreadCount = globalNotificationQueue.filter(n => !n.read).length; - badge.textContent = unreadCount; - badge.style.display = unreadCount > 0 ? 'flex' : 'none'; + const unreadCount = globalNotificationQueue.filter(n => !n.read).length; + + const countBadge = document.getElementById('notifCountBadge'); + const toggleBadge = document.getElementById('notifToggleBadge'); + + if (countBadge) { + countBadge.textContent = globalNotificationQueue.length; + countBadge.style.display = globalNotificationQueue.length > 0 ? 'inline-flex' : 'none'; + } + + if (toggleBadge) { + toggleBadge.textContent = unreadCount; + toggleBadge.style.display = unreadCount > 0 ? 'flex' : 'none'; } } @@ -210,7 +347,6 @@ function updateGlobalNotifBadge() { function clearGlobalNotifications() { globalNotificationQueue = []; - // Clear from localStorage if (typeof saveNotificationsToStorage === 'function') { saveNotificationsToStorage(); } @@ -225,7 +361,6 @@ function clearGlobalNotifications() { function markAllNotificationsRead() { globalNotificationQueue.forEach(n => n.read = true); - // Save to localStorage if (typeof saveNotificationsToStorage === 'function') { saveNotificationsToStorage(); } @@ -233,4 +368,3 @@ function markAllNotificationsRead() { renderGlobalNotifications(); updateGlobalNotifBadge(); } - diff --git a/ccw/src/templates/dashboard-js/components/task-queue-sidebar.js b/ccw/src/templates/dashboard-js/components/task-queue-sidebar.js new file mode 100644 index 00000000..07c704e2 --- /dev/null +++ b/ccw/src/templates/dashboard-js/components/task-queue-sidebar.js @@ -0,0 +1,265 @@ +// ========================================== +// TASK QUEUE SIDEBAR - Right Sidebar +// ========================================== +// Right-side slide-out toolbar for task queue management + +let isTaskQueueVisible = false; +let taskQueueData = []; + +/** + * Initialize task queue sidebar + */ +function initTaskQueueSidebar() { + // Create sidebar if not exists + if (!document.getElementById('taskQueueSidebar')) { + const sidebarHtml = ` +
+
+
+ 📋 + Task Queue + 0 +
+ +
+ +
+ + + +
+ +
+
+
📋
+
No tasks in queue
+
Active workflow tasks will appear here
+
+
+
+ +
+ 📋 + +
+ +
+ `; + + const container = document.createElement('div'); + container.id = 'taskQueueContainer'; + container.innerHTML = sidebarHtml; + document.body.appendChild(container); + } + + updateTaskQueueData(); + renderTaskQueue(); + updateTaskQueueBadge(); +} + +/** + * Toggle task queue sidebar visibility + */ +function toggleTaskQueueSidebar() { + isTaskQueueVisible = !isTaskQueueVisible; + const sidebar = document.getElementById('taskQueueSidebar'); + const overlay = document.getElementById('taskQueueOverlay'); + const toggle = document.getElementById('taskQueueToggle'); + + if (sidebar && overlay && toggle) { + if (isTaskQueueVisible) { + // Close notification sidebar if open + if (isNotificationPanelVisible && typeof toggleNotifSidebar === 'function') { + toggleNotifSidebar(); + } + sidebar.classList.add('open'); + overlay.classList.add('show'); + toggle.classList.add('hidden'); + // Refresh data when opened + updateTaskQueueData(); + renderTaskQueue(); + } else { + sidebar.classList.remove('open'); + overlay.classList.remove('show'); + toggle.classList.remove('hidden'); + } + } +} + +/** + * Update task queue data from workflow data + */ +function updateTaskQueueData() { + taskQueueData = []; + + // Collect tasks from active sessions + const activeSessions = workflowData.activeSessions || []; + + activeSessions.forEach(session => { + const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-'); + const sessionData = sessionDataStore[sessionKey] || session; + const tasks = sessionData.tasks || []; + + tasks.forEach(task => { + taskQueueData.push({ + ...task, + session_id: session.session_id, + session_type: session.type || 'workflow', + session_description: session.description || session.session_id + }); + }); + }); + + // Also check lite task sessions + Object.keys(liteTaskDataStore).forEach(key => { + const liteSession = liteTaskDataStore[key]; + if (liteSession && liteSession.tasks) { + liteSession.tasks.forEach(task => { + taskQueueData.push({ + ...task, + session_id: liteSession.session_id || key, + session_type: liteSession.type || 'lite', + session_description: liteSession.description || key + }); + }); + } + }); + + // Sort: in_progress first, then pending, then by timestamp + taskQueueData.sort((a, b) => { + const statusOrder = { 'in_progress': 0, 'pending': 1, 'completed': 2, 'skipped': 3 }; + const aOrder = statusOrder[a.status] ?? 99; + const bOrder = statusOrder[b.status] ?? 99; + if (aOrder !== bOrder) return aOrder - bOrder; + return 0; + }); +} + +/** + * Render task queue list + */ +function renderTaskQueue(filter = 'all') { + const contentEl = document.getElementById('taskQueueContent'); + if (!contentEl) return; + + let filteredTasks = taskQueueData; + if (filter !== 'all') { + filteredTasks = taskQueueData.filter(t => t.status === filter); + } + + if (filteredTasks.length === 0) { + contentEl.innerHTML = ` +
+
📋
+
${filter === 'all' ? 'No tasks in queue' : `No ${filter.replace('_', ' ')} tasks`}
+
Active workflow tasks will appear here
+
+ `; + return; + } + + contentEl.innerHTML = filteredTasks.map(task => { + const statusIcon = { + 'in_progress': '🔄', + 'pending': '⏳', + 'completed': '✅', + 'skipped': '⏭️' + }[task.status] || '📋'; + + const statusClass = task.status || 'pending'; + const taskId = task.task_id || task.id || 'N/A'; + const title = task.title || task.description || taskId; + + return ` +
+
+ ${statusIcon} +
+ ${escapeHtml(title)} + ${escapeHtml(taskId)} +
+
+
+ + ${escapeHtml(task.session_id)} + + ${escapeHtml(task.session_type)} +
+ ${task.scope ? `
${escapeHtml(task.scope)}
` : ''} +
+ `; + }).join(''); +} + +/** + * Filter task queue + */ +function filterTaskQueue(filter) { + // Update active filter button + document.querySelectorAll('.task-filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.filter === filter); + }); + renderTaskQueue(filter); +} + +/** + * Open task from queue (navigate to task detail) + */ +function openTaskFromQueue(sessionId, taskId) { + // Close sidebar + toggleTaskQueueSidebar(); + + // Try to find and open the task + const sessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-'); + + // Check if it's a lite task session + if (liteTaskDataStore[sessionKey]) { + if (typeof openTaskDrawerForLite === 'function') { + currentSessionDetailKey = sessionKey; + openTaskDrawerForLite(sessionId, taskId); + } + } else { + // Regular workflow task + if (typeof openTaskDrawer === 'function') { + currentDrawerTasks = sessionDataStore[sessionKey]?.tasks || []; + openTaskDrawer(taskId); + } + } +} + +/** + * Update task queue badge counts + */ +function updateTaskQueueBadge() { + const inProgressCount = taskQueueData.filter(t => t.status === 'in_progress').length; + const pendingCount = taskQueueData.filter(t => t.status === 'pending').length; + const activeCount = inProgressCount + pendingCount; + + const countBadge = document.getElementById('taskQueueCountBadge'); + const toggleBadge = document.getElementById('taskQueueToggleBadge'); + + if (countBadge) { + countBadge.textContent = taskQueueData.length; + countBadge.style.display = taskQueueData.length > 0 ? 'inline-flex' : 'none'; + } + + if (toggleBadge) { + toggleBadge.textContent = activeCount; + toggleBadge.style.display = activeCount > 0 ? 'flex' : 'none'; + // Highlight if there are in-progress tasks + toggleBadge.classList.toggle('has-active', inProgressCount > 0); + } +} + +/** + * Refresh task queue (called from external updates) + */ +function refreshTaskQueue() { + updateTaskQueueData(); + renderTaskQueue(); + updateTaskQueueBadge(); +} diff --git a/ccw/src/templates/dashboard-js/main.js b/ccw/src/templates/dashboard-js/main.js index 3b8d9c25..4772560a 100644 --- a/ccw/src/templates/dashboard-js/main.js +++ b/ccw/src/templates/dashboard-js/main.js @@ -20,6 +20,7 @@ document.addEventListener('DOMContentLoaded', async () => { try { initHookManager(); } catch (e) { console.error('Hook Manager init failed:', e); } try { initCliStatus(); } catch (e) { console.error('CLI Status init failed:', e); } try { initGlobalNotifications(); } catch (e) { console.error('Global notifications init failed:', e); } + try { initTaskQueueSidebar(); } catch (e) { console.error('Task queue sidebar init failed:', e); } try { initVersionCheck(); } catch (e) { console.error('Version check init failed:', e); } // Initialize real-time features (WebSocket + auto-refresh) @@ -71,6 +72,16 @@ document.addEventListener('DOMContentLoaded', async () => { if (typeof closeHookCreateModal === 'function') { closeHookCreateModal(); } + + // Close notification sidebar if open + if (isNotificationPanelVisible && typeof toggleNotifSidebar === 'function') { + toggleNotifSidebar(); + } + + // Close task queue sidebar if open + if (isTaskQueueVisible && typeof toggleTaskQueueSidebar === 'function') { + toggleTaskQueueSidebar(); + } } }); }); diff --git a/ccw/src/templates/dashboard-js/state.js b/ccw/src/templates/dashboard-js/state.js index b42abe68..68753190 100644 --- a/ccw/src/templates/dashboard-js/state.js +++ b/ccw/src/templates/dashboard-js/state.js @@ -229,6 +229,7 @@ function handleWorkflowEvent(event) { if (typeof updateStats === 'function') updateStats(); if (typeof updateBadges === 'function') updateBadges(); if (typeof updateCarousel === 'function') updateCarousel(); + if (typeof refreshTaskQueue === 'function') refreshTaskQueue(); // Re-render current view if needed if (currentView === 'sessions' && typeof renderSessions === 'function') { diff --git a/ccw/src/templates/dashboard-js/views/history.js b/ccw/src/templates/dashboard-js/views/history.js index dedeaf9b..e7c091c8 100644 --- a/ccw/src/templates/dashboard-js/views/history.js +++ b/ccw/src/templates/dashboard-js/views/history.js @@ -1,5 +1,5 @@ // CLI History View -// Standalone view for CLI execution history +// Standalone view for CLI execution history with resume support // ========== Rendering ========== async function renderCliHistoryView() { @@ -47,12 +47,21 @@ async function renderCliHistoryView() { exec.status === 'timeout' ? 'warning' : 'error'; var duration = formatDuration(exec.duration_ms); var timeAgo = getTimeAgo(new Date(exec.timestamp)); + var isResume = exec.prompt_preview && exec.prompt_preview.includes('[Resume session'); - historyHtml += '
' + + var sourceDirHtml = exec.sourceDir && exec.sourceDir !== '.' + ? ' ' + escapeHtml(exec.sourceDir) + '' + : ''; + + var resumeBadge = isResume ? '' : ''; + + historyHtml += '
' + '
' + '
' + '' + exec.tool + '' + '' + (exec.mode || 'analysis') + '' + + resumeBadge + + sourceDirHtml + '' + '' + exec.status + @@ -62,9 +71,13 @@ async function renderCliHistoryView() { '
' + ' ' + timeAgo + '' + ' ' + duration + '' + + ' ' + exec.id.split('-')[0] + '' + '
' + '
' + '
' + + '' + '' + @@ -130,3 +143,79 @@ async function refreshCliHistoryView() { renderCliHistoryView(); showRefreshToast('History refreshed', 'success'); } + +// ========== Resume Execution ========== +function promptResumeExecution(executionId, tool) { + var modalContent = '
' + + '

Resume this ' + tool + ' session with an optional continuation prompt:

' + + '' + + '
' + + '' + + '' + + '
' + + '
'; + + showModal('Resume Session', modalContent); +} + +async function executeResume(executionId, tool) { + var promptInput = document.getElementById('resumePromptInput'); + var additionalPrompt = promptInput ? promptInput.value.trim() : ''; + + closeModal(); + showRefreshToast('Resuming session...', 'info'); + + try { + var response = await fetch('/api/cli/resume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + executionId: executionId, + tool: tool, + prompt: additionalPrompt || undefined + }) + }); + + var result = await response.json(); + + if (result.success) { + showRefreshToast('Session resumed successfully', 'success'); + // Refresh history to show new execution + await refreshCliHistoryView(); + } else { + showRefreshToast('Resume failed: ' + (result.error || 'Unknown error'), 'error'); + } + } catch (err) { + console.error('Resume failed:', err); + showRefreshToast('Resume failed: ' + err.message, 'error'); + } +} + +async function resumeLastSession(tool) { + showRefreshToast('Resuming last ' + (tool || '') + ' session...', 'info'); + + try { + var response = await fetch('/api/cli/resume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tool: tool || undefined, + last: true + }) + }); + + var result = await response.json(); + + if (result.success) { + showRefreshToast('Session resumed successfully', 'success'); + await refreshCliHistoryView(); + } else { + showRefreshToast('Resume failed: ' + (result.error || 'Unknown error'), 'error'); + } + } catch (err) { + console.error('Resume failed:', err); + showRefreshToast('Resume failed: ' + err.message, 'error'); + } +} diff --git a/ccw/src/tools/cli-executor.ts b/ccw/src/tools/cli-executor.ts index ef9a7828..5545a7cb 100644 --- a/ccw/src/tools/cli-executor.ts +++ b/ccw/src/tools/cli-executor.ts @@ -6,8 +6,8 @@ import { z } from 'zod'; import type { ToolSchema, ToolResult } from '../types/tool.js'; import { spawn, ChildProcess } from 'child_process'; -import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; -import { join } from 'path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; // CLI History storage path const CLI_HISTORY_DIR = join(process.cwd(), '.workflow', '.cli-history'); @@ -81,7 +81,7 @@ async function checkToolAvailability(tool: string): Promise { }); let stdout = ''; - child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.stdout!.on('data', (data) => { stdout += data.toString(); }); child.on('close', (code) => { if (code === 0 && stdout.trim()) { @@ -113,16 +113,17 @@ function buildCommand(params: { model?: string; dir?: string; include?: string; -}): { command: string; args: string[] } { +}): { command: string; args: string[]; useStdin: boolean } { const { tool, prompt, mode = 'analysis', model, dir, include } = params; let command = tool; let args: string[] = []; + // Default to stdin for all tools to avoid escaping issues on Windows + let useStdin = true; switch (tool) { case 'gemini': - // gemini "[prompt]" [-m model] [--approval-mode yolo] [--include-directories] - args.push(prompt); + // gemini reads from stdin when no positional prompt is provided if (model) { args.push('-m', model); } @@ -135,8 +136,7 @@ function buildCommand(params: { break; case 'qwen': - // qwen "[prompt]" [-m model] [--approval-mode yolo] - args.push(prompt); + // qwen reads from stdin when no positional prompt is provided if (model) { args.push('-m', model); } @@ -149,7 +149,7 @@ function buildCommand(params: { break; case 'codex': - // codex exec [OPTIONS] "[prompt]" + // codex reads from stdin for prompt args.push('exec'); if (dir) { args.push('-C', dir); @@ -168,15 +168,14 @@ function buildCommand(params: { args.push('--add-dir', addDir); } } - // Prompt must be last (positional argument) - args.push(prompt); + // Prompt passed via stdin (default) break; default: throw new Error(`Unknown CLI tool: ${tool}`); } - return { command, args }; + return { command, args, useStdin }; } /** @@ -262,7 +261,7 @@ async function executeCliTool( } // Build command - const { command, args } = buildCommand({ + const { command, args, useStdin } = buildCommand({ tool, prompt, mode, @@ -298,15 +297,21 @@ async function executeCliTool( const child = spawn(command, spawnArgs, { cwd: workingDir, shell: isWindows, - stdio: ['ignore', 'pipe', 'pipe'] + stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); + // Write prompt to stdin if using stdin mode (for gemini/qwen) + if (useStdin && child.stdin) { + child.stdin.write(prompt); + child.stdin.end(); + } + let stdout = ''; let stderr = ''; let timedOut = false; // Handle stdout - child.stdout.on('data', (data) => { + child.stdout!.on('data', (data) => { const text = data.toString(); stdout += text; if (onOutput) { @@ -315,7 +320,7 @@ async function executeCliTool( }); // Handle stderr - child.stderr.on('data', (data) => { + child.stderr!.on('data', (data) => { const text = data.toString(); stderr += text; if (onOutput) { @@ -462,6 +467,39 @@ export async function handler(params: Record): Promise maxDepth) return; + + // Check if this directory has CLI history + const historyDir = join(dir, '.workflow', '.cli-history'); + if (existsSync(join(historyDir, 'index.json'))) { + historyDirs.push(historyDir); + } + + // Scan subdirectories + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.') && !ignoreDirs.has(entry.name)) { + scanDir(join(dir, entry.name), depth + 1); + } + } + } catch { + // Ignore permission errors + } + } + + scanDir(baseDir, 0); + return historyDirs; +} + /** * Get execution history */ @@ -469,33 +507,59 @@ export function getExecutionHistory(baseDir: string, options: { limit?: number; tool?: string | null; status?: string | null; + recursive?: boolean; } = {}): { total: number; count: number; - executions: HistoryIndex['executions']; + executions: (HistoryIndex['executions'][0] & { sourceDir?: string })[]; } { - const { limit = 50, tool = null, status = null } = options; + const { limit = 50, tool = null, status = null, recursive = false } = options; - const historyDir = join(baseDir, '.workflow', '.cli-history'); - const index = loadHistoryIndex(historyDir); + let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = []; + let totalCount = 0; - let executions = index.executions; + if (recursive) { + // Find all CLI history directories in subdirectories + const historyDirs = findCliHistoryDirs(baseDir); + + for (const historyDir of historyDirs) { + const index = loadHistoryIndex(historyDir); + totalCount += index.total_executions; + + // Add source directory info to each execution + const sourceDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, ''); + const relativeSource = relative(baseDir, sourceDir) || '.'; + + for (const exec of index.executions) { + allExecutions.push({ ...exec, sourceDir: relativeSource }); + } + } + + // Sort by timestamp (newest first) + allExecutions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + } else { + // Original behavior - single directory + const historyDir = join(baseDir, '.workflow', '.cli-history'); + const index = loadHistoryIndex(historyDir); + totalCount = index.total_executions; + allExecutions = index.executions; + } // Filter by tool if (tool) { - executions = executions.filter(e => e.tool === tool); + allExecutions = allExecutions.filter(e => e.tool === tool); } // Filter by status if (status) { - executions = executions.filter(e => e.status === status); + allExecutions = allExecutions.filter(e => e.status === status); } // Limit results - executions = executions.slice(0, limit); + const executions = allExecutions.slice(0, limit); return { - total: index.total_executions, + total: totalCount, count: executions.length, executions }; @@ -574,6 +638,181 @@ export async function getCliToolsStatus(): Promise void) | null +): Promise { + const { tool, executionId, last = false, prompt } = options; + + // For Codex, use native resume + if (tool === 'codex' || (!tool && !executionId)) { + return resumeCodexSession(baseDir, { last }, onOutput); + } + + // For Gemini/Qwen, load previous session and continue + let previousExecution: ExecutionRecord | null = null; + + if (executionId) { + previousExecution = getExecutionDetail(baseDir, executionId); + } else if (last) { + // Get the most recent execution for the specified tool (or any tool) + const history = getExecutionHistory(baseDir, { limit: 1, tool: tool || null }); + if (history.executions.length > 0) { + previousExecution = getExecutionDetail(baseDir, history.executions[0].id); + } + } + + if (!previousExecution) { + throw new Error('No previous session found to resume'); + } + + // Build continuation prompt with previous context + const continuationPrompt = buildContinuationPrompt(previousExecution, prompt); + + // Execute with the continuation prompt + return executeCliTool({ + tool: previousExecution.tool, + prompt: continuationPrompt, + mode: previousExecution.mode, + model: previousExecution.model !== 'default' ? previousExecution.model : undefined + }, onOutput); +} + +/** + * Resume Codex session using native command + */ +async function resumeCodexSession( + baseDir: string, + options: { last?: boolean }, + onOutput?: ((data: { type: string; data: string }) => void) | null +): Promise { + const { last = false } = options; + + // Check codex availability + const toolStatus = await checkToolAvailability('codex'); + if (!toolStatus.available) { + throw new Error('Codex CLI not available. Please ensure it is installed and in PATH.'); + } + + const args = ['resume']; + if (last) { + args.push('--last'); + } + + const isWindows = process.platform === 'win32'; + const startTime = Date.now(); + const executionId = `${Date.now()}-codex-resume`; + + return new Promise((resolve, reject) => { + const child = spawn('codex', args, { + cwd: baseDir, + shell: isWindows, + stdio: ['inherit', 'pipe', 'pipe'] // inherit stdin for interactive picker + }); + + let stdout = ''; + let stderr = ''; + + child.stdout!.on('data', (data) => { + const text = data.toString(); + stdout += text; + if (onOutput) { + onOutput({ type: 'stdout', data: text }); + } + }); + + child.stderr!.on('data', (data) => { + const text = data.toString(); + stderr += text; + if (onOutput) { + onOutput({ type: 'stderr', data: text }); + } + }); + + child.on('close', (code) => { + const duration = Date.now() - startTime; + const status: 'success' | 'error' = code === 0 ? 'success' : 'error'; + + const execution: ExecutionRecord = { + id: executionId, + timestamp: new Date(startTime).toISOString(), + tool: 'codex', + model: 'default', + mode: 'auto', + prompt: `[Resume session${last ? ' --last' : ''}]`, + status, + exit_code: code, + duration_ms: duration, + output: { + stdout: stdout.substring(0, 10240), + stderr: stderr.substring(0, 2048), + truncated: stdout.length > 10240 || stderr.length > 2048 + } + }; + + resolve({ + success: status === 'success', + execution, + stdout, + stderr + }); + }); + + child.on('error', (error) => { + reject(new Error(`Failed to resume codex session: ${error.message}`)); + }); + }); +} + +/** + * Build continuation prompt with previous conversation context + */ +function buildContinuationPrompt(previous: ExecutionRecord, additionalPrompt?: string): string { + const parts: string[] = []; + + // Add previous conversation context + parts.push('=== PREVIOUS CONVERSATION ==='); + parts.push(''); + parts.push('USER PROMPT:'); + parts.push(previous.prompt); + parts.push(''); + parts.push('ASSISTANT RESPONSE:'); + parts.push(previous.output.stdout || '[No output recorded]'); + parts.push(''); + parts.push('=== CONTINUATION ==='); + parts.push(''); + + if (additionalPrompt) { + parts.push(additionalPrompt); + } else { + parts.push('Continue from where we left off. What should we do next?'); + } + + return parts.join('\n'); +} + +/** + * Get latest execution for a specific tool + */ +export function getLatestExecution(baseDir: string, tool?: string): ExecutionRecord | null { + const history = getExecutionHistory(baseDir, { limit: 1, tool: tool || null }); + if (history.executions.length === 0) { + return null; + } + return getExecutionDetail(baseDir, history.executions[0].id); +} + // Export utility functions and tool definition for backward compatibility export { executeCliTool, checkToolAvailability };