feat: Implement resume strategy engine and session content parser

- Added `resume-strategy.ts` to determine optimal resume approaches including native, prompt concatenation, and hybrid modes.
- Introduced `determineResumeStrategy` function to evaluate various resume scenarios.
- Created utility functions for building context prefixes and formatting outputs in plain, YAML, and JSON formats.
- Added `session-content-parser.ts` to parse native CLI tool session files supporting Gemini/Qwen JSON and Codex JSONL formats.
- Implemented parsing logic for different session formats, including error handling for invalid lines.
- Provided functions to format conversations and extract user-assistant pairs from parsed sessions.
This commit is contained in:
catlog22
2025-12-13 20:29:19 +08:00
parent 32217f87fd
commit 52935d4b8e
26 changed files with 9387 additions and 86 deletions

View File

@@ -1,20 +1,24 @@
// CLI History Component
// Displays execution history with filtering, search, and delete
// Supports native session linking and full conversation parsing
// ========== CLI History State ==========
let cliExecutionHistory = [];
let cliHistoryFilter = null; // Filter by tool
let cliHistorySearch = ''; // Search query
let cliHistoryLimit = 50;
let showNativeOnly = false; // Filter to show only native-linked executions
// ========== Data Loading ==========
async function loadCliHistory(options = {}) {
try {
const { limit = cliHistoryLimit, tool = cliHistoryFilter, status = null } = options;
let url = `/api/cli/history?path=${encodeURIComponent(projectPath)}&limit=${limit}`;
// Use history-native endpoint to get native session info
let url = `/api/cli/history-native?path=${encodeURIComponent(projectPath)}&limit=${limit}`;
if (tool) url += `&tool=${tool}`;
if (status) url += `&status=${status}`;
if (cliHistorySearch) url += `&search=${encodeURIComponent(cliHistorySearch)}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load CLI history');
@@ -28,6 +32,32 @@ async function loadCliHistory(options = {}) {
}
}
// Load native session content for a specific execution
async function loadNativeSessionContent(executionId) {
try {
const url = `/api/cli/native-session?path=${encodeURIComponent(projectPath)}&id=${encodeURIComponent(executionId)}`;
const response = await fetch(url);
if (!response.ok) return null;
return await response.json();
} catch (err) {
console.error('Failed to load native session:', err);
return null;
}
}
// Load enriched conversation (CCW + Native merged)
async function loadEnrichedConversation(executionId) {
try {
const url = `/api/cli/enriched?path=${encodeURIComponent(projectPath)}&id=${encodeURIComponent(executionId)}`;
const response = await fetch(url);
if (!response.ok) return null;
return await response.json();
} catch (err) {
console.error('Failed to load enriched conversation:', err);
return null;
}
}
async function loadExecutionDetail(executionId, sourceDir) {
try {
// If sourceDir provided, use it to build the correct path
@@ -95,22 +125,39 @@ function renderCliHistory() {
? `<span class="cli-turn-badge">${exec.turn_count} turns</span>`
: '';
// Native session indicator
const hasNative = exec.hasNativeSession || exec.nativeSessionId;
const nativeBadge = hasNative
? `<span class="cli-native-badge" title="Native session: ${exec.nativeSessionId}">
<i data-lucide="file-json" class="w-3 h-3"></i>
</span>`
: '';
return `
<div class="cli-history-item">
<div class="cli-history-item ${hasNative ? 'has-native' : ''}">
<div class="cli-history-item-content" onclick="showExecutionDetail('${exec.id}')">
<div class="cli-history-item-header">
<span class="cli-tool-tag cli-tool-${exec.tool}">${exec.tool}</span>
${turnBadge}
<span class="cli-history-time">${timeAgo}</span>
<i data-lucide="${statusIcon}" class="w-3.5 h-3.5 ${statusClass}"></i>
<span class="cli-tool-tag cli-tool-${exec.tool}">${exec.tool.toUpperCase()}</span>
<span class="cli-mode-tag">${exec.mode || 'analysis'}</span>
<span class="cli-status-badge ${statusClass}">
<i data-lucide="${statusIcon}" class="w-3 h-3"></i> ${exec.status}
</span>
${nativeBadge}
</div>
<div class="cli-history-prompt">${escapeHtml(exec.prompt_preview)}</div>
<div class="cli-history-meta">
<span>${duration}</span>
<span>${exec.mode || 'analysis'}</span>
<span><i data-lucide="clock" class="w-3 h-3"></i> ${timeAgo}</span>
<span><i data-lucide="timer" class="w-3 h-3"></i> ${duration}</span>
<span><i data-lucide="hash" class="w-3 h-3"></i> ${exec.id.split('-')[0]}</span>
${turnBadge}
</div>
</div>
<div class="cli-history-actions">
${hasNative ? `
<button class="btn-icon" onclick="event.stopPropagation(); showNativeSessionDetail('${exec.id}')" title="View Native Session">
<i data-lucide="file-json" class="w-3.5 h-3.5"></i>
</button>
` : ''}
<button class="btn-icon" onclick="event.stopPropagation(); showExecutionDetail('${exec.id}')" title="View Details">
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
</button>
@@ -588,6 +635,188 @@ async function copyConcatenatedPrompt(executionId) {
}
}
// ========== Native Session Detail ==========
/**
* Show native session detail modal with full conversation content
*/
async function showNativeSessionDetail(executionId) {
// Load native session content
const nativeSession = await loadNativeSessionContent(executionId);
if (!nativeSession) {
showRefreshToast('Native session not found', 'error');
return;
}
// Build turns HTML from native session
const turnsHtml = nativeSession.turns && nativeSession.turns.length > 0
? nativeSession.turns.map((turn, idx) => {
const isLast = idx === nativeSession.turns.length - 1;
const roleIcon = turn.role === 'user' ? 'user' : 'bot';
const roleClass = turn.role === 'user' ? 'user' : 'assistant';
// Token info
const tokenInfo = turn.tokens
? `<span class="native-turn-tokens">
<i data-lucide="coins" class="w-3 h-3"></i>
${turn.tokens.total || 0} tokens
(in: ${turn.tokens.input || 0}, out: ${turn.tokens.output || 0}${turn.tokens.cached ? `, cached: ${turn.tokens.cached}` : ''})
</span>`
: '';
// Thoughts section
const thoughtsHtml = turn.thoughts && turn.thoughts.length > 0
? `<div class="native-thoughts-section">
<h5><i data-lucide="brain" class="w-3 h-3"></i> Thoughts</h5>
<ul class="native-thoughts-list">
${turn.thoughts.map(t => `<li>${escapeHtml(t)}</li>`).join('')}
</ul>
</div>`
: '';
// Tool calls section
const toolCallsHtml = turn.toolCalls && turn.toolCalls.length > 0
? `<div class="native-tools-section">
<h5><i data-lucide="wrench" class="w-3 h-3"></i> Tool Calls (${turn.toolCalls.length})</h5>
<div class="native-tools-list">
${turn.toolCalls.map(tc => `
<div class="native-tool-call">
<span class="native-tool-name">${escapeHtml(tc.name)}</span>
${tc.output ? `<pre class="native-tool-output">${escapeHtml(tc.output.substring(0, 500))}${tc.output.length > 500 ? '...' : ''}</pre>` : ''}
</div>
`).join('')}
</div>
</div>`
: '';
return `
<div class="native-turn ${roleClass} ${isLast ? 'latest' : ''}">
<div class="native-turn-header">
<span class="native-turn-role">
<i data-lucide="${roleIcon}" class="w-3.5 h-3.5"></i>
${turn.role === 'user' ? 'User' : 'Assistant'}
</span>
<span class="native-turn-number">Turn ${turn.turnNumber}</span>
${tokenInfo}
${isLast ? '<span class="native-turn-latest">Latest</span>' : ''}
</div>
<div class="native-turn-content">
<pre>${escapeHtml(turn.content)}</pre>
</div>
${thoughtsHtml}
${toolCallsHtml}
</div>
`;
}).join('')
: '<p class="text-muted-foreground">No conversation turns found</p>';
// Total tokens summary
const totalTokensHtml = nativeSession.totalTokens
? `<div class="native-tokens-summary">
<i data-lucide="bar-chart-3" class="w-4 h-4"></i>
<strong>Total Tokens:</strong>
${nativeSession.totalTokens.total || 0}
(Input: ${nativeSession.totalTokens.input || 0},
Output: ${nativeSession.totalTokens.output || 0}
${nativeSession.totalTokens.cached ? `, Cached: ${nativeSession.totalTokens.cached}` : ''})
</div>`
: '';
const modalContent = `
<div class="native-session-detail">
<div class="native-session-header">
<div class="native-session-info">
<span class="cli-tool-tag cli-tool-${nativeSession.tool}">${nativeSession.tool.toUpperCase()}</span>
${nativeSession.model ? `<span class="native-model"><i data-lucide="cpu" class="w-3 h-3"></i> ${nativeSession.model}</span>` : ''}
<span class="native-session-id"><i data-lucide="fingerprint" class="w-3 h-3"></i> ${nativeSession.sessionId}</span>
</div>
<div class="native-session-meta">
<span><i data-lucide="calendar" class="w-3 h-3"></i> ${new Date(nativeSession.startTime).toLocaleString()}</span>
${nativeSession.workingDir ? `<span><i data-lucide="folder" class="w-3 h-3"></i> ${nativeSession.workingDir}</span>` : ''}
${nativeSession.projectHash ? `<span><i data-lucide="hash" class="w-3 h-3"></i> ${nativeSession.projectHash.substring(0, 12)}...</span>` : ''}
</div>
</div>
${totalTokensHtml}
<div class="native-turns-container">
${turnsHtml}
</div>
<div class="native-session-actions">
<button class="btn btn-sm btn-outline" onclick="copyNativeSessionId('${nativeSession.sessionId}')">
<i data-lucide="copy" class="w-3.5 h-3.5"></i> Copy Session ID
</button>
<button class="btn btn-sm btn-outline" onclick="copyNativeSessionPath('${executionId}')">
<i data-lucide="file" class="w-3.5 h-3.5"></i> Copy File Path
</button>
<button class="btn btn-sm btn-outline" onclick="exportNativeSession('${executionId}')">
<i data-lucide="download" class="w-3.5 h-3.5"></i> Export JSON
</button>
</div>
</div>
`;
// Store for export
window._currentNativeSession = nativeSession;
showModal('Native Session Detail', modalContent, 'modal-lg');
}
/**
* Copy native session ID to clipboard
*/
async function copyNativeSessionId(sessionId) {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(sessionId);
showRefreshToast('Session ID copied', 'success');
} catch (err) {
showRefreshToast('Failed to copy', 'error');
}
}
}
/**
* Copy native session file path
*/
async function copyNativeSessionPath(executionId) {
// Find execution in history
const exec = cliExecutionHistory.find(e => e.id === executionId);
if (exec && exec.nativeSessionPath) {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(exec.nativeSessionPath);
showRefreshToast('File path copied', 'success');
} catch (err) {
showRefreshToast('Failed to copy', 'error');
}
}
} else {
showRefreshToast('Path not available', 'error');
}
}
/**
* Export native session as JSON file
*/
function exportNativeSession(executionId) {
const session = window._currentNativeSession;
if (!session) {
showRefreshToast('No session data', 'error');
return;
}
const blob = new Blob([JSON.stringify(session, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `native-session-${session.sessionId}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showRefreshToast('Session exported', 'success');
}
// ========== Helpers ==========
function formatDuration(ms) {
if (ms >= 60000) {

View File

@@ -12,6 +12,9 @@ let promptConcatFormat = localStorage.getItem('ccw-prompt-format') || 'plain'; /
let smartContextEnabled = localStorage.getItem('ccw-smart-context') === 'true';
let smartContextMaxFiles = parseInt(localStorage.getItem('ccw-smart-context-max-files') || '10', 10);
// Native Resume settings
let nativeResumeEnabled = localStorage.getItem('ccw-native-resume') !== 'false'; // default true
// ========== Initialization ==========
function initCliStatus() {
// Load CLI status on init
@@ -256,6 +259,19 @@ function renderCliStatus() {
</div>
<p class="cli-setting-desc">Auto-analyze prompt and add relevant file paths</p>
</div>
<div class="cli-setting-item">
<label class="cli-setting-label">
<i data-lucide="refresh-cw" class="w-3 h-3"></i>
Native Resume
</label>
<div class="cli-setting-control">
<label class="cli-toggle">
<input type="checkbox" ${nativeResumeEnabled ? 'checked' : ''} onchange="setNativeResumeEnabled(this.checked)">
<span class="cli-toggle-slider"></span>
</label>
</div>
<p class="cli-setting-desc">Use native tool resume (gemini -r, qwen --resume, codex resume)</p>
</div>
<div class="cli-setting-item ${!smartContextEnabled ? 'disabled' : ''}">
<label class="cli-setting-label">
<i data-lucide="files" class="w-3 h-3"></i>
@@ -326,6 +342,12 @@ function setSmartContextMaxFiles(max) {
showRefreshToast(`Smart Context max files set to ${max}`, 'success');
}
function setNativeResumeEnabled(enabled) {
nativeResumeEnabled = enabled;
localStorage.setItem('ccw-native-resume', enabled.toString());
showRefreshToast(`Native Resume ${enabled ? 'enabled' : 'disabled'}`, 'success');
}
async function refreshAllCliStatus() {
await Promise.all([loadCliToolStatus(), loadCodexLensStatus()]);
renderCliStatus();

View File

@@ -12,8 +12,8 @@ const HOOK_TEMPLATES = {
'ccw-notify': {
event: 'PostToolUse',
matcher: 'Write',
command: 'curl',
args: ['-s', '-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{"type":"summary_written","filePath":"$CLAUDE_FILE_PATHS"}', 'http://localhost:3456/api/hook'],
command: 'bash',
args: ['-c', 'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"file_written\\",\\"filePath\\":\\"$FILE_PATH\\"}" http://localhost:3456/api/hook || true'],
description: 'Notify CCW dashboard when files are written',
category: 'notification'
},
@@ -21,7 +21,7 @@ const HOOK_TEMPLATES = {
event: 'PostToolUse',
matcher: '',
command: 'bash',
args: ['-c', 'echo "[$(date)] Tool: $CLAUDE_TOOL_NAME, Files: $CLAUDE_FILE_PATHS" >> ~/.claude/tool-usage.log'],
args: ['-c', 'INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty"); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); echo "[$(date)] Tool: $TOOL, File: $FILE" >> ~/.claude/tool-usage.log'],
description: 'Log all tool executions to a file',
category: 'logging'
},
@@ -29,7 +29,7 @@ const HOOK_TEMPLATES = {
event: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do if [[ "$f" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$f" --fix 2>/dev/null || true; fi; done'],
args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); if [[ "$FILE" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$FILE" --fix 2>/dev/null || true; fi'],
description: 'Run ESLint on JavaScript/TypeScript files after write',
category: 'quality'
},
@@ -37,7 +37,7 @@ const HOOK_TEMPLATES = {
event: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do git add "$f" 2>/dev/null || true; done'],
args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); [ -n "$FILE" ] && git add "$FILE" 2>/dev/null || true'],
description: 'Automatically stage written files to git',
category: 'git'
},
@@ -45,7 +45,7 @@ const HOOK_TEMPLATES = {
event: 'PostToolUse',
matcher: 'Write|Edit',
command: 'bash',
args: ['-c', 'if [ -d ".codexlens" ] && [ -n "$CLAUDE_FILE_PATHS" ]; then python -m codexlens update $CLAUDE_FILE_PATHS --json 2>/dev/null || ~/.codexlens/venv/bin/python -m codexlens update $CLAUDE_FILE_PATHS --json 2>/dev/null || true; fi'],
args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -d ".codexlens" ] && [ -n "$FILE" ] && (python -m codexlens update "$FILE" --json 2>/dev/null || ~/.codexlens/venv/bin/python -m codexlens update "$FILE" --json 2>/dev/null || true)'],
description: 'Auto-update code index when files are written or edited',
category: 'indexing'
},
@@ -80,7 +80,7 @@ const HOOK_TEMPLATES = {
event: 'UserPromptSubmit',
matcher: '',
command: 'bash',
args: ['-c', 'ccw tool exec skill_context_loader \'{"keywords":"$SKILL_KEYWORDS","skills":"$SKILL_NAMES","prompt":"$CLAUDE_PROMPT"}\''],
args: ['-c', 'ccw tool exec skill_context_loader --stdin'],
description: 'Load SKILL context based on keyword matching in user prompt',
category: 'skill',
configurable: true,
@@ -93,10 +93,37 @@ const HOOK_TEMPLATES = {
event: 'UserPromptSubmit',
matcher: '',
command: 'bash',
args: ['-c', 'ccw tool exec skill_context_loader \'{"mode":"auto","prompt":"$CLAUDE_PROMPT"}\''],
args: ['-c', 'ccw tool exec skill_context_loader --stdin --mode auto'],
description: 'Auto-detect and load SKILL based on skill name in prompt',
category: 'skill',
configurable: false
},
'memory-file-read': {
event: 'PostToolUse',
matcher: 'Read|mcp__ccw-tools__read_file',
command: 'ccw',
args: ['memory', 'track', '--type', 'file', '--action', 'read', '--stdin'],
description: 'Track file reads to build context heatmap',
category: 'memory',
timeout: 5000
},
'memory-file-write': {
event: 'PostToolUse',
matcher: 'Write|Edit|mcp__ccw-tools__write_file|mcp__ccw-tools__edit_file',
command: 'ccw',
args: ['memory', 'track', '--type', 'file', '--action', 'write', '--stdin'],
description: 'Track file modifications to identify core modules',
category: 'memory',
timeout: 5000
},
'memory-prompt-track': {
event: 'UserPromptSubmit',
matcher: '',
command: 'ccw',
args: ['memory', 'track', '--type', 'topic', '--action', 'mention', '--stdin'],
description: 'Record user prompts for pattern analysis',
category: 'memory',
timeout: 5000
}
};
@@ -147,6 +174,33 @@ const WIZARD_TEMPLATES = {
configFields: [],
requiresSkillDiscovery: true,
customRenderer: 'renderSkillContextConfig'
},
'memory-setup': {
name: 'Memory Module Setup',
description: 'Configure automatic context tracking',
icon: 'brain',
options: [
{
id: 'file-read',
name: 'File Read Tracker',
description: 'Track file reads to build context heatmap',
templateId: 'memory-file-read'
},
{
id: 'file-write',
name: 'File Write Tracker',
description: 'Track file modifications to identify core modules',
templateId: 'memory-file-write'
},
{
id: 'prompts',
name: 'Prompt Tracker',
description: 'Record user prompts for pattern analysis',
templateId: 'memory-prompt-track'
}
],
configFields: [],
multiSelect: true
}
};
@@ -181,8 +235,60 @@ async function loadHookConfig() {
}
}
/**
* Convert internal hook format to Claude Code format
* Internal: { command, args, matcher, timeout }
* Claude Code: { matcher, hooks: [{ type: "command", command: "...", timeout }] }
*/
function convertToClaudeCodeFormat(hookData) {
// If already in correct format, return as-is
if (hookData.hooks && Array.isArray(hookData.hooks)) {
return hookData;
}
// Build command string from command + args
let commandStr = hookData.command || '';
if (hookData.args && Array.isArray(hookData.args)) {
// Join args, properly quoting if needed
const quotedArgs = hookData.args.map(arg => {
if (arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
return `"${arg.replace(/"/g, '\\"')}"`;
}
return arg;
});
commandStr = `${commandStr} ${quotedArgs.join(' ')}`.trim();
}
const converted = {
hooks: [{
type: 'command',
command: commandStr
}]
};
// Add matcher if present (not needed for UserPromptSubmit, Stop, etc.)
if (hookData.matcher) {
converted.matcher = hookData.matcher;
}
// Add timeout if present (in seconds for Claude Code)
if (hookData.timeout) {
converted.hooks[0].timeout = Math.ceil(hookData.timeout / 1000);
}
// Preserve replaceIndex for updates
if (hookData.replaceIndex !== undefined) {
converted.replaceIndex = hookData.replaceIndex;
}
return converted;
}
async function saveHook(scope, event, hookData) {
try {
// Convert to Claude Code format before saving
const convertedHookData = convertToClaudeCodeFormat(hookData);
const response = await fetch('/api/hooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -190,7 +296,7 @@ async function saveHook(scope, event, hookData) {
projectPath: projectPath,
scope: scope,
event: event,
hookData: hookData
hookData: convertedHookData
})
});
@@ -419,6 +525,11 @@ function openHookWizardModal(wizardId) {
wizardConfig[field.key] = field.default;
});
// Initialize selectedOptions for multi-select wizards
if (wizard.multiSelect) {
wizardConfig.selectedOptions = [];
}
const modal = document.getElementById('hookWizardModal');
if (modal) {
renderWizardModalContent();
@@ -445,8 +556,10 @@ function renderWizardModalContent() {
// Get translated wizard name and description
const wizardName = wizardId === 'memory-update' ? t('hook.wizard.memoryUpdate') :
wizardId === 'memory-setup' ? t('hook.wizard.memorySetup') :
wizardId === 'skill-context' ? t('hook.wizard.skillContext') : wizard.name;
const wizardDesc = wizardId === 'memory-update' ? t('hook.wizard.memoryUpdateDesc') :
wizardId === 'memory-setup' ? t('hook.wizard.memorySetupDesc') :
wizardId === 'skill-context' ? t('hook.wizard.skillContextDesc') : wizard.description;
// Helper to get translated option names
@@ -455,6 +568,11 @@ function renderWizardModalContent() {
if (optId === 'on-stop') return t('hook.wizard.onSessionEnd');
if (optId === 'periodic') return t('hook.wizard.periodicUpdate');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTracker');
if (optId === 'file-write') return t('hook.wizard.fileWriteTracker');
if (optId === 'prompts') return t('hook.wizard.promptTracker');
}
if (wizardId === 'skill-context') {
if (optId === 'keyword') return t('hook.wizard.keywordMatching');
if (optId === 'auto') return t('hook.wizard.autoDetection');
@@ -467,6 +585,11 @@ function renderWizardModalContent() {
if (optId === 'on-stop') return t('hook.wizard.onSessionEndDesc');
if (optId === 'periodic') return t('hook.wizard.periodicUpdateDesc');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTrackerDesc');
if (optId === 'file-write') return t('hook.wizard.fileWriteTrackerDesc');
if (optId === 'prompts') return t('hook.wizard.promptTrackerDesc');
}
if (wizardId === 'skill-context') {
if (optId === 'keyword') return t('hook.wizard.keywordMatchingDesc');
if (optId === 'auto') return t('hook.wizard.autoDetectionDesc');
@@ -508,9 +631,23 @@ function renderWizardModalContent() {
<!-- Trigger Type Selection -->
<div class="space-y-3">
<label class="block text-sm font-medium text-foreground">${t('hook.wizard.whenToTrigger')}</label>
<label class="block text-sm font-medium text-foreground">${wizard.multiSelect ? t('hook.wizard.selectTrackers') : t('hook.wizard.whenToTrigger')}</label>
<div class="grid grid-cols-1 gap-3">
${wizard.options.map(opt => `
${wizard.multiSelect ? wizard.options.map(opt => {
const isSelected = wizardConfig.selectedOptions?.includes(opt.id) || false;
return `
<label class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-all ${isSelected ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'}">
<input type="checkbox" name="wizardTrigger" value="${opt.id}"
${isSelected ? 'checked' : ''}
onchange="toggleWizardOption('${opt.id}')"
class="mt-1">
<div class="flex-1">
<span class="font-medium text-foreground">${escapeHtml(getOptionName(opt.id))}</span>
<p class="text-sm text-muted-foreground">${escapeHtml(getOptionDesc(opt.id))}</p>
</div>
</label>
`;
}).join('') : wizard.options.map(opt => `
<label class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-all ${selectedOption === opt.id ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'}">
<input type="radio" name="wizardTrigger" value="${opt.id}"
${selectedOption === opt.id ? 'checked' : ''}
@@ -609,6 +746,21 @@ function updateWizardTrigger(triggerId) {
renderWizardModalContent();
}
function toggleWizardOption(optionId) {
if (!wizardConfig.selectedOptions) {
wizardConfig.selectedOptions = [];
}
const index = wizardConfig.selectedOptions.indexOf(optionId);
if (index === -1) {
wizardConfig.selectedOptions.push(optionId);
} else {
wizardConfig.selectedOptions.splice(index, 1);
}
renderWizardModalContent();
}
function updateWizardConfig(key, value) {
wizardConfig[key] = value;
// Update command preview
@@ -793,6 +945,75 @@ async function submitHookWizard() {
if (!currentWizardTemplate) return;
const wizard = currentWizardTemplate;
const scope = document.querySelector('input[name="wizardScope"]:checked')?.value || 'project';
// Handle multi-select wizards
if (wizard.multiSelect) {
const selectedOptions = wizardConfig.selectedOptions || [];
if (selectedOptions.length === 0) {
showRefreshToast('Please select at least one option', 'error');
return;
}
// Install each selected hook (skip if already exists)
let installedCount = 0;
let skippedCount = 0;
for (const optionId of selectedOptions) {
const selectedOption = wizard.options.find(o => o.id === optionId);
if (!selectedOption) continue;
const baseTemplate = HOOK_TEMPLATES[selectedOption.templateId];
if (!baseTemplate) continue;
// Check if hook already exists
const existingHooks = scope === 'global'
? hookConfig.global?.hooks?.[baseTemplate.event] || []
: hookConfig.project?.hooks?.[baseTemplate.event] || [];
const hookList = Array.isArray(existingHooks) ? existingHooks : [existingHooks];
const alreadyExists = hookList.some(h => {
// Check by matcher and command
const existingMatcher = h.matcher || '';
const templateMatcher = baseTemplate.matcher || '';
const existingCmd = h.hooks?.[0]?.command || h.command || '';
const templateCmd = baseTemplate.command + ' ' + (baseTemplate.args || []).join(' ');
return existingMatcher === templateMatcher && existingCmd.includes(baseTemplate.command);
});
if (alreadyExists) {
skippedCount++;
continue;
}
const hookData = {
command: baseTemplate.command,
args: baseTemplate.args
};
if (baseTemplate.matcher) {
hookData.matcher = baseTemplate.matcher;
}
if (baseTemplate.timeout) {
hookData.timeout = baseTemplate.timeout;
}
await saveHook(scope, baseTemplate.event, hookData);
installedCount++;
}
closeHookWizardModal();
if (skippedCount > 0 && installedCount === 0) {
showRefreshToast(`All ${skippedCount} hook(s) already installed`, 'info');
} else if (skippedCount > 0) {
showRefreshToast(`Installed ${installedCount}, skipped ${skippedCount} (already exists)`, 'success');
}
return;
}
// Handle single-select wizards
const triggerType = wizardConfig.triggerType || wizard.options[0].id;
const selectedOption = wizard.options.find(o => o.id === triggerType);
if (!selectedOption) return;
@@ -800,7 +1021,6 @@ async function submitHookWizard() {
const baseTemplate = HOOK_TEMPLATES[selectedOption.templateId];
if (!baseTemplate) return;
const scope = document.querySelector('input[name="wizardScope"]:checked')?.value || 'project';
const command = generateWizardCommand();
const hookData = {

View File

@@ -104,6 +104,10 @@ function initNavigation() {
renderCliHistoryView();
} else if (currentView === 'hook-manager') {
renderHookManager();
} else if (currentView === 'memory') {
renderMemoryView();
} else if (currentView === 'prompt-history') {
renderPromptHistoryView();
}
});
});
@@ -128,6 +132,10 @@ function updateContentTitle() {
titleEl.textContent = t('title.cliHistory');
} else if (currentView === 'hook-manager') {
titleEl.textContent = t('title.hookManager');
} else if (currentView === 'memory') {
titleEl.textContent = t('title.memoryModule');
} else if (currentView === 'prompt-history') {
titleEl.textContent = t('title.promptHistory');
} else if (currentView === 'liteTasks') {
const names = { 'lite-plan': t('title.litePlanSessions'), 'lite-fix': t('title.liteFixSessions') };
titleEl.textContent = names[currentLiteType] || t('title.liteTasks');

View File

@@ -1,23 +1,30 @@
// ==========================================
// TASK QUEUE SIDEBAR - Right Sidebar
// ==========================================
// Right-side slide-out toolbar for task queue management
// Right-side slide-out toolbar for task queue and CLI execution management
let isTaskQueueSidebarVisible = false;
let taskQueueData = [];
let cliQueueData = [];
let currentQueueTab = 'tasks'; // 'tasks' | 'cli'
let cliCategoryFilter = 'all'; // 'all' | 'user' | 'internal' | 'insight'
/**
* Initialize task queue sidebar
*/
function initTaskQueueSidebar() {
// Create sidebar if not exists
// Create sidebar if not exists - check for container to handle partial creation
var existingContainer = document.getElementById('taskQueueContainer');
if (existingContainer) {
existingContainer.remove();
}
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>Execution Queue</span>
<span class="task-queue-count-badge" id="taskQueueCountBadge">0</span>
</div>
<button class="task-queue-close" onclick="toggleTaskQueueSidebar()" title="Close">
@@ -27,12 +34,28 @@ function initTaskQueueSidebar() {
</button>
</div>
<div class="task-queue-filters">
<div class="task-queue-tabs">
<button class="task-queue-tab active" data-tab="tasks" onclick="switchQueueTab('tasks')">
📋 Tasks <span class="tab-badge" id="tasksTabBadge">0</span>
</button>
<button class="task-queue-tab" data-tab="cli" onclick="switchQueueTab('cli')">
⚡ CLI <span class="tab-badge" id="cliTabBadge">0</span>
</button>
</div>
<div class="task-queue-filters" id="taskQueueFilters">
<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-filters" id="cliQueueFilters" style="display: none;">
<button class="cli-filter-btn active" data-filter="all" onclick="filterCliQueue('all')">All</button>
<button class="cli-filter-btn" data-filter="user" onclick="filterCliQueue('user')">🔵 User</button>
<button class="cli-filter-btn" data-filter="insight" onclick="filterCliQueue('insight')">🟣 Insight</button>
<button class="cli-filter-btn" data-filter="internal" onclick="filterCliQueue('internal')">🟢 Internal</button>
</div>
<div class="task-queue-content" id="taskQueueContent">
<div class="task-queue-empty-state">
<div class="task-queue-empty-icon">📋</div>
@@ -40,9 +63,17 @@ function initTaskQueueSidebar() {
<div class="task-queue-empty-hint">Active workflow tasks will appear here</div>
</div>
</div>
<div class="task-queue-content" id="cliQueueContent" style="display: none;">
<div class="task-queue-empty-state">
<div class="task-queue-empty-icon">⚡</div>
<div class="task-queue-empty-text">No CLI executions</div>
<div class="task-queue-empty-hint">CLI tool executions will appear here</div>
</div>
</div>
</div>
<div class="task-queue-toggle" id="taskQueueToggle" onclick="toggleTaskQueueSidebar()" title="Task Queue">
<div class="task-queue-toggle" id="taskQueueToggle" onclick="toggleTaskQueueSidebar()" title="Execution Queue">
<span class="toggle-icon">📋</span>
<span class="toggle-badge" id="taskQueueToggleBadge"></span>
</div>
@@ -57,7 +88,9 @@ function initTaskQueueSidebar() {
}
updateTaskQueueData();
updateCliQueueData();
renderTaskQueue();
renderCliQueue();
updateTaskQueueBadge();
}
@@ -96,8 +129,14 @@ function toggleTaskQueueSidebar() {
function updateTaskQueueData() {
taskQueueData = [];
// Safety check for global state
if (typeof workflowData === 'undefined' || !workflowData) {
console.warn('[TaskQueue] workflowData not initialized');
return;
}
// Collect tasks from active sessions
const activeSessions = workflowData.activeSessions || [];
var activeSessions = workflowData.activeSessions || [];
activeSessions.forEach(session => {
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
@@ -115,7 +154,10 @@ function updateTaskQueueData() {
});
// Also check lite task sessions
Object.keys(liteTaskDataStore).forEach(key => {
if (typeof liteTaskDataStore === 'undefined' || !liteTaskDataStore) {
return;
}
Object.keys(liteTaskDataStore).forEach(function(key) {
const liteSession = liteTaskDataStore[key];
if (liteSession && liteSession.tasks) {
liteSession.tasks.forEach(task => {
@@ -142,9 +184,13 @@ function updateTaskQueueData() {
/**
* Render task queue list
*/
function renderTaskQueue(filter = 'all') {
const contentEl = document.getElementById('taskQueueContent');
if (!contentEl) return;
function renderTaskQueue(filter) {
filter = filter || 'all';
var contentEl = document.getElementById('taskQueueContent');
if (!contentEl) {
console.warn('[TaskQueue] taskQueueContent element not found');
return;
}
let filteredTasks = taskQueueData;
if (filter !== 'all') {
@@ -260,6 +306,156 @@ function updateTaskQueueBadge() {
*/
function refreshTaskQueue() {
updateTaskQueueData();
updateCliQueueData();
renderTaskQueue();
renderCliQueue();
updateTaskQueueBadge();
}
/**
* Switch between Tasks and CLI tabs
*/
function switchQueueTab(tab) {
currentQueueTab = tab;
// Update tab button states
document.querySelectorAll('.task-queue-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
// Show/hide filters and content
const taskFilters = document.getElementById('taskQueueFilters');
const cliFilters = document.getElementById('cliQueueFilters');
const taskContent = document.getElementById('taskQueueContent');
const cliContent = document.getElementById('cliQueueContent');
if (tab === 'tasks') {
if (taskFilters) taskFilters.style.display = 'flex';
if (cliFilters) cliFilters.style.display = 'none';
if (taskContent) taskContent.style.display = 'block';
if (cliContent) cliContent.style.display = 'none';
} else {
if (taskFilters) taskFilters.style.display = 'none';
if (cliFilters) cliFilters.style.display = 'flex';
if (taskContent) taskContent.style.display = 'none';
if (cliContent) cliContent.style.display = 'block';
// Refresh CLI data when switching to CLI tab
updateCliQueueData();
renderCliQueue();
}
}
/**
* Update CLI queue data from API
*/
async function updateCliQueueData() {
try {
// Fetch recent CLI executions with category info
const response = await fetch(`/api/cli/history-native?path=${encodeURIComponent(projectPath)}&limit=20`);
if (!response.ok) return;
const data = await response.json();
cliQueueData = data.executions || [];
} catch (err) {
console.warn('[TaskQueue] Failed to load CLI queue:', err);
cliQueueData = [];
}
}
/**
* Render CLI queue list
*/
function renderCliQueue() {
const contentEl = document.getElementById('cliQueueContent');
if (!contentEl) return;
// Filter by category
let filtered = cliQueueData;
if (cliCategoryFilter !== 'all') {
filtered = cliQueueData.filter(exec => (exec.category || 'user') === cliCategoryFilter);
}
// Update tab badge
const cliTabBadge = document.getElementById('cliTabBadge');
if (cliTabBadge) {
cliTabBadge.textContent = cliQueueData.length;
cliTabBadge.style.display = cliQueueData.length > 0 ? 'inline' : 'none';
}
if (filtered.length === 0) {
const emptyText = cliCategoryFilter === 'all'
? 'No CLI executions'
: `No ${cliCategoryFilter} executions`;
contentEl.innerHTML = `
<div class="task-queue-empty-state">
<div class="task-queue-empty-icon">⚡</div>
<div class="task-queue-empty-text">${emptyText}</div>
<div class="task-queue-empty-hint">CLI tool executions will appear here</div>
</div>
`;
return;
}
contentEl.innerHTML = filtered.map(exec => {
const category = exec.category || 'user';
const categoryIcon = { user: '🔵', internal: '🟢', insight: '🟣' }[category] || '⚪';
const statusIcon = exec.status === 'success' ? '✅' : exec.status === 'timeout' ? '⏰' : '❌';
const timeAgo = getCliTimeAgo(new Date(exec.updated_at || exec.timestamp));
const promptPreview = (exec.prompt_preview || '').substring(0, 60);
return `
<div class="cli-queue-item category-${category}" onclick="showCliExecutionFromQueue('${escapeHtml(exec.id)}')">
<div class="cli-queue-item-header">
<span class="cli-queue-category-icon">${categoryIcon}</span>
<span class="cli-queue-tool-tag cli-tool-${exec.tool}">${exec.tool.toUpperCase()}</span>
<span class="cli-queue-status">${statusIcon}</span>
<span class="cli-queue-time">${timeAgo}</span>
</div>
<div class="cli-queue-prompt">${escapeHtml(promptPreview)}${promptPreview.length >= 60 ? '...' : ''}</div>
<div class="cli-queue-meta">
<span class="cli-queue-id">#${exec.id.split('-')[0]}</span>
${exec.turn_count > 1 ? `<span class="cli-queue-turns">${exec.turn_count} turns</span>` : ''}
${exec.hasNativeSession ? '<span class="cli-queue-native">📎</span>' : ''}
</div>
</div>
`;
}).join('');
}
/**
* Filter CLI queue by category
*/
function filterCliQueue(category) {
cliCategoryFilter = category;
// Update filter button states
document.querySelectorAll('.cli-filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === category);
});
renderCliQueue();
}
/**
* Show CLI execution detail from queue
*/
function showCliExecutionFromQueue(executionId) {
toggleTaskQueueSidebar();
// Use the showExecutionDetail function from cli-history.js if available
if (typeof showExecutionDetail === 'function') {
showExecutionDetail(executionId);
} else {
console.warn('[TaskQueue] showExecutionDetail not available');
}
}
/**
* Helper to format time ago
*/
function getCliTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}