mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-09 02:24:11 +08:00
feat: Dashboard 增强 - MCP管理器、Review Session 和 UI 改进
- 添加 MCP Manager 组件,支持服务器状态管理 - 增强 Review Session 视图,添加 conflict/review tabs - 新增 _conflict_tab.js 和 _review_tab.js 组件 - 改进 carousel、tabs-other 等组件 - 大量 CSS 样式更新和优化 - home.js 添加新功能支持 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,8 @@ const MODULE_FILES = [
|
||||
'components/_exp_helpers.js',
|
||||
'components/tabs-other.js',
|
||||
'components/tabs-context.js',
|
||||
'components/_conflict_tab.js',
|
||||
'components/_review_tab.js',
|
||||
'components/task-drawer-core.js',
|
||||
'components/task-drawer-renderers.js',
|
||||
'components/flowchart.js',
|
||||
|
||||
112
ccw/src/templates/dashboard-js/components/_conflict_tab.js
Normal file
112
ccw/src/templates/dashboard-js/components/_conflict_tab.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// ==========================================
|
||||
// Conflict Resolution Tab
|
||||
// ==========================================
|
||||
|
||||
async function loadAndRenderConflictTab(session, contentArea) {
|
||||
contentArea.innerHTML = '<div class="tab-loading">Loading conflict resolution...</div>';
|
||||
|
||||
try {
|
||||
if (window.SERVER_MODE && session.path) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=conflict`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderConflictCards(data.conflictResolution);
|
||||
return;
|
||||
}
|
||||
}
|
||||
contentArea.innerHTML = `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">⚖️</div>
|
||||
<div class="empty-title">No Conflict Resolution</div>
|
||||
<div class="empty-text">No conflict-resolution-decisions.json found for this session.</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
contentArea.innerHTML = `<div class="tab-error">Failed to load conflict resolution: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderConflictCards(conflictResolution) {
|
||||
if (!conflictResolution) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">⚖️</div>
|
||||
<div class="empty-title">No Conflict Resolution</div>
|
||||
<div class="empty-text">No conflict decisions found for this session.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let cards = [];
|
||||
|
||||
// Header info
|
||||
cards.push(`
|
||||
<div class="conflict-tab-header">
|
||||
<h3>⚖️ Conflict Resolution Decisions</h3>
|
||||
<div class="conflict-meta-info">
|
||||
<span>Session: <strong>${escapeHtml(conflictResolution.session_id || 'N/A')}</strong></span>
|
||||
${conflictResolution.resolved_at ? `<span>Resolved: <strong>${formatDate(conflictResolution.resolved_at)}</strong></span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// User Decisions as cards
|
||||
if (conflictResolution.user_decisions && Object.keys(conflictResolution.user_decisions).length > 0) {
|
||||
const decisions = Object.entries(conflictResolution.user_decisions);
|
||||
|
||||
cards.push(`<div class="conflict-section-title">🎯 User Decisions (${decisions.length})</div>`);
|
||||
cards.push('<div class="conflict-cards-grid">');
|
||||
|
||||
for (const [key, decision] of decisions) {
|
||||
const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
cards.push(`
|
||||
<div class="conflict-card decision-card">
|
||||
<div class="conflict-card-header">
|
||||
<span class="conflict-card-label">${escapeHtml(label)}</span>
|
||||
</div>
|
||||
<div class="conflict-card-choice">
|
||||
<span class="choice-label">Choice:</span>
|
||||
<span class="choice-value">${escapeHtml(decision.choice || 'N/A')}</span>
|
||||
</div>
|
||||
${decision.description ? `
|
||||
<div class="conflict-card-desc">${escapeHtml(decision.description)}</div>
|
||||
` : ''}
|
||||
${decision.implications && decision.implications.length > 0 ? `
|
||||
<div class="conflict-card-implications">
|
||||
<span class="impl-label">Implications:</span>
|
||||
<ul>
|
||||
${decision.implications.map(impl => `<li>${escapeHtml(impl)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
cards.push('</div>');
|
||||
}
|
||||
|
||||
// Resolved Conflicts as cards
|
||||
if (conflictResolution.resolved_conflicts && conflictResolution.resolved_conflicts.length > 0) {
|
||||
cards.push(`<div class="conflict-section-title">✅ Resolved Conflicts (${conflictResolution.resolved_conflicts.length})</div>`);
|
||||
cards.push('<div class="conflict-cards-grid">');
|
||||
|
||||
for (const conflict of conflictResolution.resolved_conflicts) {
|
||||
cards.push(`
|
||||
<div class="conflict-card resolved-card">
|
||||
<div class="conflict-card-header">
|
||||
<span class="conflict-card-id">${escapeHtml(conflict.id || 'N/A')}</span>
|
||||
<span class="conflict-category-tag">${escapeHtml(conflict.category || 'General')}</span>
|
||||
</div>
|
||||
<div class="conflict-card-brief">${escapeHtml(conflict.brief || '')}</div>
|
||||
<div class="conflict-card-strategy">
|
||||
<span class="strategy-label">Strategy:</span>
|
||||
<span class="strategy-tag">${escapeHtml(conflict.strategy || 'N/A')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
cards.push('</div>');
|
||||
}
|
||||
|
||||
return `<div class="conflict-tab-content">${cards.join('')}</div>`;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Exploration helpers loaded
|
||||
|
||||
// Helper: Render exploration field with smart type detection
|
||||
function renderExpField(label, value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
640
ccw/src/templates/dashboard-js/components/_review_tab.js
Normal file
640
ccw/src/templates/dashboard-js/components/_review_tab.js
Normal file
@@ -0,0 +1,640 @@
|
||||
// ==========================================
|
||||
// Enhanced Review Tab with Multi-Select & Preview
|
||||
// ==========================================
|
||||
|
||||
// Review tab state
|
||||
let reviewTabState = {
|
||||
allFindings: [],
|
||||
filteredFindings: [],
|
||||
selectedFindings: new Set(),
|
||||
currentFilters: {
|
||||
dimension: 'all',
|
||||
severities: new Set(),
|
||||
search: ''
|
||||
},
|
||||
sortConfig: {
|
||||
field: 'severity',
|
||||
order: 'desc'
|
||||
},
|
||||
previewFinding: null,
|
||||
sessionPath: null,
|
||||
sessionId: null
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Main Review Tab Render
|
||||
// ==========================================
|
||||
|
||||
function renderReviewContent(review) {
|
||||
if (!review || !review.dimensions) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<div class="empty-title">No Review Data</div>
|
||||
<div class="empty-text">No review findings in .review/</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Convert dimensions object to flat findings array
|
||||
const findings = [];
|
||||
let findingIndex = 0;
|
||||
|
||||
Object.entries(review.dimensions).forEach(([dim, rawFindings]) => {
|
||||
let dimFindings = [];
|
||||
if (Array.isArray(rawFindings)) {
|
||||
dimFindings = rawFindings;
|
||||
} else if (rawFindings && typeof rawFindings === 'object') {
|
||||
if (Array.isArray(rawFindings.findings)) {
|
||||
dimFindings = rawFindings.findings;
|
||||
}
|
||||
}
|
||||
|
||||
dimFindings.forEach(f => {
|
||||
findings.push({
|
||||
id: f.id || `finding-${findingIndex++}`,
|
||||
title: f.title || 'Finding',
|
||||
description: f.description || '',
|
||||
severity: (f.severity || 'medium').toLowerCase(),
|
||||
dimension: dim,
|
||||
category: f.category || '',
|
||||
file: f.file || '',
|
||||
line: f.line || '',
|
||||
code_context: f.code_context || f.snippet || '',
|
||||
recommendations: f.recommendations || (f.recommendation ? [f.recommendation] : []),
|
||||
root_cause: f.root_cause || '',
|
||||
impact: f.impact || '',
|
||||
references: f.references || [],
|
||||
metadata: f.metadata || {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (findings.length === 0) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<div class="empty-title">No Findings</div>
|
||||
<div class="empty-text">No review findings found.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Store findings in state
|
||||
reviewTabState.allFindings = findings;
|
||||
reviewTabState.filteredFindings = [...findings];
|
||||
reviewTabState.selectedFindings.clear();
|
||||
reviewTabState.previewFinding = null;
|
||||
|
||||
// Get dimensions for tabs
|
||||
const dimensions = [...new Set(findings.map(f => f.dimension))];
|
||||
|
||||
// Count by severity
|
||||
const severityCounts = {
|
||||
critical: findings.filter(f => f.severity === 'critical').length,
|
||||
high: findings.filter(f => f.severity === 'high').length,
|
||||
medium: findings.filter(f => f.severity === 'medium').length,
|
||||
low: findings.filter(f => f.severity === 'low').length
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="review-enhanced-container">
|
||||
<!-- Header with Stats & Controls -->
|
||||
<div class="review-header-bar">
|
||||
<div class="review-severity-stats">
|
||||
<span class="severity-stat critical" onclick="filterReviewBySeverity('critical')" title="Filter Critical">
|
||||
🔴 ${severityCounts.critical}
|
||||
</span>
|
||||
<span class="severity-stat high" onclick="filterReviewBySeverity('high')" title="Filter High">
|
||||
🟠 ${severityCounts.high}
|
||||
</span>
|
||||
<span class="severity-stat medium" onclick="filterReviewBySeverity('medium')" title="Filter Medium">
|
||||
🟡 ${severityCounts.medium}
|
||||
</span>
|
||||
<span class="severity-stat low" onclick="filterReviewBySeverity('low')" title="Filter Low">
|
||||
🟢 ${severityCounts.low}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="review-search-box">
|
||||
<input type="text"
|
||||
id="reviewSearchInput"
|
||||
placeholder="Search findings..."
|
||||
oninput="onReviewSearch(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="review-selection-controls">
|
||||
<span class="selection-counter" id="reviewSelectionCounter">0 selected</span>
|
||||
<button class="btn-mini" onclick="selectAllReviewFindings()">Select All</button>
|
||||
<button class="btn-mini" onclick="selectVisibleReviewFindings()">Select Visible</button>
|
||||
<button class="btn-mini" onclick="clearReviewSelection()">Clear</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-export-fix" id="exportFixBtn" onclick="exportReviewFixJson()" disabled>
|
||||
🔧 Export Fix JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="review-filter-bar">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Severity:</span>
|
||||
<div class="filter-chips">
|
||||
<label class="filter-chip" id="filter-critical">
|
||||
<input type="checkbox" onchange="toggleReviewSeverityFilter('critical')">
|
||||
<span>Critical</span>
|
||||
</label>
|
||||
<label class="filter-chip" id="filter-high">
|
||||
<input type="checkbox" onchange="toggleReviewSeverityFilter('high')">
|
||||
<span>High</span>
|
||||
</label>
|
||||
<label class="filter-chip" id="filter-medium">
|
||||
<input type="checkbox" onchange="toggleReviewSeverityFilter('medium')">
|
||||
<span>Medium</span>
|
||||
</label>
|
||||
<label class="filter-chip" id="filter-low">
|
||||
<input type="checkbox" onchange="toggleReviewSeverityFilter('low')">
|
||||
<span>Low</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Sort:</span>
|
||||
<select id="reviewSortSelect" class="sort-select" onchange="sortReviewFindings()">
|
||||
<option value="severity">By Severity</option>
|
||||
<option value="dimension">By Dimension</option>
|
||||
<option value="file">By File</option>
|
||||
</select>
|
||||
<button class="btn-sort-order" id="reviewSortOrderBtn" onclick="toggleReviewSortOrder()">
|
||||
<span id="reviewSortOrderIcon">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-mini" onclick="resetReviewFilters()">Reset Filters</button>
|
||||
</div>
|
||||
|
||||
<!-- Dimension Tabs -->
|
||||
<div class="review-dimension-tabs">
|
||||
<button class="dim-tab active" data-dimension="all" onclick="filterReviewByDimension('all')">
|
||||
All (${findings.length})
|
||||
</button>
|
||||
${dimensions.map(dim => `
|
||||
<button class="dim-tab" data-dimension="${dim}" onclick="filterReviewByDimension('${dim}')">
|
||||
${escapeHtml(dim)} (${findings.filter(f => f.dimension === dim).length})
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Split Panel: List + Preview -->
|
||||
<div class="review-split-panel">
|
||||
<!-- Left: Findings List -->
|
||||
<div class="review-findings-panel">
|
||||
<div class="findings-list-header">
|
||||
<span id="reviewFindingsCount">${findings.length} findings</span>
|
||||
</div>
|
||||
<div class="review-findings-list" id="reviewFindingsList">
|
||||
${renderReviewFindingsList(findings)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Preview Panel -->
|
||||
<div class="review-preview-panel" id="reviewPreviewPanel">
|
||||
<div class="preview-empty-state">
|
||||
<div class="preview-icon">👆</div>
|
||||
<div class="preview-text">Click on a finding to preview details</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Findings List Rendering
|
||||
// ==========================================
|
||||
|
||||
function renderReviewFindingsList(findings) {
|
||||
if (findings.length === 0) {
|
||||
return `
|
||||
<div class="findings-empty">
|
||||
<span class="empty-icon">✨</span>
|
||||
<span>No findings match your filters</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return findings.map(finding => `
|
||||
<div class="review-finding-item ${finding.severity} ${reviewTabState.selectedFindings.has(finding.id) ? 'selected' : ''}"
|
||||
data-finding-id="${finding.id}"
|
||||
onclick="previewReviewFinding('${finding.id}')">
|
||||
<input type="checkbox"
|
||||
class="finding-checkbox"
|
||||
${reviewTabState.selectedFindings.has(finding.id) ? 'checked' : ''}
|
||||
onclick="toggleReviewFindingSelection('${finding.id}', event)">
|
||||
<div class="finding-content">
|
||||
<div class="finding-top-row">
|
||||
<span class="severity-badge ${finding.severity}">${finding.severity}</span>
|
||||
<span class="dimension-badge">${escapeHtml(finding.dimension)}</span>
|
||||
</div>
|
||||
<div class="finding-title">${escapeHtml(finding.title)}</div>
|
||||
${finding.file ? `<div class="finding-file">📄 ${escapeHtml(finding.file)}${finding.line ? ':' + finding.line : ''}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Preview Panel Rendering
|
||||
// ==========================================
|
||||
|
||||
function previewReviewFinding(findingId) {
|
||||
const finding = reviewTabState.allFindings.find(f => f.id === findingId);
|
||||
if (!finding) return;
|
||||
|
||||
reviewTabState.previewFinding = finding;
|
||||
|
||||
// Update active state in list
|
||||
document.querySelectorAll('.review-finding-item').forEach(item => {
|
||||
item.classList.toggle('previewing', item.dataset.findingId === findingId);
|
||||
});
|
||||
|
||||
const previewPanel = document.getElementById('reviewPreviewPanel');
|
||||
if (!previewPanel) return;
|
||||
|
||||
previewPanel.innerHTML = `
|
||||
<div class="preview-content">
|
||||
<div class="preview-header">
|
||||
<div class="preview-badges">
|
||||
<span class="severity-badge ${finding.severity}">${finding.severity}</span>
|
||||
<span class="dimension-badge">${escapeHtml(finding.dimension)}</span>
|
||||
${finding.category ? `<span class="category-badge">${escapeHtml(finding.category)}</span>` : ''}
|
||||
</div>
|
||||
<button class="btn-select-finding ${reviewTabState.selectedFindings.has(finding.id) ? 'selected' : ''}"
|
||||
onclick="toggleReviewFindingSelection('${finding.id}', event)">
|
||||
${reviewTabState.selectedFindings.has(finding.id) ? '✓ Selected' : '+ Select for Fix'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="preview-title">${escapeHtml(finding.title)}</h3>
|
||||
|
||||
${finding.file ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">📄 Location</div>
|
||||
<div class="preview-location">
|
||||
<code>${escapeHtml(finding.file)}${finding.line ? ':' + finding.line : ''}</code>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">📝 Description</div>
|
||||
<div class="preview-description">${escapeHtml(finding.description)}</div>
|
||||
</div>
|
||||
|
||||
${finding.code_context ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">💻 Code Context</div>
|
||||
<pre class="preview-code">${escapeHtml(finding.code_context)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.recommendations && finding.recommendations.length > 0 ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">✅ Recommendations</div>
|
||||
<ul class="preview-recommendations">
|
||||
${finding.recommendations.map(r => `<li>${escapeHtml(r)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.root_cause ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">🔍 Root Cause</div>
|
||||
<div class="preview-root-cause">${escapeHtml(finding.root_cause)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.impact ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">⚠️ Impact</div>
|
||||
<div class="preview-impact">${escapeHtml(finding.impact)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.references && finding.references.length > 0 ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">🔗 References</div>
|
||||
<ul class="preview-references">
|
||||
${finding.references.map(ref => {
|
||||
const isUrl = ref.startsWith('http');
|
||||
return `<li>${isUrl ? `<a href="${ref}" target="_blank">${ref}</a>` : ref}</li>`;
|
||||
}).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.metadata && Object.keys(finding.metadata).length > 0 ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">ℹ️ Metadata</div>
|
||||
<div class="preview-metadata">
|
||||
${Object.entries(finding.metadata).map(([key, value]) => `
|
||||
<div class="metadata-item">
|
||||
<span class="meta-key">${escapeHtml(key)}:</span>
|
||||
<span class="meta-value">${escapeHtml(String(value))}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Selection Management
|
||||
// ==========================================
|
||||
|
||||
function toggleReviewFindingSelection(findingId, event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (reviewTabState.selectedFindings.has(findingId)) {
|
||||
reviewTabState.selectedFindings.delete(findingId);
|
||||
} else {
|
||||
reviewTabState.selectedFindings.add(findingId);
|
||||
}
|
||||
|
||||
updateReviewSelectionUI();
|
||||
|
||||
// Update preview panel button if this finding is being previewed
|
||||
if (reviewTabState.previewFinding && reviewTabState.previewFinding.id === findingId) {
|
||||
previewReviewFinding(findingId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllReviewFindings() {
|
||||
reviewTabState.allFindings.forEach(f => reviewTabState.selectedFindings.add(f.id));
|
||||
updateReviewSelectionUI();
|
||||
}
|
||||
|
||||
function selectVisibleReviewFindings() {
|
||||
reviewTabState.filteredFindings.forEach(f => reviewTabState.selectedFindings.add(f.id));
|
||||
updateReviewSelectionUI();
|
||||
}
|
||||
|
||||
function selectReviewBySeverity(severity) {
|
||||
reviewTabState.allFindings
|
||||
.filter(f => f.severity === severity)
|
||||
.forEach(f => reviewTabState.selectedFindings.add(f.id));
|
||||
updateReviewSelectionUI();
|
||||
}
|
||||
|
||||
function clearReviewSelection() {
|
||||
reviewTabState.selectedFindings.clear();
|
||||
updateReviewSelectionUI();
|
||||
}
|
||||
|
||||
function updateReviewSelectionUI() {
|
||||
// Update counter
|
||||
const counter = document.getElementById('reviewSelectionCounter');
|
||||
if (counter) {
|
||||
counter.textContent = `${reviewTabState.selectedFindings.size} selected`;
|
||||
}
|
||||
|
||||
// Update export button
|
||||
const exportBtn = document.getElementById('exportFixBtn');
|
||||
if (exportBtn) {
|
||||
exportBtn.disabled = reviewTabState.selectedFindings.size === 0;
|
||||
}
|
||||
|
||||
// Update checkbox states in list
|
||||
document.querySelectorAll('.review-finding-item').forEach(item => {
|
||||
const findingId = item.dataset.findingId;
|
||||
const isSelected = reviewTabState.selectedFindings.has(findingId);
|
||||
item.classList.toggle('selected', isSelected);
|
||||
const checkbox = item.querySelector('.finding-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = isSelected;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Filtering & Sorting
|
||||
// ==========================================
|
||||
|
||||
function filterReviewByDimension(dimension) {
|
||||
reviewTabState.currentFilters.dimension = dimension;
|
||||
|
||||
// Update tab active state
|
||||
document.querySelectorAll('.dim-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.dimension === dimension);
|
||||
});
|
||||
|
||||
applyReviewFilters();
|
||||
}
|
||||
|
||||
function filterReviewBySeverity(severity) {
|
||||
// Toggle the severity filter
|
||||
if (reviewTabState.currentFilters.severities.has(severity)) {
|
||||
reviewTabState.currentFilters.severities.delete(severity);
|
||||
} else {
|
||||
reviewTabState.currentFilters.severities.add(severity);
|
||||
}
|
||||
|
||||
// Update filter chip UI
|
||||
const filterChip = document.getElementById(`filter-${severity}`);
|
||||
if (filterChip) {
|
||||
filterChip.classList.toggle('active', reviewTabState.currentFilters.severities.has(severity));
|
||||
const checkbox = filterChip.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = reviewTabState.currentFilters.severities.has(severity);
|
||||
}
|
||||
}
|
||||
|
||||
applyReviewFilters();
|
||||
}
|
||||
|
||||
function toggleReviewSeverityFilter(severity) {
|
||||
filterReviewBySeverity(severity);
|
||||
}
|
||||
|
||||
function onReviewSearch(searchText) {
|
||||
reviewTabState.currentFilters.search = searchText.toLowerCase();
|
||||
applyReviewFilters();
|
||||
}
|
||||
|
||||
function applyReviewFilters() {
|
||||
reviewTabState.filteredFindings = reviewTabState.allFindings.filter(finding => {
|
||||
// Dimension filter
|
||||
if (reviewTabState.currentFilters.dimension !== 'all') {
|
||||
if (finding.dimension !== reviewTabState.currentFilters.dimension) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Severity filter (multi-select)
|
||||
if (reviewTabState.currentFilters.severities.size > 0) {
|
||||
if (!reviewTabState.currentFilters.severities.has(finding.severity)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (reviewTabState.currentFilters.search) {
|
||||
const searchText = `${finding.title} ${finding.description} ${finding.file} ${finding.category}`.toLowerCase();
|
||||
if (!searchText.includes(reviewTabState.currentFilters.search)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
sortReviewFindings();
|
||||
}
|
||||
|
||||
function sortReviewFindings() {
|
||||
const sortBy = document.getElementById('reviewSortSelect')?.value || 'severity';
|
||||
reviewTabState.sortConfig.field = sortBy;
|
||||
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
|
||||
reviewTabState.filteredFindings.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortBy === 'severity') {
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
} else if (sortBy === 'dimension') {
|
||||
comparison = a.dimension.localeCompare(b.dimension);
|
||||
} else if (sortBy === 'file') {
|
||||
comparison = (a.file || '').localeCompare(b.file || '');
|
||||
}
|
||||
|
||||
return reviewTabState.sortConfig.order === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
renderFilteredReviewFindings();
|
||||
}
|
||||
|
||||
function toggleReviewSortOrder() {
|
||||
reviewTabState.sortConfig.order = reviewTabState.sortConfig.order === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
const icon = document.getElementById('reviewSortOrderIcon');
|
||||
if (icon) {
|
||||
icon.textContent = reviewTabState.sortConfig.order === 'asc' ? '↑' : '↓';
|
||||
}
|
||||
|
||||
sortReviewFindings();
|
||||
}
|
||||
|
||||
function resetReviewFilters() {
|
||||
// Reset state
|
||||
reviewTabState.currentFilters.dimension = 'all';
|
||||
reviewTabState.currentFilters.severities.clear();
|
||||
reviewTabState.currentFilters.search = '';
|
||||
reviewTabState.sortConfig.field = 'severity';
|
||||
reviewTabState.sortConfig.order = 'desc';
|
||||
|
||||
// Reset UI
|
||||
document.querySelectorAll('.dim-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.dimension === 'all');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.filter-chip').forEach(chip => {
|
||||
chip.classList.remove('active');
|
||||
const checkbox = chip.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('reviewSearchInput');
|
||||
if (searchInput) searchInput.value = '';
|
||||
|
||||
const sortSelect = document.getElementById('reviewSortSelect');
|
||||
if (sortSelect) sortSelect.value = 'severity';
|
||||
|
||||
const sortIcon = document.getElementById('reviewSortOrderIcon');
|
||||
if (sortIcon) sortIcon.textContent = '↓';
|
||||
|
||||
// Re-apply filters
|
||||
reviewTabState.filteredFindings = [...reviewTabState.allFindings];
|
||||
sortReviewFindings();
|
||||
}
|
||||
|
||||
function renderFilteredReviewFindings() {
|
||||
const listContainer = document.getElementById('reviewFindingsList');
|
||||
const countEl = document.getElementById('reviewFindingsCount');
|
||||
|
||||
if (listContainer) {
|
||||
listContainer.innerHTML = renderReviewFindingsList(reviewTabState.filteredFindings);
|
||||
}
|
||||
|
||||
if (countEl) {
|
||||
countEl.textContent = `${reviewTabState.filteredFindings.length} findings`;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Export Fix JSON
|
||||
// ==========================================
|
||||
|
||||
function exportReviewFixJson() {
|
||||
if (reviewTabState.selectedFindings.size === 0) {
|
||||
showToast('Please select at least one finding to export', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedFindingsData = reviewTabState.allFindings.filter(f =>
|
||||
reviewTabState.selectedFindings.has(f.id)
|
||||
);
|
||||
|
||||
const session = sessionDataStore[currentSessionDetailKey];
|
||||
const sessionId = session?.session_id || 'unknown';
|
||||
const exportId = `fix-${Date.now()}`;
|
||||
|
||||
const exportData = {
|
||||
export_id: exportId,
|
||||
export_timestamp: new Date().toISOString(),
|
||||
review_id: `review-${sessionId}`,
|
||||
session_id: sessionId,
|
||||
findings_count: selectedFindingsData.length,
|
||||
findings: selectedFindingsData.map(f => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
severity: f.severity,
|
||||
dimension: f.dimension,
|
||||
category: f.category || 'uncategorized',
|
||||
file: f.file,
|
||||
line: f.line,
|
||||
code_context: f.code_context || null,
|
||||
recommendations: f.recommendations || [],
|
||||
root_cause: f.root_cause || null
|
||||
}))
|
||||
};
|
||||
|
||||
// Convert to JSON and download
|
||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const filename = `fix-export-${exportId}.json`;
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Show success notification
|
||||
const severityCounts = {
|
||||
critical: selectedFindingsData.filter(f => f.severity === 'critical').length,
|
||||
high: selectedFindingsData.filter(f => f.severity === 'high').length,
|
||||
medium: selectedFindingsData.filter(f => f.severity === 'medium').length,
|
||||
low: selectedFindingsData.filter(f => f.severity === 'low').length
|
||||
};
|
||||
|
||||
showToast(`Exported ${selectedFindingsData.length} findings for fixing (Critical: ${severityCounts.critical}, High: ${severityCounts.high}, Medium: ${severityCounts.medium}, Low: ${severityCounts.low})`, 'success');
|
||||
}
|
||||
@@ -118,6 +118,14 @@ function renderCarouselSlide(direction = 'none') {
|
||||
}
|
||||
|
||||
const session = carouselSessions[carouselIndex];
|
||||
const sessionType = session.type || 'workflow';
|
||||
|
||||
// Use simplified view for review sessions
|
||||
if (sessionType === 'review') {
|
||||
renderReviewCarouselSlide(container, session, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = session.tasks || [];
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
|
||||
@@ -126,7 +134,6 @@ function renderCarouselSlide(direction = 'none') {
|
||||
const progress = taskCount > 0 ? Math.round((completed / taskCount) * 100) : 0;
|
||||
|
||||
// Get session type badge
|
||||
const sessionType = session.type || 'workflow';
|
||||
const typeBadgeClass = getSessionTypeBadgeClass(sessionType);
|
||||
|
||||
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
@@ -217,6 +224,48 @@ function renderCarouselSlide(direction = 'none') {
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified carousel slide for review sessions
|
||||
function renderReviewCarouselSlide(container, session, direction) {
|
||||
const typeBadgeClass = getSessionTypeBadgeClass('review');
|
||||
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const animClass = direction === 'left' ? 'carousel-slide-left' :
|
||||
direction === 'right' ? 'carousel-slide-right' : 'carousel-fade-in';
|
||||
const createdTime = session.created_at ? formatRelativeTime(session.created_at) : '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="carousel-slide ${animClass} h-full">
|
||||
<div class="session-card h-full p-3 cursor-pointer hover:bg-hover/30 transition-colors"
|
||||
onclick="showSessionDetailPage('${sessionKey}')">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded ${typeBadgeClass}">review</span>
|
||||
</div>
|
||||
<h4 class="font-semibold text-foreground text-sm line-clamp-2 mb-3" title="${escapeHtml(session.session_id)}">${escapeHtml(session.session_id)}</h4>
|
||||
|
||||
<!-- Simple info -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">🔍</div>
|
||||
<div class="text-xs text-muted-foreground">Click to view findings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-auto text-xs text-muted-foreground">
|
||||
📅 ${createdTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Store session data for navigation
|
||||
if (!sessionDataStore[sessionKey]) {
|
||||
sessionDataStore[sessionKey] = session;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionTypeBadgeClass(type) {
|
||||
const classes = {
|
||||
'tdd': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
let mcpConfig = null;
|
||||
let mcpAllProjects = {};
|
||||
let mcpCurrentProjectServers = {};
|
||||
let mcpCreateMode = 'form'; // 'form' or 'json'
|
||||
|
||||
// ========== Initialization ==========
|
||||
function initMcpManager() {
|
||||
@@ -189,13 +190,21 @@ function openMcpCreateModal() {
|
||||
const modal = document.getElementById('mcpCreateModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
// Reset to form mode
|
||||
mcpCreateMode = 'form';
|
||||
switchMcpCreateTab('form');
|
||||
// Clear form
|
||||
document.getElementById('mcpServerName').value = '';
|
||||
document.getElementById('mcpServerCommand').value = '';
|
||||
document.getElementById('mcpServerArgs').value = '';
|
||||
document.getElementById('mcpServerEnv').value = '';
|
||||
// Clear JSON input
|
||||
document.getElementById('mcpServerJson').value = '';
|
||||
document.getElementById('mcpJsonPreview').classList.add('hidden');
|
||||
// Focus on name input
|
||||
document.getElementById('mcpServerName').focus();
|
||||
// Setup JSON input listener
|
||||
setupMcpJsonListener();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +215,139 @@ function closeMcpCreateModal() {
|
||||
}
|
||||
}
|
||||
|
||||
function switchMcpCreateTab(tab) {
|
||||
mcpCreateMode = tab;
|
||||
const formMode = document.getElementById('mcpFormMode');
|
||||
const jsonMode = document.getElementById('mcpJsonMode');
|
||||
const tabForm = document.getElementById('mcpTabForm');
|
||||
const tabJson = document.getElementById('mcpTabJson');
|
||||
|
||||
if (tab === 'form') {
|
||||
formMode.classList.remove('hidden');
|
||||
jsonMode.classList.add('hidden');
|
||||
tabForm.classList.add('active');
|
||||
tabJson.classList.remove('active');
|
||||
} else {
|
||||
formMode.classList.add('hidden');
|
||||
jsonMode.classList.remove('hidden');
|
||||
tabForm.classList.remove('active');
|
||||
tabJson.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function setupMcpJsonListener() {
|
||||
const jsonInput = document.getElementById('mcpServerJson');
|
||||
if (jsonInput && !jsonInput.hasAttribute('data-listener-attached')) {
|
||||
jsonInput.setAttribute('data-listener-attached', 'true');
|
||||
jsonInput.addEventListener('input', () => {
|
||||
updateMcpJsonPreview();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseMcpJsonConfig(jsonText) {
|
||||
if (!jsonText.trim()) {
|
||||
return { servers: {}, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
let servers = {};
|
||||
|
||||
// Support multiple formats:
|
||||
// 1. {"servers": {...}} format (claude desktop style)
|
||||
// 2. {"mcpServers": {...}} format (claude.json style)
|
||||
// 3. {"serverName": {command, args}} format (direct server config)
|
||||
// 4. {command, args} format (single server without name)
|
||||
|
||||
if (parsed.servers && typeof parsed.servers === 'object') {
|
||||
servers = parsed.servers;
|
||||
} else if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
|
||||
servers = parsed.mcpServers;
|
||||
} else if (parsed.command && typeof parsed.command === 'string') {
|
||||
// Single server without name - will prompt for name
|
||||
servers = { '__unnamed__': parsed };
|
||||
} else {
|
||||
// Check if all values are server configs (have 'command' property)
|
||||
const isDirectServerConfig = Object.values(parsed).every(
|
||||
v => v && typeof v === 'object' && v.command
|
||||
);
|
||||
if (isDirectServerConfig && Object.keys(parsed).length > 0) {
|
||||
servers = parsed;
|
||||
} else {
|
||||
return { servers: {}, error: 'Invalid MCP server JSON format' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each server config
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
if (!config.command || typeof config.command !== 'string') {
|
||||
return { servers: {}, error: `Server "${name}" missing required "command" field` };
|
||||
}
|
||||
if (config.args && !Array.isArray(config.args)) {
|
||||
return { servers: {}, error: `Server "${name}" has invalid "args" (must be array)` };
|
||||
}
|
||||
if (config.env && typeof config.env !== 'object') {
|
||||
return { servers: {}, error: `Server "${name}" has invalid "env" (must be object)` };
|
||||
}
|
||||
}
|
||||
|
||||
return { servers, error: null };
|
||||
} catch (e) {
|
||||
return { servers: {}, error: 'Invalid JSON: ' + e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function updateMcpJsonPreview() {
|
||||
const jsonInput = document.getElementById('mcpServerJson');
|
||||
const previewContainer = document.getElementById('mcpJsonPreview');
|
||||
const previewContent = document.getElementById('mcpJsonPreviewContent');
|
||||
|
||||
const jsonText = jsonInput.value;
|
||||
const { servers, error } = parseMcpJsonConfig(jsonText);
|
||||
|
||||
if (!jsonText.trim()) {
|
||||
previewContainer.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
previewContainer.classList.remove('hidden');
|
||||
|
||||
if (error) {
|
||||
previewContent.innerHTML = `<div class="text-destructive">${escapeHtml(error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const serverCount = Object.keys(servers).length;
|
||||
if (serverCount === 0) {
|
||||
previewContent.innerHTML = `<div class="text-muted-foreground">No servers found</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const previewHtml = Object.entries(servers).map(([name, config]) => {
|
||||
const displayName = name === '__unnamed__' ? '(will prompt for name)' : name;
|
||||
const argsPreview = config.args ? config.args.slice(0, 2).join(' ') + (config.args.length > 2 ? '...' : '') : '';
|
||||
return `
|
||||
<div class="flex items-center gap-2 p-2 bg-background rounded">
|
||||
<span class="text-success">+</span>
|
||||
<span class="font-medium">${escapeHtml(displayName)}</span>
|
||||
<span class="text-muted-foreground text-xs">${escapeHtml(config.command)} ${escapeHtml(argsPreview)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
previewContent.innerHTML = previewHtml;
|
||||
}
|
||||
|
||||
async function submitMcpCreate() {
|
||||
if (mcpCreateMode === 'json') {
|
||||
await submitMcpCreateFromJson();
|
||||
} else {
|
||||
await submitMcpCreateFromForm();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMcpCreateFromForm() {
|
||||
const name = document.getElementById('mcpServerName').value.trim();
|
||||
const command = document.getElementById('mcpServerCommand').value.trim();
|
||||
const argsText = document.getElementById('mcpServerArgs').value.trim();
|
||||
@@ -255,6 +396,86 @@ async function submitMcpCreate() {
|
||||
serverConfig.env = env;
|
||||
}
|
||||
|
||||
await createMcpServerWithConfig(name, serverConfig);
|
||||
}
|
||||
|
||||
async function submitMcpCreateFromJson() {
|
||||
const jsonText = document.getElementById('mcpServerJson').value.trim();
|
||||
|
||||
if (!jsonText) {
|
||||
showRefreshToast('Please enter JSON configuration', 'error');
|
||||
document.getElementById('mcpServerJson').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const { servers, error } = parseMcpJsonConfig(jsonText);
|
||||
|
||||
if (error) {
|
||||
showRefreshToast(error, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(servers).length === 0) {
|
||||
showRefreshToast('No valid servers found in JSON', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle unnamed server case
|
||||
if (servers['__unnamed__']) {
|
||||
const serverName = prompt('Enter a name for this MCP server:');
|
||||
if (!serverName || !serverName.trim()) {
|
||||
showRefreshToast('Server name is required', 'error');
|
||||
return;
|
||||
}
|
||||
servers[serverName.trim()] = servers['__unnamed__'];
|
||||
delete servers['__unnamed__'];
|
||||
}
|
||||
|
||||
// Add all servers
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const serverNames = Object.keys(servers);
|
||||
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-copy-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: name,
|
||||
serverConfig: config
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to create MCP server "${name}":`, err);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
closeMcpCreateModal();
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
|
||||
if (failCount === 0) {
|
||||
showRefreshToast(`${successCount} MCP server${successCount > 1 ? 's' : ''} created successfully`, 'success');
|
||||
} else if (successCount > 0) {
|
||||
showRefreshToast(`${successCount} created, ${failCount} failed`, 'warning');
|
||||
} else {
|
||||
showRefreshToast('Failed to create MCP servers', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function createMcpServerWithConfig(name, serverConfig) {
|
||||
// Submit to API
|
||||
try {
|
||||
const response = await fetch('/api/mcp-copy-server', {
|
||||
|
||||
@@ -62,6 +62,7 @@ function initNavigation() {
|
||||
currentView = 'sessions';
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
showStatsAndSearch();
|
||||
renderSessions();
|
||||
});
|
||||
});
|
||||
@@ -75,6 +76,7 @@ function initNavigation() {
|
||||
currentView = 'liteTasks';
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
showStatsAndSearch();
|
||||
renderLiteTasks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -970,10 +970,6 @@ function renderConflictDetectionSection(conflictDetection) {
|
||||
function renderSessionContextContent(context, explorations, conflictResolution) {
|
||||
let sections = [];
|
||||
|
||||
// Render conflict resolution decisions if available
|
||||
if (conflictResolution) {
|
||||
sections.push(renderConflictResolutionContext(conflictResolution));
|
||||
}
|
||||
|
||||
// Render explorations if available (from exploration-*.json files)
|
||||
if (explorations && explorations.manifest) {
|
||||
@@ -1002,7 +998,7 @@ function renderSessionContextContent(context, explorations, conflictResolution)
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📦</div>
|
||||
<div class="empty-title">No Context Data</div>
|
||||
<div class="empty-text">No context-package.json, exploration files, or conflict resolution data found for this session.</div>
|
||||
<div class="empty-text">No context-package.json or exploration files found for this session.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -89,69 +89,8 @@ function renderImplPlanContent(implPlan) {
|
||||
// ==========================================
|
||||
// Review Tab Rendering
|
||||
// ==========================================
|
||||
|
||||
function renderReviewContent(review) {
|
||||
if (!review || !review.dimensions) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<div class="empty-title">No Review Data</div>
|
||||
<div class="empty-text">No review findings in .review/</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const dimensions = Object.entries(review.dimensions);
|
||||
if (dimensions.length === 0) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<div class="empty-title">No Findings</div>
|
||||
<div class="empty-text">No review findings found.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="review-tab-content">
|
||||
${dimensions.map(([dim, rawFindings]) => {
|
||||
// Normalize findings to always be an array
|
||||
let findings = [];
|
||||
if (Array.isArray(rawFindings)) {
|
||||
findings = rawFindings;
|
||||
} else if (rawFindings && typeof rawFindings === 'object') {
|
||||
// If it's an object with a findings array, use that
|
||||
if (Array.isArray(rawFindings.findings)) {
|
||||
findings = rawFindings.findings;
|
||||
} else {
|
||||
// Wrap single object in array or show raw JSON
|
||||
findings = [{ title: dim, description: JSON.stringify(rawFindings, null, 2), severity: 'info' }];
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="review-dimension-section">
|
||||
<div class="dimension-header">
|
||||
<span class="dimension-name">${escapeHtml(dim)}</span>
|
||||
<span class="dimension-count">${findings.length} finding${findings.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="dimension-findings">
|
||||
${findings.map(f => `
|
||||
<div class="finding-item ${f.severity || 'medium'}">
|
||||
<div class="finding-header">
|
||||
<span class="finding-severity ${f.severity || 'medium'}">${f.severity || 'medium'}</span>
|
||||
<span class="finding-title">${escapeHtml(f.title || 'Finding')}</span>
|
||||
</div>
|
||||
<p class="finding-description">${escapeHtml(f.description || '')}</p>
|
||||
${f.file ? `<div class="finding-file">📄 ${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`}).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// NOTE: Enhanced review tab with multi-select, filtering, and preview panel
|
||||
// is now in _review_tab.js - renderReviewContent() function defined there
|
||||
|
||||
// ==========================================
|
||||
// Lite Context Tab Rendering
|
||||
|
||||
@@ -20,6 +20,13 @@ function showStatsAndSearch() {
|
||||
if (searchInput) searchInput.parentElement.style.display = '';
|
||||
}
|
||||
|
||||
function hideStatsAndCarousel() {
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const stats = workflowData.statistics || {};
|
||||
document.getElementById('statTotalSessions').textContent = stats.totalSessions || 0;
|
||||
@@ -95,6 +102,11 @@ function renderSessionCard(session) {
|
||||
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
sessionDataStore[sessionKey] = session;
|
||||
|
||||
// Special rendering for review sessions
|
||||
if (sessionType === 'review') {
|
||||
return renderReviewSessionCard(session, sessionKey, typeBadge, isActive, date);
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="session-card" onclick="showSessionDetailPage('${sessionKey}')">
|
||||
<div class="session-header">
|
||||
@@ -126,3 +138,56 @@ function renderSessionCard(session) {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Special card rendering for review sessions
|
||||
function renderReviewSessionCard(session, sessionKey, typeBadge, isActive, date) {
|
||||
// Calculate findings stats from reviewDimensions
|
||||
const dimensions = session.reviewDimensions || [];
|
||||
let totalFindings = 0;
|
||||
let criticalCount = 0;
|
||||
let highCount = 0;
|
||||
let mediumCount = 0;
|
||||
let lowCount = 0;
|
||||
|
||||
dimensions.forEach(dim => {
|
||||
const findings = dim.findings || [];
|
||||
totalFindings += findings.length;
|
||||
criticalCount += findings.filter(f => f.severity === 'critical').length;
|
||||
highCount += findings.filter(f => f.severity === 'high').length;
|
||||
mediumCount += findings.filter(f => f.severity === 'medium').length;
|
||||
lowCount += findings.filter(f => f.severity === 'low').length;
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="session-card" onclick="showSessionDetailPage('${sessionKey}')">
|
||||
<div class="session-header">
|
||||
<div class="session-title">${escapeHtml(session.session_id || 'Unknown')}</div>
|
||||
<div class="session-badges">
|
||||
${typeBadge}
|
||||
<span class="session-status ${isActive ? 'active' : 'archived'}">
|
||||
${isActive ? 'ACTIVE' : 'ARCHIVED'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-body">
|
||||
<div class="session-meta">
|
||||
<span class="session-meta-item">📅 ${formatDate(date)}</span>
|
||||
<span class="session-meta-item">🔍 ${totalFindings} findings</span>
|
||||
</div>
|
||||
${totalFindings > 0 ? `
|
||||
<div class="review-findings-summary">
|
||||
<div class="findings-severity-row">
|
||||
${criticalCount > 0 ? `<span class="finding-count critical">🔴 ${criticalCount}</span>` : ''}
|
||||
${highCount > 0 ? `<span class="finding-count high">🟠 ${highCount}</span>` : ''}
|
||||
${mediumCount > 0 ? `<span class="finding-count medium">🟡 ${mediumCount}</span>` : ''}
|
||||
${lowCount > 0 ? `<span class="finding-count low">🟢 ${lowCount}</span>` : ''}
|
||||
</div>
|
||||
<div class="dimensions-info">
|
||||
${dimensions.length} dimensions
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ function showLiteTaskDetailPage(sessionKey) {
|
||||
currentView = 'liteTaskDetail';
|
||||
currentSessionDetailKey = sessionKey;
|
||||
|
||||
// Hide stats grid and carousel on detail pages
|
||||
hideStatsAndCarousel();
|
||||
|
||||
// Also store in sessionDataStore for tab switching compatibility
|
||||
sessionDataStore[sessionKey] = {
|
||||
...session,
|
||||
@@ -158,6 +161,7 @@ function goBackToLiteTasks() {
|
||||
currentView = 'liteTasks';
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
showStatsAndSearch();
|
||||
renderLiteTasks();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,73 @@
|
||||
// ==========================================
|
||||
// REVIEW SESSION DETAIL PAGE
|
||||
// ==========================================
|
||||
// Enhanced with multi-select, filters, split preview panel, and export fix JSON
|
||||
|
||||
// Review session state
|
||||
let reviewSessionState = {
|
||||
allFindings: [],
|
||||
filteredFindings: [],
|
||||
selectedFindings: new Set(),
|
||||
currentFilters: {
|
||||
dimension: 'all',
|
||||
severities: new Set(),
|
||||
search: ''
|
||||
},
|
||||
sortConfig: {
|
||||
field: 'severity',
|
||||
order: 'desc'
|
||||
},
|
||||
previewFinding: null,
|
||||
dimensions: [],
|
||||
session: null
|
||||
};
|
||||
|
||||
function renderReviewSessionDetailPage(session) {
|
||||
const isActive = session._isActive !== false;
|
||||
const tasks = session.tasks || [];
|
||||
const dimensions = session.reviewDimensions || [];
|
||||
|
||||
// Calculate review statistics
|
||||
const totalFindings = dimensions.reduce((sum, d) => sum + (d.findings?.length || 0), 0);
|
||||
const criticalCount = dimensions.reduce((sum, d) =>
|
||||
sum + (d.findings?.filter(f => f.severity === 'critical').length || 0), 0);
|
||||
const highCount = dimensions.reduce((sum, d) =>
|
||||
sum + (d.findings?.filter(f => f.severity === 'high').length || 0), 0);
|
||||
// Store session and dimensions
|
||||
reviewSessionState.session = session;
|
||||
reviewSessionState.dimensions = dimensions;
|
||||
|
||||
// Build flat findings array with dimension info
|
||||
const allFindings = [];
|
||||
let findingIndex = 0;
|
||||
dimensions.forEach(dim => {
|
||||
(dim.findings || []).forEach(f => {
|
||||
allFindings.push({
|
||||
id: f.id || `finding-${findingIndex++}`,
|
||||
title: f.title || 'Finding',
|
||||
description: f.description || '',
|
||||
severity: (f.severity || 'medium').toLowerCase(),
|
||||
dimension: dim.name || 'unknown',
|
||||
category: f.category || '',
|
||||
file: f.file || '',
|
||||
line: f.line || '',
|
||||
code_context: f.code_context || f.snippet || '',
|
||||
recommendations: f.recommendations || (f.recommendation ? [f.recommendation] : []),
|
||||
root_cause: f.root_cause || '',
|
||||
impact: f.impact || '',
|
||||
references: f.references || [],
|
||||
metadata: f.metadata || {},
|
||||
fix_status: f.fix_status || null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
reviewSessionState.allFindings = allFindings;
|
||||
reviewSessionState.filteredFindings = [...allFindings];
|
||||
reviewSessionState.selectedFindings.clear();
|
||||
reviewSessionState.previewFinding = null;
|
||||
|
||||
// Calculate statistics
|
||||
const totalFindings = allFindings.length;
|
||||
const severityCounts = {
|
||||
critical: allFindings.filter(f => f.severity === 'critical').length,
|
||||
high: allFindings.filter(f => f.severity === 'high').length,
|
||||
medium: allFindings.filter(f => f.severity === 'medium').length,
|
||||
low: allFindings.filter(f => f.severity === 'low').length
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="session-detail-page session-type-review">
|
||||
@@ -37,7 +92,7 @@ function renderReviewSessionDetailPage(session) {
|
||||
<div class="review-progress-section">
|
||||
<div class="review-progress-header">
|
||||
<h3>📊 Review Progress</h3>
|
||||
<span class="phase-badge ${session.phase || 'in-progress'}">${session.phase || 'In Progress'}</span>
|
||||
<span class="phase-badge ${session.phase || 'in-progress'}">${(session.phase || 'In Progress').toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
@@ -49,12 +104,12 @@ function renderReviewSessionDetailPage(session) {
|
||||
</div>
|
||||
<div class="summary-card critical">
|
||||
<div class="summary-icon">🔴</div>
|
||||
<div class="summary-value">${criticalCount}</div>
|
||||
<div class="summary-value">${severityCounts.critical}</div>
|
||||
<div class="summary-label">Critical</div>
|
||||
</div>
|
||||
<div class="summary-card high">
|
||||
<div class="summary-icon">🟠</div>
|
||||
<div class="summary-value">${highCount}</div>
|
||||
<div class="summary-value">${severityCounts.high}</div>
|
||||
<div class="summary-label">High</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
@@ -64,31 +119,117 @@ function renderReviewSessionDetailPage(session) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dimension Timeline -->
|
||||
<div class="dimension-timeline" id="dimensionTimeline">
|
||||
${dimensions.map((dim, idx) => `
|
||||
<div class="dimension-item ${dim.status || 'pending'}" data-dimension="${dim.name}">
|
||||
<div class="dimension-number">D${idx + 1}</div>
|
||||
<div class="dimension-name">${escapeHtml(dim.name || 'Unknown')}</div>
|
||||
<div class="dimension-stats">${dim.findings?.length || 0} findings</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Findings Grid -->
|
||||
<div class="review-findings-section">
|
||||
<div class="findings-header">
|
||||
<h3>🔍 Findings by Dimension</h3>
|
||||
<div class="findings-filters">
|
||||
<button class="filter-btn active" data-severity="all" onclick="filterReviewFindings('all')">All</button>
|
||||
<button class="filter-btn" data-severity="critical" onclick="filterReviewFindings('critical')">Critical</button>
|
||||
<button class="filter-btn" data-severity="high" onclick="filterReviewFindings('high')">High</button>
|
||||
<button class="filter-btn" data-severity="medium" onclick="filterReviewFindings('medium')">Medium</button>
|
||||
<!-- Enhanced Findings Section -->
|
||||
<div class="review-enhanced-container">
|
||||
<!-- Header with Stats & Controls -->
|
||||
<div class="review-header-bar">
|
||||
<div class="review-severity-stats">
|
||||
<span class="severity-stat critical" onclick="toggleReviewSessionSeverity('critical')" title="Filter Critical">
|
||||
🔴 ${severityCounts.critical}
|
||||
</span>
|
||||
<span class="severity-stat high" onclick="toggleReviewSessionSeverity('high')" title="Filter High">
|
||||
🟠 ${severityCounts.high}
|
||||
</span>
|
||||
<span class="severity-stat medium" onclick="toggleReviewSessionSeverity('medium')" title="Filter Medium">
|
||||
🟡 ${severityCounts.medium}
|
||||
</span>
|
||||
<span class="severity-stat low" onclick="toggleReviewSessionSeverity('low')" title="Filter Low">
|
||||
🟢 ${severityCounts.low}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="review-search-box">
|
||||
<input type="text"
|
||||
id="reviewSessionSearchInput"
|
||||
placeholder="Search findings..."
|
||||
oninput="onReviewSessionSearch(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="review-selection-controls">
|
||||
<span class="selection-counter" id="reviewSessionSelectionCounter">0 selected</span>
|
||||
<button class="btn-mini" onclick="selectAllReviewSessionFindings()">Select All</button>
|
||||
<button class="btn-mini" onclick="selectVisibleReviewSessionFindings()">Visible</button>
|
||||
<button class="btn-mini" onclick="selectReviewSessionBySeverity('critical')">Critical</button>
|
||||
<button class="btn-mini" onclick="clearReviewSessionSelection()">Clear</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-export-fix" id="reviewSessionExportBtn" onclick="exportReviewSessionFixJson()" disabled>
|
||||
🔧 Export Fix JSON
|
||||
</button>
|
||||
</div>
|
||||
<div class="findings-grid" id="reviewFindingsGrid">
|
||||
${renderReviewFindingsGrid(dimensions)}
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="review-filter-bar">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Severity:</span>
|
||||
<div class="filter-chips">
|
||||
<label class="filter-chip" id="rs-filter-critical">
|
||||
<input type="checkbox" onchange="toggleReviewSessionSeverity('critical')">
|
||||
<span>Critical</span>
|
||||
</label>
|
||||
<label class="filter-chip" id="rs-filter-high">
|
||||
<input type="checkbox" onchange="toggleReviewSessionSeverity('high')">
|
||||
<span>High</span>
|
||||
</label>
|
||||
<label class="filter-chip" id="rs-filter-medium">
|
||||
<input type="checkbox" onchange="toggleReviewSessionSeverity('medium')">
|
||||
<span>Medium</span>
|
||||
</label>
|
||||
<label class="filter-chip" id="rs-filter-low">
|
||||
<input type="checkbox" onchange="toggleReviewSessionSeverity('low')">
|
||||
<span>Low</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Sort:</span>
|
||||
<select id="reviewSessionSortSelect" class="sort-select" onchange="sortReviewSessionFindings()">
|
||||
<option value="severity">By Severity</option>
|
||||
<option value="dimension">By Dimension</option>
|
||||
<option value="file">By File</option>
|
||||
</select>
|
||||
<button class="btn-sort-order" id="reviewSessionSortOrderBtn" onclick="toggleReviewSessionSortOrder()">
|
||||
<span id="reviewSessionSortOrderIcon">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-mini" onclick="resetReviewSessionFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- Dimension Tabs -->
|
||||
<div class="review-dimension-tabs">
|
||||
<button class="dim-tab active" data-dimension="all" onclick="filterReviewSessionByDimension('all')">
|
||||
All (${totalFindings})
|
||||
</button>
|
||||
${dimensions.map(dim => `
|
||||
<button class="dim-tab" data-dimension="${dim.name}" onclick="filterReviewSessionByDimension('${dim.name}')">
|
||||
${escapeHtml(dim.name)} (${dim.findings?.length || 0})
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Split Panel: List + Preview -->
|
||||
<div class="review-split-panel">
|
||||
<!-- Left: Findings List -->
|
||||
<div class="review-findings-panel">
|
||||
<div class="findings-list-header">
|
||||
<span id="reviewSessionFindingsCount">${totalFindings} findings</span>
|
||||
</div>
|
||||
<div class="review-findings-list" id="reviewSessionFindingsList">
|
||||
${renderReviewSessionFindingsList(allFindings)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Preview Panel -->
|
||||
<div class="review-preview-panel" id="reviewSessionPreviewPanel">
|
||||
<div class="preview-empty-state">
|
||||
<div class="preview-icon">👆</div>
|
||||
<div class="preview-text">Click on a finding to preview details</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,64 +254,458 @@ function renderReviewSessionDetailPage(session) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderReviewFindingsGrid(dimensions) {
|
||||
if (!dimensions || dimensions.length === 0) {
|
||||
// ==========================================
|
||||
// Findings List Rendering
|
||||
// ==========================================
|
||||
|
||||
function renderReviewSessionFindingsList(findings) {
|
||||
if (findings.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<div class="empty-text">No review dimensions found</div>
|
||||
<div class="findings-empty">
|
||||
<span class="empty-icon">✨</span>
|
||||
<span>No findings match your filters</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
dimensions.forEach(dim => {
|
||||
const findings = dim.findings || [];
|
||||
if (findings.length === 0) return;
|
||||
|
||||
html += `
|
||||
<div class="dimension-findings-group" data-dimension="${dim.name}">
|
||||
<div class="dimension-group-header">
|
||||
<span class="dimension-badge">${escapeHtml(dim.name)}</span>
|
||||
<span class="dimension-count">${findings.length} findings</span>
|
||||
</div>
|
||||
<div class="findings-cards">
|
||||
${findings.map(f => `
|
||||
<div class="finding-card severity-${f.severity || 'medium'}" data-severity="${f.severity || 'medium'}">
|
||||
<div class="finding-card-header">
|
||||
<span class="severity-badge ${f.severity || 'medium'}">${f.severity || 'medium'}</span>
|
||||
${f.fix_status ? `<span class="fix-status-badge status-${f.fix_status}">${f.fix_status}</span>` : ''}
|
||||
</div>
|
||||
<div class="finding-card-title">${escapeHtml(f.title || 'Finding')}</div>
|
||||
<div class="finding-card-desc">${escapeHtml((f.description || '').substring(0, 100))}${f.description?.length > 100 ? '...' : ''}</div>
|
||||
${f.file ? `<div class="finding-card-file">📄 ${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
return findings.map(finding => `
|
||||
<div class="review-finding-item ${finding.severity} ${reviewSessionState.selectedFindings.has(finding.id) ? 'selected' : ''}"
|
||||
data-finding-id="${finding.id}"
|
||||
onclick="previewReviewSessionFinding('${finding.id}')">
|
||||
<input type="checkbox"
|
||||
class="finding-checkbox"
|
||||
${reviewSessionState.selectedFindings.has(finding.id) ? 'checked' : ''}
|
||||
onclick="toggleReviewSessionFindingSelection('${finding.id}', event)">
|
||||
<div class="finding-content">
|
||||
<div class="finding-top-row">
|
||||
<span class="severity-badge ${finding.severity}">${finding.severity}</span>
|
||||
<span class="dimension-badge">${escapeHtml(finding.dimension)}</span>
|
||||
${finding.fix_status ? `<span class="fix-status-mini status-${finding.fix_status}">${finding.fix_status}</span>` : ''}
|
||||
</div>
|
||||
<div class="finding-title">${escapeHtml(finding.title)}</div>
|
||||
${finding.file ? `<div class="finding-file">📄 ${escapeHtml(finding.file)}${finding.line ? ':' + finding.line : ''}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
return html || '<div class="empty-state"><div class="empty-text">No findings</div></div>';
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function initReviewSessionPage(session) {
|
||||
// Initialize event handlers for review session page
|
||||
// Filter handlers are inline onclick
|
||||
}
|
||||
// ==========================================
|
||||
// Preview Panel
|
||||
// ==========================================
|
||||
|
||||
function filterReviewFindings(severity) {
|
||||
// Update filter buttons
|
||||
document.querySelectorAll('.findings-filters .filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.severity === severity);
|
||||
function previewReviewSessionFinding(findingId) {
|
||||
const finding = reviewSessionState.allFindings.find(f => f.id === findingId);
|
||||
if (!finding) return;
|
||||
|
||||
reviewSessionState.previewFinding = finding;
|
||||
|
||||
// Update active state in list
|
||||
document.querySelectorAll('.review-finding-item').forEach(item => {
|
||||
item.classList.toggle('previewing', item.dataset.findingId === findingId);
|
||||
});
|
||||
|
||||
// Filter finding cards
|
||||
document.querySelectorAll('.finding-card').forEach(card => {
|
||||
if (severity === 'all' || card.dataset.severity === severity) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
const previewPanel = document.getElementById('reviewSessionPreviewPanel');
|
||||
if (!previewPanel) return;
|
||||
|
||||
previewPanel.innerHTML = `
|
||||
<div class="preview-content">
|
||||
<div class="preview-header">
|
||||
<div class="preview-badges">
|
||||
<span class="severity-badge ${finding.severity}">${finding.severity}</span>
|
||||
<span class="dimension-badge">${escapeHtml(finding.dimension)}</span>
|
||||
${finding.category ? `<span class="category-badge">${escapeHtml(finding.category)}</span>` : ''}
|
||||
${finding.fix_status ? `<span class="fix-status-badge status-${finding.fix_status}">${finding.fix_status}</span>` : ''}
|
||||
</div>
|
||||
<button class="btn-select-finding ${reviewSessionState.selectedFindings.has(finding.id) ? 'selected' : ''}"
|
||||
onclick="toggleReviewSessionFindingSelection('${finding.id}', event)">
|
||||
${reviewSessionState.selectedFindings.has(finding.id) ? '✓ Selected' : '+ Select for Fix'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="preview-title">${escapeHtml(finding.title)}</h3>
|
||||
|
||||
${finding.file ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">📄 Location</div>
|
||||
<div class="preview-location">
|
||||
<code>${escapeHtml(finding.file)}${finding.line ? ':' + finding.line : ''}</code>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">📝 Description</div>
|
||||
<div class="preview-description">${escapeHtml(finding.description)}</div>
|
||||
</div>
|
||||
|
||||
${finding.code_context ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">💻 Code Context</div>
|
||||
<pre class="preview-code">${escapeHtml(finding.code_context)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.recommendations && finding.recommendations.length > 0 ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">✅ Recommendations</div>
|
||||
<ul class="preview-recommendations">
|
||||
${finding.recommendations.map(r => `<li>${escapeHtml(r)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.root_cause ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">🔍 Root Cause</div>
|
||||
<div class="preview-root-cause">${escapeHtml(finding.root_cause)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.impact ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">⚠️ Impact</div>
|
||||
<div class="preview-impact">${escapeHtml(finding.impact)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.references && finding.references.length > 0 ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">🔗 References</div>
|
||||
<ul class="preview-references">
|
||||
${finding.references.map(ref => {
|
||||
const isUrl = ref.startsWith('http');
|
||||
return `<li>${isUrl ? `<a href="${ref}" target="_blank">${ref}</a>` : ref}</li>`;
|
||||
}).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.metadata && Object.keys(finding.metadata).length > 0 ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">ℹ️ Metadata</div>
|
||||
<div class="preview-metadata">
|
||||
${Object.entries(finding.metadata).map(([key, value]) => `
|
||||
<div class="metadata-item">
|
||||
<span class="meta-key">${escapeHtml(key)}:</span>
|
||||
<span class="meta-value">${escapeHtml(String(value))}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Selection Management
|
||||
// ==========================================
|
||||
|
||||
function toggleReviewSessionFindingSelection(findingId, event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (reviewSessionState.selectedFindings.has(findingId)) {
|
||||
reviewSessionState.selectedFindings.delete(findingId);
|
||||
} else {
|
||||
reviewSessionState.selectedFindings.add(findingId);
|
||||
}
|
||||
|
||||
updateReviewSessionSelectionUI();
|
||||
|
||||
// Update preview panel button if this finding is being previewed
|
||||
if (reviewSessionState.previewFinding && reviewSessionState.previewFinding.id === findingId) {
|
||||
previewReviewSessionFinding(findingId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllReviewSessionFindings() {
|
||||
reviewSessionState.allFindings.forEach(f => reviewSessionState.selectedFindings.add(f.id));
|
||||
updateReviewSessionSelectionUI();
|
||||
}
|
||||
|
||||
function selectVisibleReviewSessionFindings() {
|
||||
reviewSessionState.filteredFindings.forEach(f => reviewSessionState.selectedFindings.add(f.id));
|
||||
updateReviewSessionSelectionUI();
|
||||
}
|
||||
|
||||
function selectReviewSessionBySeverity(severity) {
|
||||
reviewSessionState.allFindings
|
||||
.filter(f => f.severity === severity)
|
||||
.forEach(f => reviewSessionState.selectedFindings.add(f.id));
|
||||
updateReviewSessionSelectionUI();
|
||||
}
|
||||
|
||||
function clearReviewSessionSelection() {
|
||||
reviewSessionState.selectedFindings.clear();
|
||||
updateReviewSessionSelectionUI();
|
||||
}
|
||||
|
||||
function updateReviewSessionSelectionUI() {
|
||||
// Update counter
|
||||
const counter = document.getElementById('reviewSessionSelectionCounter');
|
||||
if (counter) {
|
||||
counter.textContent = `${reviewSessionState.selectedFindings.size} selected`;
|
||||
}
|
||||
|
||||
// Update export button
|
||||
const exportBtn = document.getElementById('reviewSessionExportBtn');
|
||||
if (exportBtn) {
|
||||
exportBtn.disabled = reviewSessionState.selectedFindings.size === 0;
|
||||
}
|
||||
|
||||
// Update checkbox states in list
|
||||
document.querySelectorAll('.review-finding-item').forEach(item => {
|
||||
const findingId = item.dataset.findingId;
|
||||
const isSelected = reviewSessionState.selectedFindings.has(findingId);
|
||||
item.classList.toggle('selected', isSelected);
|
||||
const checkbox = item.querySelector('.finding-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = isSelected;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Filtering & Sorting
|
||||
// ==========================================
|
||||
|
||||
function filterReviewSessionByDimension(dimension) {
|
||||
reviewSessionState.currentFilters.dimension = dimension;
|
||||
|
||||
// Update tab active state
|
||||
document.querySelectorAll('.dim-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.dimension === dimension);
|
||||
});
|
||||
|
||||
// Update dimension timeline highlight
|
||||
document.querySelectorAll('.dimension-item').forEach(item => {
|
||||
item.classList.toggle('active', dimension === 'all' || item.dataset.dimension === dimension);
|
||||
});
|
||||
|
||||
applyReviewSessionFilters();
|
||||
}
|
||||
|
||||
function toggleReviewSessionSeverity(severity) {
|
||||
if (reviewSessionState.currentFilters.severities.has(severity)) {
|
||||
reviewSessionState.currentFilters.severities.delete(severity);
|
||||
} else {
|
||||
reviewSessionState.currentFilters.severities.add(severity);
|
||||
}
|
||||
|
||||
// Update filter chip UI
|
||||
const filterChip = document.getElementById(`rs-filter-${severity}`);
|
||||
if (filterChip) {
|
||||
filterChip.classList.toggle('active', reviewSessionState.currentFilters.severities.has(severity));
|
||||
const checkbox = filterChip.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = reviewSessionState.currentFilters.severities.has(severity);
|
||||
}
|
||||
}
|
||||
|
||||
applyReviewSessionFilters();
|
||||
}
|
||||
|
||||
function onReviewSessionSearch(searchText) {
|
||||
reviewSessionState.currentFilters.search = searchText.toLowerCase();
|
||||
applyReviewSessionFilters();
|
||||
}
|
||||
|
||||
function applyReviewSessionFilters() {
|
||||
reviewSessionState.filteredFindings = reviewSessionState.allFindings.filter(finding => {
|
||||
// Dimension filter
|
||||
if (reviewSessionState.currentFilters.dimension !== 'all') {
|
||||
if (finding.dimension !== reviewSessionState.currentFilters.dimension) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Severity filter (multi-select)
|
||||
if (reviewSessionState.currentFilters.severities.size > 0) {
|
||||
if (!reviewSessionState.currentFilters.severities.has(finding.severity)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (reviewSessionState.currentFilters.search) {
|
||||
const searchText = `${finding.title} ${finding.description} ${finding.file} ${finding.category}`.toLowerCase();
|
||||
if (!searchText.includes(reviewSessionState.currentFilters.search)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
sortReviewSessionFindings();
|
||||
}
|
||||
|
||||
function sortReviewSessionFindings() {
|
||||
const sortBy = document.getElementById('reviewSessionSortSelect')?.value || 'severity';
|
||||
reviewSessionState.sortConfig.field = sortBy;
|
||||
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
|
||||
reviewSessionState.filteredFindings.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortBy === 'severity') {
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
} else if (sortBy === 'dimension') {
|
||||
comparison = a.dimension.localeCompare(b.dimension);
|
||||
} else if (sortBy === 'file') {
|
||||
comparison = (a.file || '').localeCompare(b.file || '');
|
||||
}
|
||||
|
||||
return reviewSessionState.sortConfig.order === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
renderFilteredReviewSessionFindings();
|
||||
}
|
||||
|
||||
function toggleReviewSessionSortOrder() {
|
||||
reviewSessionState.sortConfig.order = reviewSessionState.sortConfig.order === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
const icon = document.getElementById('reviewSessionSortOrderIcon');
|
||||
if (icon) {
|
||||
icon.textContent = reviewSessionState.sortConfig.order === 'asc' ? '↑' : '↓';
|
||||
}
|
||||
|
||||
sortReviewSessionFindings();
|
||||
}
|
||||
|
||||
function resetReviewSessionFilters() {
|
||||
// Reset state
|
||||
reviewSessionState.currentFilters.dimension = 'all';
|
||||
reviewSessionState.currentFilters.severities.clear();
|
||||
reviewSessionState.currentFilters.search = '';
|
||||
reviewSessionState.sortConfig.field = 'severity';
|
||||
reviewSessionState.sortConfig.order = 'desc';
|
||||
|
||||
// Reset UI
|
||||
document.querySelectorAll('.dim-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.dimension === 'all');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.filter-chip').forEach(chip => {
|
||||
chip.classList.remove('active');
|
||||
const checkbox = chip.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.dimension-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('reviewSessionSearchInput');
|
||||
if (searchInput) searchInput.value = '';
|
||||
|
||||
const sortSelect = document.getElementById('reviewSessionSortSelect');
|
||||
if (sortSelect) sortSelect.value = 'severity';
|
||||
|
||||
const sortIcon = document.getElementById('reviewSessionSortOrderIcon');
|
||||
if (sortIcon) sortIcon.textContent = '↓';
|
||||
|
||||
// Re-apply filters
|
||||
reviewSessionState.filteredFindings = [...reviewSessionState.allFindings];
|
||||
sortReviewSessionFindings();
|
||||
}
|
||||
|
||||
function renderFilteredReviewSessionFindings() {
|
||||
const listContainer = document.getElementById('reviewSessionFindingsList');
|
||||
const countEl = document.getElementById('reviewSessionFindingsCount');
|
||||
|
||||
if (listContainer) {
|
||||
listContainer.innerHTML = renderReviewSessionFindingsList(reviewSessionState.filteredFindings);
|
||||
}
|
||||
|
||||
if (countEl) {
|
||||
countEl.textContent = `${reviewSessionState.filteredFindings.length} findings`;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Export Fix JSON
|
||||
// ==========================================
|
||||
|
||||
function exportReviewSessionFixJson() {
|
||||
if (reviewSessionState.selectedFindings.size === 0) {
|
||||
showToast('Please select at least one finding to export', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedFindingsData = reviewSessionState.allFindings.filter(f =>
|
||||
reviewSessionState.selectedFindings.has(f.id)
|
||||
);
|
||||
|
||||
const session = reviewSessionState.session;
|
||||
const sessionId = session?.session_id || 'unknown';
|
||||
const exportId = `fix-${Date.now()}`;
|
||||
|
||||
const exportData = {
|
||||
export_id: exportId,
|
||||
export_timestamp: new Date().toISOString(),
|
||||
review_id: `review-${sessionId}`,
|
||||
session_id: sessionId,
|
||||
findings_count: selectedFindingsData.length,
|
||||
findings: selectedFindingsData.map(f => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
severity: f.severity,
|
||||
dimension: f.dimension,
|
||||
category: f.category || 'uncategorized',
|
||||
file: f.file,
|
||||
line: f.line,
|
||||
code_context: f.code_context || null,
|
||||
recommendations: f.recommendations || [],
|
||||
root_cause: f.root_cause || null
|
||||
}))
|
||||
};
|
||||
|
||||
// Convert to JSON and download
|
||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const filename = `fix-export-${exportId}.json`;
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Show success notification
|
||||
const severityCounts = {
|
||||
critical: selectedFindingsData.filter(f => f.severity === 'critical').length,
|
||||
high: selectedFindingsData.filter(f => f.severity === 'high').length,
|
||||
medium: selectedFindingsData.filter(f => f.severity === 'medium').length,
|
||||
low: selectedFindingsData.filter(f => f.severity === 'low').length
|
||||
};
|
||||
|
||||
showToast(`Exported ${selectedFindingsData.length} findings (Critical: ${severityCounts.critical}, High: ${severityCounts.high}, Medium: ${severityCounts.medium}, Low: ${severityCounts.low})`, 'success');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Page Initialization
|
||||
// ==========================================
|
||||
|
||||
function initReviewSessionPage(session) {
|
||||
// Reset state when page loads
|
||||
reviewSessionState.session = session;
|
||||
// Event handlers are inline onclick - no additional setup needed
|
||||
}
|
||||
|
||||
// Legacy filter function for compatibility
|
||||
function filterReviewFindings(severity) {
|
||||
if (severity === 'all') {
|
||||
reviewSessionState.currentFilters.severities.clear();
|
||||
} else {
|
||||
reviewSessionState.currentFilters.severities.clear();
|
||||
reviewSessionState.currentFilters.severities.add(severity);
|
||||
}
|
||||
applyReviewSessionFilters();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ function showSessionDetailPage(sessionKey) {
|
||||
currentSessionDetailKey = sessionKey;
|
||||
updateContentTitle();
|
||||
|
||||
// Hide stats grid and carousel on detail pages
|
||||
hideStatsAndCarousel();
|
||||
|
||||
const container = document.getElementById('mainContent');
|
||||
const sessionType = session.type || 'workflow';
|
||||
|
||||
@@ -94,6 +97,7 @@ function showSessionDetailPage(sessionKey) {
|
||||
<span class="tab-icon">📐</span>
|
||||
<span class="tab-text">IMPL Plan</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="conflict" onclick="switchDetailTab('conflict')"> <span class="tab-icon">⚖️</span> <span class="tab-text">Conflict</span> </button>
|
||||
${session.hasReview ? `
|
||||
<button class="detail-tab" data-tab="review" onclick="switchDetailTab('review')">
|
||||
<span class="tab-icon">🔍</span>
|
||||
@@ -114,6 +118,7 @@ function goBackToSessions() {
|
||||
currentView = 'sessions';
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
showStatsAndSearch();
|
||||
renderSessions();
|
||||
}
|
||||
|
||||
@@ -148,6 +153,7 @@ function switchDetailTab(tabName) {
|
||||
case 'review':
|
||||
loadAndRenderReviewTab(session, contentArea);
|
||||
break;
|
||||
case 'conflict': loadAndRenderConflictTab(session, contentArea); break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -479,10 +479,17 @@
|
||||
<div class="mcp-modal-backdrop absolute inset-0 bg-black/60" onclick="closeMcpCreateModal()"></div>
|
||||
<div class="mcp-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-lg flex flex-col">
|
||||
<div class="mcp-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground">Create MCP Server</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">Create MCP Server</h3>
|
||||
<div class="flex bg-muted rounded-lg p-0.5">
|
||||
<button id="mcpTabForm" class="mcp-tab-btn px-3 py-1 text-sm rounded-md transition-colors active" onclick="switchMcpCreateTab('form')">Form</button>
|
||||
<button id="mcpTabJson" class="mcp-tab-btn px-3 py-1 text-sm rounded-md transition-colors" onclick="switchMcpCreateTab('json')">JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeMcpCreateModal()">×</button>
|
||||
</div>
|
||||
<div class="mcp-modal-body p-4 space-y-4">
|
||||
<!-- Form Mode -->
|
||||
<div id="mcpFormMode" class="mcp-modal-body p-4 space-y-4">
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Server Name <span class="text-destructive">*</span></label>
|
||||
<input type="text" id="mcpServerName" placeholder="e.g., my-mcp-server"
|
||||
@@ -504,6 +511,29 @@
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- JSON Mode -->
|
||||
<div id="mcpJsonMode" class="mcp-modal-body p-4 space-y-4 hidden">
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Paste MCP Server JSON Configuration</label>
|
||||
<textarea id="mcpServerJson" placeholder='{
|
||||
"servers": {
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@package/server"],
|
||||
"env": {
|
||||
"API_KEY": "your-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}' rows="12"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
|
||||
<p class="text-xs text-muted-foreground mt-2">Supports <code class="bg-muted px-1 rounded">{"servers": {...}}</code>, <code class="bg-muted px-1 rounded">{"mcpServers": {...}}</code>, and direct server config formats.</p>
|
||||
</div>
|
||||
<div id="mcpJsonPreview" class="hidden">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Preview (servers to be added):</label>
|
||||
<div id="mcpJsonPreviewContent" class="bg-muted rounded-lg p-3 text-sm space-y-2 max-h-32 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mcp-modal-footer flex justify-end gap-2 px-4 py-3 border-t border-border">
|
||||
<button class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-hover transition-colors" onclick="closeMcpCreateModal()">Cancel</button>
|
||||
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" onclick="submitMcpCreate()">Create</button>
|
||||
|
||||
Reference in New Issue
Block a user