mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat: add task queue sidebar and resume functionality for CLI sessions
- Implemented task queue sidebar for managing active tasks with filtering options. - Added functionality to close notification sidebar when opening task queue. - Enhanced CLI history view to support resuming previous sessions with optional prompts. - Updated CLI executor to handle resuming sessions for Codex, Gemini, and Qwen tools. - Introduced utility functions for finding CLI history directories recursively. - Improved task queue data management and rendering logic.
This commit is contained in:
@@ -51,6 +51,7 @@ ccw cli exec "<prompt>" --tool codex --mode auto --cd ./project --includeDirs ./
|
||||
|---------|-------------|
|
||||
| `ccw cli status` | Check CLI tools availability |
|
||||
| `ccw cli exec "<prompt>"` | Execute a CLI tool |
|
||||
| `ccw cli resume [id]` | Resume a previous session |
|
||||
| `ccw cli history` | Show execution history |
|
||||
| `ccw cli detail <id>` | Show execution detail |
|
||||
|
||||
@@ -121,22 +122,38 @@ ccw cli exec "<prompt>" --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 <image_file>` - 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 <path>` | `cd <path> &&` (prepend) | `-C <path>` |
|
||||
| `--includeDirs <dirs>` | `--include-directories <dirs>` | `--add-dir <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 <m>` | `-m <m>` |
|
||||
# Resume specific session by ID
|
||||
ccw cli resume <execution-id>
|
||||
|
||||
# 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 <tool>` | Filter by tool (gemini, qwen, codex) |
|
||||
| `--prompt <text>` | 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -154,12 +154,14 @@ export function run(argv: string[]): void {
|
||||
.option('--tool <tool>', 'CLI tool to use', 'gemini')
|
||||
.option('--mode <mode>', 'Execution mode: analysis, write, auto', 'analysis')
|
||||
.option('--model <model>', 'Model override')
|
||||
.option('--cd <path>', 'Working directory (-C for codex)')
|
||||
.option('--cd <path>', 'Working directory')
|
||||
.option('--includeDirs <dirs>', 'Additional directories (--include-directories for gemini/qwen, --add-dir for codex)')
|
||||
.option('--timeout <ms>', 'Timeout in milliseconds', '300000')
|
||||
.option('--no-stream', 'Disable streaming output')
|
||||
.option('--limit <n>', 'History limit')
|
||||
.option('--status <status>', 'Filter by status')
|
||||
.option('--last', 'Resume most recent session')
|
||||
.option('--prompt <text>', 'Additional prompt for resume continuation')
|
||||
.action((subcommand, args, options) => cliCommand(subcommand, args, options));
|
||||
|
||||
program.parse(argv);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 <prompt> 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 <id> Show execution detail'));
|
||||
console.log();
|
||||
@@ -243,13 +315,19 @@ export async function cliCommand(
|
||||
console.log(chalk.gray(' --tool <tool> Tool to use: gemini, qwen, codex (default: gemini)'));
|
||||
console.log(chalk.gray(' --mode <mode> Mode: analysis, write, auto (default: analysis)'));
|
||||
console.log(chalk.gray(' --model <model> Model override'));
|
||||
console.log(chalk.gray(' --cd <path> Working directory (-C for codex)'));
|
||||
console.log(chalk.gray(' --cd <path> Working directory'));
|
||||
console.log(chalk.gray(' --includeDirs <dirs> Additional directories (comma-separated)'));
|
||||
console.log(chalk.gray(' → gemini/qwen: --include-directories'));
|
||||
console.log(chalk.gray(' → codex: --add-dir'));
|
||||
console.log(chalk.gray(' --timeout <ms> 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> Tool filter (gemini, qwen, codex)'));
|
||||
console.log(chalk.gray(' --prompt <text> 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 <n> Number of results (default: 20)'));
|
||||
console.log(chalk.gray(' --tool <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 <id> --prompt "Continue..." # Resume specific session'));
|
||||
console.log(chalk.gray(' ccw cli history --tool gemini --limit 10'));
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<http.Ser
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
||||
const tool = url.searchParams.get('tool') || null;
|
||||
const status = url.searchParams.get('status') || null;
|
||||
const recursive = url.searchParams.get('recursive') !== 'false'; // Default true
|
||||
|
||||
const history = getExecutionHistory(projectPath, { limit, tool, status });
|
||||
const history = getExecutionHistory(projectPath, { limit, tool, status, recursive });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(history));
|
||||
return;
|
||||
@@ -763,6 +764,81 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Resume CLI Session
|
||||
if (pathname === '/api/cli/resume' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = `
|
||||
<div class="global-notif-fab" id="globalNotificationFab" onclick="toggleGlobalNotifications()" title="Notifications">
|
||||
<span class="fab-icon">🔔</span>
|
||||
<span class="fab-badge" id="globalNotifBadge">0</span>
|
||||
</div>
|
||||
|
||||
<div class="global-notif-panel" id="globalNotificationPanel">
|
||||
<div class="global-notif-header">
|
||||
<span class="global-notif-title">🔔 Notifications</span>
|
||||
<button class="global-notif-close" onclick="toggleGlobalNotifications()">×</button>
|
||||
</div>
|
||||
<div class="global-notif-actions">
|
||||
<button class="notif-action-btn" onclick="clearGlobalNotifications()">
|
||||
<span>🗑️</span> Clear All
|
||||
// Create sidebar if not exists
|
||||
if (!document.getElementById('notifSidebar')) {
|
||||
const sidebarHtml = `
|
||||
<div class="notif-sidebar" id="notifSidebar">
|
||||
<div class="notif-sidebar-header">
|
||||
<div class="notif-sidebar-title">
|
||||
<span class="notif-title-icon">🔔</span>
|
||||
<span>Notifications</span>
|
||||
<span class="notif-count-badge" id="notifCountBadge">0</span>
|
||||
</div>
|
||||
<button class="notif-sidebar-close" onclick="toggleNotifSidebar()" title="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="global-notif-list" id="globalNotificationList">
|
||||
<div class="global-notif-empty">
|
||||
<span>No notifications</span>
|
||||
<p>System events and task updates will appear here</p>
|
||||
|
||||
<div class="notif-sidebar-actions">
|
||||
<button class="notif-action-btn" onclick="markAllNotificationsRead()" title="Mark all read">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
<span>Mark Read</span>
|
||||
</button>
|
||||
<button class="notif-action-btn" onclick="clearGlobalNotifications()" title="Clear all">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||||
</svg>
|
||||
<span>Clear All</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="notif-sidebar-content" id="notifSidebarContent">
|
||||
<div class="notif-empty-state">
|
||||
<div class="notif-empty-icon">🔔</div>
|
||||
<div class="notif-empty-text">No notifications</div>
|
||||
<div class="notif-empty-hint">System events and task updates will appear here</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notif-sidebar-toggle" id="notifSidebarToggle" onclick="toggleNotifSidebar()" title="Notifications">
|
||||
<span class="toggle-icon">🔔</span>
|
||||
<span class="toggle-badge" id="notifToggleBadge"></span>
|
||||
</div>
|
||||
|
||||
<div class="notif-sidebar-overlay" id="notifSidebarOverlay" onclick="toggleNotifSidebar()"></div>
|
||||
`;
|
||||
|
||||
|
||||
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 '<span class="json-empty">(empty array)</span>';
|
||||
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 `<div class="json-array-item"><span class="json-index">[${i}]</span> ${escapeHtml(truncated)}</div>`;
|
||||
});
|
||||
if (obj.length > 5) {
|
||||
items.push(`<div class="json-more">... +${obj.length - 5} more items</div>`);
|
||||
}
|
||||
return items.join('');
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
const entries = Object.entries(obj);
|
||||
if (entries.length === 0) return '<span class="json-empty">(empty object)</span>';
|
||||
|
||||
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 `<div class="json-field"><span class="json-key">${escapeHtml(key)}:</span> <span class="${valClass}">${escapeHtml(valStr)}</span></div>`;
|
||||
});
|
||||
|
||||
if (entries.length > 8) {
|
||||
lines.push(`<div class="json-more">... +${entries.length - 8} more fields</div>`);
|
||||
}
|
||||
|
||||
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) {
|
||||
<span class="toast-message">${escapeHtml(notification.message)}</span>
|
||||
`;
|
||||
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 = `
|
||||
<div class="global-notif-empty">
|
||||
<span>No notifications</span>
|
||||
<p>System events and task updates will appear here</p>
|
||||
contentEl.innerHTML = `
|
||||
<div class="notif-empty-state">
|
||||
<div class="notif-empty-icon">🔔</div>
|
||||
<div class="notif-empty-text">No notifications</div>
|
||||
<div class="notif-empty-hint">System events and task updates will appear here</div>
|
||||
</div>
|
||||
`;
|
||||
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 ? `<span class="notif-source">${notif.source}</span>` : '';
|
||||
|
||||
const sourceLabel = notif.source ? `<span class="notif-source">${escapeHtml(notif.source)}</span>` : '';
|
||||
|
||||
// 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 = `<div class="notif-details-json">${notif.details}</div>`;
|
||||
} else {
|
||||
detailsHtml = `<div class="notif-details">${escapeHtml(String(notif.details))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="global-notif-item type-${notif.type} ${notif.read ? 'read' : ''}" data-id="${notif.id}">
|
||||
<div class="notif-item type-${notif.type} ${notif.read ? 'read' : ''}" data-id="${notif.id}">
|
||||
<div class="notif-item-header">
|
||||
<span class="notif-icon">${typeIcon}</span>
|
||||
<span class="notif-message">${escapeHtml(notif.message)}</span>
|
||||
${sourceLabel}
|
||||
<div class="notif-item-content">
|
||||
<span class="notif-message">${escapeHtml(notif.message)}</span>
|
||||
${sourceLabel}
|
||||
</div>
|
||||
</div>
|
||||
${notif.details ? `<div class="notif-details">${escapeHtml(notif.details)}</div>` : ''}
|
||||
${detailsHtml}
|
||||
<div class="notif-meta">
|
||||
<span class="notif-time">${time}</span>
|
||||
</div>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
265
ccw/src/templates/dashboard-js/components/task-queue-sidebar.js
Normal file
265
ccw/src/templates/dashboard-js/components/task-queue-sidebar.js
Normal file
@@ -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 = `
|
||||
<div class="task-queue-sidebar" id="taskQueueSidebar">
|
||||
<div class="task-queue-header">
|
||||
<div class="task-queue-title">
|
||||
<span class="task-queue-title-icon">📋</span>
|
||||
<span>Task Queue</span>
|
||||
<span class="task-queue-count-badge" id="taskQueueCountBadge">0</span>
|
||||
</div>
|
||||
<button class="task-queue-close" onclick="toggleTaskQueueSidebar()" title="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="task-queue-filters">
|
||||
<button class="task-filter-btn active" data-filter="all" onclick="filterTaskQueue('all')">All</button>
|
||||
<button class="task-filter-btn" data-filter="in_progress" onclick="filterTaskQueue('in_progress')">In Progress</button>
|
||||
<button class="task-filter-btn" data-filter="pending" onclick="filterTaskQueue('pending')">Pending</button>
|
||||
</div>
|
||||
|
||||
<div class="task-queue-content" id="taskQueueContent">
|
||||
<div class="task-queue-empty-state">
|
||||
<div class="task-queue-empty-icon">📋</div>
|
||||
<div class="task-queue-empty-text">No tasks in queue</div>
|
||||
<div class="task-queue-empty-hint">Active workflow tasks will appear here</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-queue-toggle" id="taskQueueToggle" onclick="toggleTaskQueueSidebar()" title="Task Queue">
|
||||
<span class="toggle-icon">📋</span>
|
||||
<span class="toggle-badge" id="taskQueueToggleBadge"></span>
|
||||
</div>
|
||||
|
||||
<div class="task-queue-overlay" id="taskQueueOverlay" onclick="toggleTaskQueueSidebar()"></div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="task-queue-empty-state">
|
||||
<div class="task-queue-empty-icon">📋</div>
|
||||
<div class="task-queue-empty-text">${filter === 'all' ? 'No tasks in queue' : `No ${filter.replace('_', ' ')} tasks`}</div>
|
||||
<div class="task-queue-empty-hint">Active workflow tasks will appear here</div>
|
||||
</div>
|
||||
`;
|
||||
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 `
|
||||
<div class="task-queue-item status-${statusClass}" data-task-id="${escapeHtml(taskId)}" onclick="openTaskFromQueue('${escapeHtml(task.session_id)}', '${escapeHtml(taskId)}')">
|
||||
<div class="task-queue-item-header">
|
||||
<span class="task-queue-status-icon">${statusIcon}</span>
|
||||
<div class="task-queue-item-info">
|
||||
<span class="task-queue-item-title">${escapeHtml(title)}</span>
|
||||
<span class="task-queue-item-id">${escapeHtml(taskId)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-queue-item-meta">
|
||||
<span class="task-queue-session-tag" title="${escapeHtml(task.session_description)}">
|
||||
${escapeHtml(task.session_id)}
|
||||
</span>
|
||||
<span class="task-queue-type-badge type-${task.session_type}">${escapeHtml(task.session_type)}</span>
|
||||
</div>
|
||||
${task.scope ? `<div class="task-queue-item-scope"><code>${escapeHtml(task.scope)}</code></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 += '<div class="history-item" onclick="showExecutionDetail(\'' + exec.id + '\')">' +
|
||||
var sourceDirHtml = exec.sourceDir && exec.sourceDir !== '.'
|
||||
? '<span class="history-source-dir"><i data-lucide="folder" class="w-3 h-3"></i> ' + escapeHtml(exec.sourceDir) + '</span>'
|
||||
: '';
|
||||
|
||||
var resumeBadge = isResume ? '<span class="history-resume-badge"><i data-lucide="rotate-ccw" class="w-3 h-3"></i></span>' : '';
|
||||
|
||||
historyHtml += '<div class="history-item' + (isResume ? ' history-item-resume' : '') + '" onclick="showExecutionDetail(\'' + exec.id + (exec.sourceDir ? '\',\'' + escapeHtml(exec.sourceDir) : '') + '\')">' +
|
||||
'<div class="history-item-main">' +
|
||||
'<div class="history-item-header">' +
|
||||
'<span class="history-tool-tag tool-' + exec.tool + '">' + exec.tool + '</span>' +
|
||||
'<span class="history-mode-tag">' + (exec.mode || 'analysis') + '</span>' +
|
||||
resumeBadge +
|
||||
sourceDirHtml +
|
||||
'<span class="history-status ' + statusClass + '">' +
|
||||
'<i data-lucide="' + statusIcon + '" class="w-3.5 h-3.5"></i>' +
|
||||
exec.status +
|
||||
@@ -62,9 +71,13 @@ async function renderCliHistoryView() {
|
||||
'<div class="history-item-meta">' +
|
||||
'<span class="history-time"><i data-lucide="clock" class="w-3 h-3"></i> ' + timeAgo + '</span>' +
|
||||
'<span class="history-duration"><i data-lucide="timer" class="w-3 h-3"></i> ' + duration + '</span>' +
|
||||
'<span class="history-id"><i data-lucide="hash" class="w-3 h-3"></i> ' + exec.id.split('-')[0] + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="history-item-actions">' +
|
||||
'<button class="btn-icon btn-resume" onclick="event.stopPropagation(); promptResumeExecution(\'' + exec.id + '\', \'' + exec.tool + '\')" title="Resume">' +
|
||||
'<i data-lucide="play" class="w-4 h-4"></i>' +
|
||||
'</button>' +
|
||||
'<button class="btn-icon" onclick="event.stopPropagation(); showExecutionDetail(\'' + exec.id + '\')" title="View Details">' +
|
||||
'<i data-lucide="eye" class="w-4 h-4"></i>' +
|
||||
'</button>' +
|
||||
@@ -130,3 +143,79 @@ async function refreshCliHistoryView() {
|
||||
renderCliHistoryView();
|
||||
showRefreshToast('History refreshed', 'success');
|
||||
}
|
||||
|
||||
// ========== Resume Execution ==========
|
||||
function promptResumeExecution(executionId, tool) {
|
||||
var modalContent = '<div class="resume-modal">' +
|
||||
'<p>Resume this ' + tool + ' session with an optional continuation prompt:</p>' +
|
||||
'<textarea id="resumePromptInput" class="resume-prompt-input" placeholder="Continue from where we left off... (optional)" rows="3"></textarea>' +
|
||||
'<div class="resume-modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeModal()">Cancel</button>' +
|
||||
'<button class="btn btn-primary" onclick="executeResume(\'' + executionId + '\', \'' + tool + '\')">' +
|
||||
'<i data-lucide="play" class="w-4 h-4"></i> Resume' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ToolAvailability> {
|
||||
});
|
||||
|
||||
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<string, unknown>): Promise<ToolResu
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all CLI history directories in a directory tree (max depth 3)
|
||||
*/
|
||||
function findCliHistoryDirs(baseDir: string, maxDepth: number = 3): string[] {
|
||||
const historyDirs: string[] = [];
|
||||
const ignoreDirs = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', 'venv', '.venv']);
|
||||
|
||||
function scanDir(dir: string, depth: number) {
|
||||
if (depth > 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<Record<string, ToolAvailabili
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a CLI session
|
||||
* - Codex: Uses native `codex resume` command
|
||||
* - Gemini/Qwen: Loads previous conversation and continues
|
||||
*/
|
||||
export async function resumeCliSession(
|
||||
baseDir: string,
|
||||
options: {
|
||||
tool?: string;
|
||||
executionId?: string;
|
||||
last?: boolean;
|
||||
prompt?: string;
|
||||
},
|
||||
onOutput?: ((data: { type: string; data: string }) => void) | null
|
||||
): Promise<ExecutionOutput> {
|
||||
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<ExecutionOutput> {
|
||||
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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user