feat(cli): 添加 --rule 选项支持模板自动发现

重构 ccw cli 模板系统:

- 新增 template-discovery.ts 模块,支持扁平化模板自动发现
- 添加 --rule <template> 选项,自动加载 protocol 和 template
- 模板目录从嵌套结构 (prompts/category/file.txt) 迁移到扁平结构 (prompts/category-function.txt)
- 更新所有 agent/command 文件,使用 $PROTO $TMPL 环境变量替代 $(cat ...) 模式
- 支持模糊匹配:--rule 02-review-architecture 可匹配 analysis-review-architecture.txt

其他更新:
- Dashboard: 添加 Claude Manager 和 Issue Manager 页面
- Codex-lens: 增强 chain_search 和 clustering 模块

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2026-01-17 19:20:24 +08:00
parent 1fae35c05d
commit f14418603a
137 changed files with 13125 additions and 301 deletions

View File

@@ -906,3 +906,182 @@
max-height: 300px;
}
}
/* ========================================
* Batch Delete Modal
* ======================================== */
.batch-delete-modal {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.warning-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 0.5rem;
color: hsl(var(--destructive));
font-weight: 500;
}
.warning-banner i {
flex-shrink: 0;
}
.delete-summary {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.summary-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.025em;
font-weight: 500;
}
.summary-value {
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.file-list-container h4 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.75rem;
}
.file-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 0.5rem;
background: hsl(var(--muted) / 0.2);
}
.delete-file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
margin-bottom: 0.5rem;
transition: all 0.15s ease;
}
.delete-file-item:last-child {
margin-bottom: 0;
}
.delete-file-item:hover {
background: hsl(var(--hover));
}
.delete-file-item i {
flex-shrink: 0;
color: hsl(var(--muted-foreground));
}
.file-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.file-info .file-name {
font-weight: 500;
color: hsl(var(--foreground));
font-size: 0.875rem;
}
.file-info .file-path {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
font-family: 'Courier New', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.level-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.level-badge.project {
background: hsl(142, 76%, 36%, 0.15);
color: hsl(142, 76%, 36%);
border: 1px solid hsl(142, 76%, 36%, 0.3);
}
.level-badge.module {
background: hsl(221, 83%, 53%, 0.15);
color: hsl(221, 83%, 53%);
border: 1px solid hsl(221, 83%, 53%, 0.3);
}
.confirmation-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid hsl(var(--border));
}
/* Remove file button in batch delete list */
.remove-file-btn {
padding: 0.25rem;
border-radius: 0.25rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
flex-shrink: 0;
}
.delete-file-item:hover .remove-file-btn {
opacity: 1;
}
.remove-file-btn:hover {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
/* Empty list message */
.empty-list-message {
padding: 2rem;
text-align: center;
color: hsl(var(--muted-foreground));
font-style: italic;
}

View File

@@ -3300,3 +3300,93 @@
text-transform: uppercase;
letter-spacing: 0.025em;
}
/* ==========================================
SPLIT QUEUE MODAL STYLES
========================================== */
.split-queue-modal-content {
max-width: 600px;
width: 90%;
}
.split-queue-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.split-queue-issues {
display: flex;
flex-direction: column;
gap: 1rem;
}
.split-queue-issue-group {
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.3);
transition: all 0.15s ease;
}
.split-queue-issue-group:hover {
background: hsl(var(--muted) / 0.5);
border-color: hsl(var(--primary) / 0.3);
}
.split-queue-issue-header {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid hsl(var(--border) / 0.5);
}
.split-queue-issue-header label {
cursor: pointer;
user-select: none;
}
.split-queue-issue-header input[type="checkbox"] {
cursor: pointer;
}
.split-queue-solutions {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.split-queue-solutions label {
cursor: pointer;
user-select: none;
padding: 0.25rem;
border-radius: 0.25rem;
transition: background-color 0.15s ease;
}
.split-queue-solutions label:hover {
background: hsl(var(--muted) / 0.5);
}
.split-queue-solutions input[type="checkbox"] {
cursor: pointer;
}
/* Checkbox styles */
.split-queue-modal-content input[type="checkbox"] {
width: 1rem;
height: 1rem;
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.split-queue-modal-content input[type="checkbox"]:hover {
border-color: hsl(var(--primary));
}
.split-queue-modal-content input[type="checkbox"]:checked {
background-color: hsl(var(--primary));
border-color: hsl(var(--primary));
}

View File

@@ -1704,6 +1704,19 @@ const i18n = {
'claude.deleteFile': 'Delete File',
'claude.deleteConfirm': 'Are you sure you want to delete {file}?',
'claude.deleteWarning': 'This action cannot be undone.',
'claude.batchDeleteProject': 'Delete Project Files',
'claude.batchDeleteTitle': 'Delete Project Workspace Files',
'claude.batchDeleteWarning': 'This will delete all CLAUDE.md files in the project workspace (excluding user-level files)',
'claude.noProjectFiles': 'No project workspace files to delete',
'claude.filesToDelete': 'Files to delete:',
'claude.totalSize': 'Total size:',
'claude.fileList': 'File List',
'claude.confirmDelete': 'Confirm Delete',
'claude.deletingFiles': 'Deleting {count} files...',
'claude.batchDeleteSuccess': 'Successfully deleted {deleted} of {total} files',
'claude.batchDeleteError': 'Failed to delete files',
'claude.removeFromList': 'Remove from list',
'claude.noFilesInList': 'No files in the list',
'claude.copyContent': 'Copy Content',
'claude.contentCopied': 'Content copied to clipboard',
'claude.copyError': 'Failed to copy content',
@@ -4013,6 +4026,19 @@ const i18n = {
'claude.deleteFile': '删除文件',
'claude.deleteConfirm': '确定要删除 {file} 吗?',
'claude.deleteWarning': '此操作无法撤销。',
'claude.batchDeleteProject': '删除项目文件',
'claude.batchDeleteTitle': '删除项目工作空间文件',
'claude.batchDeleteWarning': '此操作将删除项目工作空间内的所有 CLAUDE.md 文件(不包括用户级文件)',
'claude.noProjectFiles': '没有可删除的项目工作空间文件',
'claude.filesToDelete': '待删除文件数:',
'claude.totalSize': '总大小:',
'claude.fileList': '文件清单',
'claude.confirmDelete': '确认删除',
'claude.deletingFiles': '正在删除 {count} 个文件...',
'claude.batchDeleteSuccess': '成功删除 {deleted}/{total} 个文件',
'claude.batchDeleteError': '删除文件失败',
'claude.removeFromList': '从清单中移除',
'claude.noFilesInList': '清单中没有文件',
'claude.copyContent': '复制内容',
'claude.contentCopied': '内容已复制到剪贴板',
'claude.copyError': '复制内容失败',

View File

@@ -24,6 +24,7 @@ var searchQuery = '';
var freshnessData = {}; // { [filePath]: FreshnessResult }
var freshnessSummary = null;
var searchKeyboardHandlerAdded = false;
var pendingDeleteFiles = []; // Files pending for batch delete
// ========== Main Render Function ==========
async function renderClaudeManager() {
@@ -64,6 +65,9 @@ async function renderClaudeManager() {
'<button class="btn btn-sm btn-secondary" onclick="refreshClaudeFiles()">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i> ' + t('common.refresh') +
'</button>' +
'<button class="btn btn-sm btn-danger" onclick="showBatchDeleteDialog()">' +
'<i data-lucide="trash-2" class="w-4 h-4"></i> ' + t('claude.batchDeleteProject') +
'</button>' +
'</div>' +
'</div>' +
'<div class="claude-manager-columns">' +
@@ -959,3 +963,167 @@ window.initClaudeManager = function() {
// Make destroyClaudeManager accessible globally as well
window.destroyClaudeManager = destroyClaudeManager;
// ========== Batch Delete Functions ==========
/**
* Show batch delete confirmation dialog for project workspace files
*/
function showBatchDeleteDialog() {
// Get project workspace files (project + modules, exclude user)
var projectFiles = [];
if (claudeFilesData.project.main) {
projectFiles.push(claudeFilesData.project.main);
}
projectFiles.push(...claudeFilesData.modules);
if (projectFiles.length === 0) {
showRefreshToast(t('claude.noProjectFiles') || 'No project workspace files to delete', 'info');
return;
}
// Initialize pending delete files list
pendingDeleteFiles = [...projectFiles];
// Render the modal with current pending files
renderBatchDeleteModal();
}
/**
* Render or re-render the batch delete modal content
*/
function renderBatchDeleteModal() {
// Build file list HTML with remove buttons
var fileListHTML = pendingDeleteFiles.map(function(file, index) {
var levelBadge = file.level === 'project'
? '<span class="level-badge project">' + t('claudeManager.projectLevel') + '</span>'
: '<span class="level-badge module">' + t('claudeManager.moduleLevel') + '</span>';
return '<div class="delete-file-item" data-file-index="' + index + '">' +
'<i data-lucide="file-text" class="w-4 h-4"></i>' +
'<div class="file-info">' +
'<span class="file-name">' + escapeHtml(file.name) + '</span>' +
'<span class="file-path">' + escapeHtml(file.relativePath) + '</span>' +
'</div>' +
levelBadge +
'<button class="btn btn-sm btn-ghost remove-file-btn" onclick="removeFromDeleteList(' + index + ')" title="' + (t('claude.removeFromList') || 'Remove from list') + '">' +
'<i data-lucide="x" class="w-4 h-4"></i>' +
'</button>' +
'</div>';
}).join('');
var totalSize = pendingDeleteFiles.reduce(function(sum, f) { return sum + f.size; }, 0);
var modalContent = '<div class="batch-delete-modal">' +
'<div class="warning-banner">' +
'<i data-lucide="alert-triangle" class="w-5 h-5"></i>' +
'<span>' + (t('claude.batchDeleteWarning') || 'This will delete all CLAUDE.md files in the project workspace') + '</span>' +
'</div>' +
'<div class="delete-summary" id="delete-summary">' +
'<div class="summary-item">' +
'<span class="summary-label">' + t('claude.filesToDelete') + '</span>' +
'<span class="summary-value" id="files-to-delete-count">' + pendingDeleteFiles.length + '</span>' +
'</div>' +
'<div class="summary-item">' +
'<span class="summary-label">' + t('claude.totalSize') + '</span>' +
'<span class="summary-value" id="total-size-value">' + formatFileSize(totalSize) + '</span>' +
'</div>' +
'</div>' +
'<div class="file-list-container">' +
'<h4>' + t('claude.fileList') + '</h4>' +
'<div class="file-list" id="pending-file-list">' + (fileListHTML || '<div class="empty-list-message">' + (t('claude.noFilesInList') || 'No files in the list') + '</div>') + '</div>' +
'</div>' +
'<div class="confirmation-actions">' +
'<button class="btn btn-secondary" onclick="closeModal()">' +
'<i data-lucide="x" class="w-4 h-4"></i> ' + t('common.cancel') +
'</button>' +
'<button class="btn btn-danger" onclick="confirmBatchDeleteProject()"' + (pendingDeleteFiles.length === 0 ? ' disabled' : '') + '>' +
'<i data-lucide="trash-2" class="w-4 h-4"></i> ' + t('claude.confirmDelete') +
'</button>' +
'</div>' +
'</div>';
showModal(
t('claude.batchDeleteTitle') || 'Delete Project Workspace Files',
modalContent,
{ size: 'large' }
);
if (window.lucide) lucide.createIcons();
}
/**
* Remove a file from the pending delete list
*/
function removeFromDeleteList(index) {
if (index >= 0 && index < pendingDeleteFiles.length) {
pendingDeleteFiles.splice(index, 1);
renderBatchDeleteModal();
}
}
/**
* Execute batch delete for project workspace files
*/
async function confirmBatchDeleteProject() {
// Collect file paths from pending delete list
var filePaths = pendingDeleteFiles.map(function(file) {
return file.path;
});
if (filePaths.length === 0) return;
closeModal();
// Show progress
showRefreshToast(
(t('claude.deletingFiles') || 'Deleting {count} files...').replace('{count}', filePaths.length),
'info'
);
try {
var res = await fetch('/api/memory/claude/batch-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paths: filePaths,
confirm: true
})
});
if (!res.ok) throw new Error('Batch delete failed');
var result = await res.json();
if (result.success) {
var message = (t('claude.batchDeleteSuccess') || 'Successfully deleted {deleted} of {total} files')
.replace('{deleted}', result.deleted)
.replace('{total}', result.total);
showRefreshToast(message, 'success');
addGlobalNotification('success', message, null, 'CLAUDE.md');
if (result.errors && result.errors.length > 0) {
console.warn('Some files failed to delete:', result.errors);
}
// Clear selection if deleted file was selected
if (selectedFile && filePaths.includes(selectedFile.path)) {
selectedFile = null;
}
// Refresh file tree
await refreshClaudeFiles();
} else {
throw new Error(result.error || 'Unknown error');
}
} catch (error) {
console.error('Error in batch delete:', error);
showRefreshToast(
t('claude.batchDeleteError') || 'Failed to delete files',
'error'
);
addGlobalNotification('error', t('claude.batchDeleteError') || 'Failed to delete files', null, 'CLAUDE.md');
}
}

View File

@@ -562,6 +562,11 @@ function renderQueueCard(queue, isActive) {
<i data-lucide="git-merge" class="w-3 h-3"></i>
</button>
` : ''}
${queue.status !== 'merged' && issueCount > 1 ? `
<button class="btn-sm" onclick="showSplitQueueModal('${safeQueueId}')" title="Split queue into multiple queues">
<i data-lucide="git-branch" class="w-3 h-3"></i>
</button>
` : ''}
<button class="btn-sm btn-danger" onclick="confirmDeleteQueue('${safeQueueId}')" title="${t('issues.deleteQueue') || 'Delete queue'}">
<i data-lucide="trash-2" class="w-3 h-3"></i>
</button>
@@ -989,6 +994,188 @@ async function executeQueueMerge(sourceQueueId) {
}
}
// ========== Queue Split Modal ==========
async function showSplitQueueModal(queueId) {
let modal = document.getElementById('splitQueueModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'splitQueueModal';
modal.className = 'issue-modal';
document.body.appendChild(modal);
}
// Fetch queue details
let queue;
try {
const response = await fetch('/api/queue/' + encodeURIComponent(queueId) + '?path=' + encodeURIComponent(projectPath));
queue = await response.json();
if (queue.error) throw new Error(queue.error);
} catch (err) {
showNotification('Failed to load queue details', 'error');
return;
}
const safeQueueId = escapeHtml(queueId || '');
const items = queue.solutions || queue.tasks || [];
const isSolutionLevel = !!queue.solutions;
// Group items by issue
const issueGroups = {};
items.forEach(item => {
const issueId = item.issue_id || 'unknown';
if (!issueGroups[issueId]) {
issueGroups[issueId] = [];
}
issueGroups[issueId].push(item);
});
const issueIds = Object.keys(issueGroups);
modal.innerHTML = `
<div class="issue-modal-content split-queue-modal-content">
<div class="issue-modal-header">
<h3><i data-lucide="git-branch" class="w-5 h-5"></i> Split Queue: ${safeQueueId}</h3>
<button class="issue-modal-close" onclick="hideSplitQueueModal()">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="issue-modal-body">
<p class="text-sm text-muted-foreground mb-4">
Select issues and their solutions to split into a new queue. The remaining items will stay in the current queue.
</p>
${issueIds.length === 0 ? `
<p class="text-center text-muted-foreground py-4">No items to split</p>
` : `
<div class="split-queue-controls mb-3">
<button class="btn-sm btn-secondary" onclick="selectAllIssues()">
<i data-lucide="check-square" class="w-3 h-3"></i> Select All
</button>
<button class="btn-sm btn-secondary" onclick="deselectAllIssues()">
<i data-lucide="square" class="w-3 h-3"></i> Deselect All
</button>
</div>
<div class="split-queue-issues">
${issueIds.map(issueId => {
const issueItems = issueGroups[issueId];
const safeIssueId = escapeHtml(issueId);
return `
<div class="split-queue-issue-group" data-issue-id="${safeIssueId}">
<div class="split-queue-issue-header">
<label class="flex items-center gap-2">
<input type="checkbox"
class="issue-checkbox"
data-issue-id="${safeIssueId}"
onchange="toggleIssueSelection('${safeIssueId}')">
<span class="font-medium">${safeIssueId}</span>
<span class="text-xs text-muted-foreground">(${issueItems.length} ${isSolutionLevel ? 'solution' : 'task'}${issueItems.length > 1 ? 's' : ''})</span>
</label>
</div>
<div class="split-queue-solutions ml-6">
${issueItems.map(item => {
const itemId = item.item_id || item.solution_id || item.task_id || '';
const safeItemId = escapeHtml(itemId);
const displayName = isSolutionLevel
? (item.solution_id || itemId)
: (item.task_id || itemId);
return `
<label class="flex items-center gap-2 py-1">
<input type="checkbox"
class="solution-checkbox"
data-issue-id="${safeIssueId}"
data-item-id="${safeItemId}"
value="${safeItemId}">
<span class="text-sm font-mono">${escapeHtml(displayName)}</span>
${item.task_count ? `<span class="text-xs text-muted-foreground">(${item.task_count} tasks)</span>` : ''}
</label>
`;
}).join('')}
</div>
</div>
`;
}).join('')}
</div>
`}
</div>
<div class="issue-modal-footer">
<button class="btn-secondary" onclick="hideSplitQueueModal()">Cancel</button>
${issueIds.length > 0 ? `
<button class="btn-primary" onclick="executeQueueSplit('${safeQueueId}')">
<i data-lucide="git-branch" class="w-4 h-4"></i>
<span>Split Queue</span>
</button>
` : ''}
</div>
</div>
`;
modal.classList.remove('hidden');
lucide.createIcons();
}
function hideSplitQueueModal() {
const modal = document.getElementById('splitQueueModal');
if (modal) {
modal.classList.add('hidden');
}
}
function toggleIssueSelection(issueId) {
const issueCheckbox = document.querySelector(`.issue-checkbox[data-issue-id="${issueId}"]`);
const solutionCheckboxes = document.querySelectorAll(`.solution-checkbox[data-issue-id="${issueId}"]`);
if (issueCheckbox && solutionCheckboxes) {
solutionCheckboxes.forEach(cb => {
cb.checked = issueCheckbox.checked;
});
}
}
function selectAllIssues() {
const allCheckboxes = document.querySelectorAll('.split-queue-modal-content input[type="checkbox"]');
allCheckboxes.forEach(cb => cb.checked = true);
}
function deselectAllIssues() {
const allCheckboxes = document.querySelectorAll('.split-queue-modal-content input[type="checkbox"]');
allCheckboxes.forEach(cb => cb.checked = false);
}
async function executeQueueSplit(sourceQueueId) {
const selectedCheckboxes = document.querySelectorAll('.solution-checkbox:checked');
const selectedItemIds = Array.from(selectedCheckboxes).map(cb => cb.value);
if (selectedItemIds.length === 0) {
showNotification('Please select at least one item to split', 'warning');
return;
}
try {
const response = await fetch('/api/queue/split?path=' + encodeURIComponent(projectPath), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceQueueId, itemIds: selectedItemIds })
});
const result = await response.json();
if (result.success) {
showNotification(`Split ${result.splitItemCount} items into new queue ${result.newQueueId}`, 'success');
hideSplitQueueModal();
queueData.expandedQueueId = null;
await Promise.all([loadQueueData(), loadAllQueues()]);
renderIssueView();
} else {
showNotification(result.error || 'Failed to split queue', 'error');
}
} catch (err) {
console.error('Failed to split queue:', err);
showNotification('Failed to split queue', 'error');
}
}
// ========== Legacy Queue Render (for backward compatibility) ==========
function renderLegacyQueueSection() {
const queue = issueData.queue;

View File

@@ -1024,7 +1024,9 @@ async function loadAndRenderMultiCliSummaryTab(session, contentArea) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderMultiCliSummaryContent(data.summary, session);
// Support both summaries (from .summaries/) and summary (from plan.json)
const summaryText = data.summary || (data.summaries?.length ? data.summaries[0].content : null);
contentArea.innerHTML = renderMultiCliSummaryContent(summaryText, session);
initCollapsibleSections(contentArea);
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
@@ -3135,16 +3137,38 @@ async function loadAndRenderLiteSummaryTab(session, contentArea) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderSummaryContent(data.summaries);
return;
// Prioritize .summaries/ directory content
if (data.summaries?.length) {
contentArea.innerHTML = renderSummaryContent(data.summaries);
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
}
// Fallback to plan.json summary field
if (data.summary) {
contentArea.innerHTML = renderSummaryContent([{ name: 'Summary', content: data.summary }]);
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
}
}
}
// Fallback
// Fallback: try to get summary from session object (plan.summary or synthesis.convergence.summary)
const plan = session.plan || {};
const synthesis = session.latestSynthesis || session.discussionTopic || {};
const summaryText = plan.summary || synthesis.convergence?.summary;
if (summaryText) {
contentArea.innerHTML = renderSummaryContent([{ name: 'Summary', content: summaryText }]);
if (typeof lucide !== 'undefined') lucide.createIcons();
return;
}
// No summary available
contentArea.innerHTML = `
<div class="tab-empty-state">
<div class="empty-icon"><i data-lucide="file-text" class="w-12 h-12"></i></div>
<div class="empty-title">No Summaries</div>
<div class="empty-text">No summaries found in .summaries/</div>
<div class="empty-title">${t('empty.noSummary') || 'No Summary'}</div>
<div class="empty-text">${t('empty.noSummaryText') || 'No summary available for this session.'}</div>
</div>
`;
if (typeof lucide !== 'undefined') lucide.createIcons();