feat: add support for Claude CLI tool and enhance memory features

- Added new CLI tool "Claude" with command handling in cli-executor.ts.
- Implemented session discovery for Claude in native-session-discovery.ts.
- Enhanced memory view with active memory controls, including sync functionality and configuration options.
- Introduced zoom and fit view controls for memory graph visualization.
- Updated i18n.js for new memory-related translations.
- Improved error handling and migration for CLI history store.
This commit is contained in:
catlog22
2025-12-13 22:44:42 +08:00
parent 52935d4b8e
commit d3a522f3e8
15 changed files with 2087 additions and 237 deletions

View File

@@ -1863,3 +1863,238 @@
}
}
/* ==========================================
UPDATE TASKS SECTION - In CLI Tab
========================================== */
/* Section Container */
.update-tasks-section {
border-bottom: 1px solid hsl(var(--border));
padding-bottom: 12px;
margin-bottom: 12px;
}
.update-tasks-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 8px;
}
.update-tasks-title {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.update-tasks-clear-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.update-tasks-clear-btn:hover {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
.update-tasks-list {
padding: 0 12px;
}
.update-tasks-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
text-align: center;
color: hsl(var(--muted-foreground));
}
.update-tasks-empty span {
font-size: 0.8125rem;
font-weight: 500;
}
.update-tasks-empty p {
font-size: 0.75rem;
margin: 4px 0 0;
}
/* CLI History Section */
.cli-history-section {
padding-top: 4px;
}
.cli-history-header {
display: flex;
align-items: center;
padding: 8px 12px;
margin-bottom: 8px;
}
.cli-history-title {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.cli-history-list {
padding: 0 12px;
}
/* Individual Update Task Item */
.update-task-item {
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
transition: all 0.15s ease;
}
.update-task-item:last-child {
margin-bottom: 0;
}
.update-task-item.status-pending {
border-left: 3px solid hsl(var(--muted-foreground));
}
.update-task-item.status-running {
border-left: 3px solid hsl(var(--warning));
background: hsl(var(--warning) / 0.05);
}
.update-task-item.status-completed {
border-left: 3px solid hsl(var(--success));
background: hsl(var(--success) / 0.05);
opacity: 0.8;
}
.update-task-item.status-failed {
border-left: 3px solid hsl(var(--destructive));
background: hsl(var(--destructive) / 0.05);
}
.update-task-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.update-task-status {
font-size: 14px;
flex-shrink: 0;
}
.update-task-item.status-running .update-task-status {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.update-task-name {
flex: 1;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.update-task-strategy {
font-size: 14px;
flex-shrink: 0;
}
.update-task-controls {
display: flex;
align-items: center;
gap: 6px;
}
.update-task-cli-select {
height: 28px;
padding: 0 8px;
font-size: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: hsl(var(--background));
color: hsl(var(--foreground));
cursor: pointer;
transition: all 0.15s;
}
.update-task-cli-select:hover:not(:disabled) {
border-color: hsl(var(--primary));
}
.update-task-cli-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.update-task-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.update-task-start {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.update-task-start:hover {
background: hsl(var(--primary) / 0.9);
transform: scale(1.05);
}
.update-task-remove {
background: transparent;
color: hsl(var(--muted-foreground));
}
.update-task-remove:hover {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
.update-task-stop {
background: hsl(var(--warning));
color: white;
}
.update-task-stop:hover {
background: hsl(var(--warning) / 0.9);
}
.update-task-message {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground));
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid hsl(var(--border));
}

View File

@@ -484,6 +484,11 @@
color: hsl(142 71% 35%);
}
.history-tool-tag.tool-claude {
background: hsl(25 90% 50% / 0.12);
color: hsl(25 90% 40%);
}
.history-mode-tag {
font-size: 0.625rem;
font-weight: 500;
@@ -713,6 +718,14 @@
border-color: hsl(142 71% 45% / 0.7);
}
.cli-tool-card.tool-claude.available {
border-color: hsl(25 90% 50% / 0.5);
}
.cli-tool-card.tool-claude.available:hover {
border-color: hsl(25 90% 50% / 0.7);
}
.cli-tool-card.unavailable {
border-color: hsl(var(--border));
opacity: 0.6;
@@ -1006,6 +1019,11 @@
color: hsl(142 71% 35%);
}
.cli-tool-claude {
background: hsl(25 90% 50% / 0.12);
color: hsl(25 90% 40%);
}
.cli-history-time {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground));
@@ -3187,6 +3205,11 @@
color: hsl(145 60% 35%);
}
.cli-queue-tool-tag.cli-tool-claude {
background: hsl(25 90% 50% / 0.15);
color: hsl(25 90% 40%);
}
.cli-queue-status {
font-size: 0.75rem;
}

View File

@@ -9,6 +9,10 @@
.memory-view {
height: 100%;
min-height: 600px;
max-height: calc(100vh - 150px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.memory-view.loading {
@@ -18,11 +22,241 @@
justify-content: center;
}
/* Memory Header with Active Memory Toggle */
.memory-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
margin-bottom: 1rem;
border-bottom: 1px solid hsl(var(--border));
flex-shrink: 0;
}
.memory-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.memory-header-left h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.memory-header-right {
display: flex;
align-items: center;
gap: 1rem;
}
/* Active Memory Controls Container */
.active-memory-controls {
display: flex;
align-items: center;
gap: 1rem;
}
/* Active Memory Toggle */
.active-memory-toggle {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Active Memory Config */
.active-memory-config {
display: flex;
align-items: center;
gap: 0.75rem;
padding-left: 0.75rem;
border-left: 1px solid hsl(var(--border));
}
.config-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
.config-item label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
.config-item select {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: hsl(var(--foreground));
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
cursor: pointer;
outline: none;
min-width: 80px;
}
.config-item select:focus {
border-color: hsl(var(--primary));
}
.config-item select:hover {
border-color: hsl(var(--primary) / 0.5);
}
/* Active Memory Actions */
.active-memory-actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding-left: 0.75rem;
border-left: 1px solid hsl(var(--border));
}
.last-sync {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
.toggle-label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: hsl(var(--muted));
border-radius: 24px;
transition: all 0.3s ease;
}
.toggle-slider::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle-switch input:checked + .toggle-slider {
background-color: hsl(var(--primary));
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(20px);
}
.toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.3);
}
.toggle-status {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: hsl(var(--muted) / 0.5);
}
.toggle-status.active {
display: flex;
align-items: center;
gap: 0.25rem;
color: hsl(142 76% 36%);
background: hsl(142 76% 36% / 0.1);
}
/* Auto-sync indicator */
.auto-sync-indicator {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
white-space: nowrap;
}
.auto-sync-indicator svg {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Sync Button */
.btn-sync {
padding: 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.3);
color: hsl(var(--primary));
cursor: pointer;
transition: all 0.2s ease;
}
.btn-sync:hover {
background: hsl(var(--primary) / 0.2);
border-color: hsl(var(--primary));
}
.btn-sync.syncing {
opacity: 0.7;
cursor: not-allowed;
}
.btn-sync.syncing i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.memory-columns {
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 1.5rem;
height: 100%;
flex: 1;
min-height: 0;
max-height: calc(100vh - 230px);
}
.memory-column {
@@ -33,6 +267,7 @@
border-radius: 0.75rem;
overflow: hidden;
min-width: 0;
max-height: 100%;
}
/* Memory Section inside columns */
@@ -40,6 +275,7 @@
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.section-header {
@@ -110,10 +346,15 @@
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
.hotspot-list-container {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
}
.hotspot-list-container:last-child {
@@ -130,6 +371,7 @@
margin: 0 0 0.5rem 0;
padding-bottom: 0.375rem;
border-bottom: 1px solid hsl(var(--border));
flex-shrink: 0;
}
/* Hotspot List Items */
@@ -267,8 +509,8 @@
}
.legend-dot.file { background: hsl(var(--primary)); }
.legend-dot.module { background: hsl(var(--muted-foreground)); }
.legend-dot.component { background: hsl(var(--success)); }
.legend-dot.module { background: hsl(var(--muted-foreground)); border: 1px dashed hsl(var(--muted-foreground)); }
.legend-dot.component { background: hsl(142 76% 36%); }
/* Graph Container */
.graph-container,
@@ -277,9 +519,11 @@
position: relative;
background: hsl(var(--background));
min-height: 300px;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.memory-graph-container svg {
@@ -425,8 +669,10 @@
flex: 1;
overflow-y: auto;
padding: 0.75rem;
min-height: 0;
}
/* Context Timeline Card Style */
.timeline-item {
display: flex;
gap: 0.75rem;
@@ -436,10 +682,21 @@
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
transition: all 0.15s ease;
cursor: pointer;
min-height: 60px;
max-height: 120px;
overflow: hidden;
}
.timeline-item:hover {
border-color: hsl(var(--primary) / 0.3);
background: hsl(var(--hover));
}
.timeline-item.expanded {
max-height: none;
background: hsl(var(--muted) / 0.3);
border-color: hsl(var(--primary) / 0.5);
}
.timeline-item:last-child {
@@ -641,6 +898,12 @@
.memory-columns {
grid-template-columns: 1fr;
gap: 1rem;
max-height: none;
overflow-y: auto;
}
.memory-column {
max-height: 500px;
}
}
@@ -654,6 +917,7 @@
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
max-height: 100%;
}
.hotspots-header {
@@ -683,6 +947,7 @@
flex: 1;
overflow-y: auto;
padding: 0.5rem;
min-height: 0;
}
.hotspot-item {
@@ -793,6 +1058,7 @@
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
max-height: 100%;
}
.graph-header {
@@ -828,7 +1094,9 @@
flex: 1;
position: relative;
background: hsl(var(--background));
min-height: 400px;
min-height: 300px;
max-height: 100%;
overflow: hidden;
}
/* D3 Graph Elements */
@@ -943,6 +1211,111 @@
}
}
/* Graph Zoom/Pan Styles */
.memory-graph-svg {
cursor: grab;
}
.memory-graph-svg:active {
cursor: grabbing;
}
.graph-content {
transition: transform 0.1s ease-out;
}
/* Graph Node Groups */
.graph-node-group {
cursor: pointer;
}
.graph-node-group:hover circle {
filter: brightness(1.2);
}
.graph-node-group.file circle {
fill: hsl(var(--primary));
stroke: hsl(var(--primary));
stroke-width: 2;
}
.graph-node-group.module circle {
fill: hsl(var(--muted));
stroke: hsl(var(--muted-foreground));
stroke-width: 2;
stroke-dasharray: 4 2;
}
.graph-node-group.component circle {
fill: hsl(142 76% 36%);
stroke: hsl(142 76% 36%);
stroke-width: 2;
}
/* Individual graph-node circles (for legacy support) */
.graph-node.file {
fill: hsl(var(--primary));
stroke: hsl(var(--primary));
stroke-width: 2;
}
.graph-node.module {
fill: hsl(var(--muted));
stroke: hsl(var(--muted-foreground));
stroke-width: 2;
stroke-dasharray: 4 2;
}
.graph-node.component {
fill: hsl(142 76% 36%);
stroke: hsl(142 76% 36%);
stroke-width: 2;
}
/* Selected Node */
.graph-node.selected {
stroke: hsl(var(--foreground));
stroke-width: 3;
filter: drop-shadow(0 0 8px hsl(var(--primary)));
}
/* Graph Labels */
.graph-label {
font-family: var(--font-sans);
font-size: 11px;
fill: hsl(var(--foreground));
pointer-events: none;
user-select: none;
text-shadow:
1px 1px 2px hsl(var(--background)),
-1px -1px 2px hsl(var(--background)),
1px -1px 2px hsl(var(--background)),
-1px 1px 2px hsl(var(--background));
}
/* Graph Controls */
.graph-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.graph-controls .btn-icon {
padding: 0.375rem;
border-radius: 0.375rem;
background: transparent;
border: 1px solid transparent;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
}
.graph-controls .btn-icon:hover {
background: hsl(var(--muted));
border-color: hsl(var(--border));
color: hsl(var(--foreground));
}
/* ========================================
* Context Timeline (Right Column)
* ======================================== */
@@ -953,6 +1326,7 @@
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
max-height: 100%;
}
.context-header {
@@ -982,37 +1356,10 @@
flex: 1;
overflow-y: auto;
padding: 0.75rem;
min-height: 0;
}
.timeline-item {
position: relative;
padding-left: 1.5rem;
padding-bottom: 1rem;
border-left: 2px solid hsl(var(--border));
}
.timeline-item:last-child {
border-left-color: transparent;
padding-bottom: 0;
}
.timeline-item::before {
content: '';
position: absolute;
left: -6px;
top: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: hsl(var(--primary));
border: 2px solid hsl(var(--card));
}
.timeline-item.recent::before {
background: hsl(0 84% 60%);
box-shadow: 0 0 8px hsl(0 84% 60% / 0.5);
animation: timelinePulse 2s infinite;
}
/* Timeline item styles moved to Context Timeline Card Style section (line ~445) */
.timeline-timestamp {
font-size: 0.6875rem;

View File

@@ -2,7 +2,7 @@
// Displays CLI tool availability status and allows setting default tool
// ========== CLI State ==========
let cliToolStatus = { gemini: {}, qwen: {}, codex: {} };
let cliToolStatus = { gemini: {}, qwen: {}, codex: {}, claude: {} };
let codexLensStatus = { ready: false };
let semanticStatus = { available: false };
let defaultCliTool = 'gemini';
@@ -105,16 +105,18 @@ function renderCliStatus() {
const toolDescriptions = {
gemini: 'Google AI for code analysis',
qwen: 'Alibaba AI assistant',
codex: 'OpenAI code generation'
codex: 'OpenAI code generation',
claude: 'Anthropic AI assistant'
};
const toolIcons = {
gemini: 'sparkle',
qwen: 'bot',
codex: 'code-2'
codex: 'code-2',
claude: 'brain'
};
const tools = ['gemini', 'qwen', 'codex'];
const tools = ['gemini', 'qwen', 'codex', 'claude'];
const toolsHtml = tools.map(tool => {
const status = cliToolStatus[tool] || {};
@@ -270,7 +272,7 @@ function renderCliStatus() {
<span class="cli-toggle-slider"></span>
</label>
</div>
<p class="cli-setting-desc">Use native tool resume (gemini -r, qwen --resume, codex resume)</p>
<p class="cli-setting-desc">Use native tool resume (gemini -r, qwen --resume, codex resume, claude --resume)</p>
</div>
<div class="cli-setting-item ${!smartContextEnabled ? 'disabled' : ''}">
<label class="cli-setting-label">

View File

@@ -9,6 +9,10 @@ let cliQueueData = [];
let currentQueueTab = 'tasks'; // 'tasks' | 'cli'
let cliCategoryFilter = 'all'; // 'all' | 'user' | 'internal' | 'insight'
// Update task queue data (for CLAUDE.md updates from explorer)
let sidebarUpdateTasks = [];
let isSidebarTaskRunning = {}; // Track running tasks by id
/**
* Initialize task queue sidebar
*/
@@ -65,10 +69,36 @@ function initTaskQueueSidebar() {
</div>
<div class="task-queue-content" id="cliQueueContent" style="display: none;">
<div class="task-queue-empty-state">
<div class="task-queue-empty-icon">⚡</div>
<div class="task-queue-empty-text">No CLI executions</div>
<div class="task-queue-empty-hint">CLI tool executions will appear here</div>
<!-- Update Tasks Section -->
<div class="update-tasks-section" id="updateTasksSection">
<div class="update-tasks-header">
<span class="update-tasks-title">📝 ${t('taskQueue.title')}</span>
<button class="update-tasks-clear-btn" onclick="clearCompletedUpdateTasks()" title="${t('taskQueue.clearCompleted')}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</button>
</div>
<div class="update-tasks-list" id="updateTasksList">
<div class="update-tasks-empty">
<span>${t('taskQueue.noTasks')}</span>
<p>${t('taskQueue.noTasksHint')}</p>
</div>
</div>
</div>
<!-- CLI History Section -->
<div class="cli-history-section" id="cliHistorySection">
<div class="cli-history-header">
<span class="cli-history-title">⚡ ${t('title.cliHistory')}</span>
</div>
<div class="cli-history-list" id="cliHistoryList">
<div class="task-queue-empty-state">
<div class="task-queue-empty-icon">⚡</div>
<div class="task-queue-empty-text">No internal executions</div>
<div class="task-queue-empty-hint">CLI tool executions will appear here</div>
</div>
</div>
</div>
</div>
</div>
@@ -89,7 +119,7 @@ function initTaskQueueSidebar() {
updateTaskQueueData();
updateCliQueueData();
renderTaskQueue();
renderTaskQueueSidebar();
renderCliQueue();
updateTaskQueueBadge();
}
@@ -114,7 +144,7 @@ function toggleTaskQueueSidebar() {
toggle.classList.add('hidden');
// Refresh data when opened
updateTaskQueueData();
renderTaskQueue();
renderTaskQueueSidebar();
} else {
sidebar.classList.remove('open');
overlay.classList.remove('show');
@@ -182,9 +212,10 @@ function updateTaskQueueData() {
}
/**
* Render task queue list
* Render task queue list in sidebar
* Note: Named renderTaskQueueSidebar to avoid conflict with explorer.js renderTaskQueue
*/
function renderTaskQueue(filter) {
function renderTaskQueueSidebar(filter) {
filter = filter || 'all';
var contentEl = document.getElementById('taskQueueContent');
if (!contentEl) {
@@ -249,7 +280,7 @@ function filterTaskQueue(filter) {
document.querySelectorAll('.task-filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === filter);
});
renderTaskQueue(filter);
renderTaskQueueSidebar(filter);
}
/**
@@ -307,7 +338,7 @@ function updateTaskQueueBadge() {
function refreshTaskQueue() {
updateTaskQueueData();
updateCliQueueData();
renderTaskQueue();
renderTaskQueueSidebar();
renderCliQueue();
updateTaskQueueBadge();
}
@@ -365,7 +396,7 @@ async function updateCliQueueData() {
* Render CLI queue list
*/
function renderCliQueue() {
const contentEl = document.getElementById('cliQueueContent');
const contentEl = document.getElementById('cliHistoryList');
if (!contentEl) return;
// Filter by category
@@ -459,3 +490,227 @@ function getCliTimeAgo(date) {
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
// ==========================================
// UPDATE TASK QUEUE - For CLAUDE.md Updates
// ==========================================
/**
* Add update task to sidebar queue (called from explorer)
*/
function addUpdateTaskToSidebar(path, tool = 'gemini', strategy = 'single-layer') {
const task = {
id: Date.now(),
path,
tool,
strategy,
status: 'pending', // pending, running, completed, failed
message: '',
addedAt: new Date().toISOString()
};
sidebarUpdateTasks.push(task);
renderSidebarUpdateTasks();
updateCliTabBadge();
// Open sidebar and switch to CLI tab if not visible
if (!isTaskQueueSidebarVisible) {
toggleTaskQueueSidebar();
}
switchQueueTab('cli');
}
/**
* Remove update task from queue
*/
function removeUpdateTask(taskId) {
sidebarUpdateTasks = sidebarUpdateTasks.filter(t => t.id !== taskId);
renderSidebarUpdateTasks();
updateCliTabBadge();
}
/**
* Clear completed/failed update tasks
*/
function clearCompletedUpdateTasks() {
sidebarUpdateTasks = sidebarUpdateTasks.filter(t => t.status === 'pending' || t.status === 'running');
renderSidebarUpdateTasks();
updateCliTabBadge();
}
/**
* Update CLI tool for a specific task
*/
function updateSidebarTaskCliTool(taskId, tool) {
const task = sidebarUpdateTasks.find(t => t.id === taskId);
if (task && task.status === 'pending') {
task.tool = tool;
}
}
/**
* Execute a single update task
*/
async function executeSidebarUpdateTask(taskId) {
const task = sidebarUpdateTasks.find(t => t.id === taskId);
if (!task || task.status !== 'pending') return;
const folderName = task.path.split('/').pop() || task.path;
// Update status to running
task.status = 'running';
task.message = t('taskQueue.processing');
isSidebarTaskRunning[taskId] = true;
renderSidebarUpdateTasks();
if (typeof addGlobalNotification === 'function') {
addGlobalNotification('info', `Processing: ${folderName}`, `Strategy: ${task.strategy}, Tool: ${task.tool}`, 'Explorer');
}
try {
const response = await fetch('/api/update-claude-md', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: task.path,
tool: task.tool,
strategy: task.strategy
})
});
const result = await response.json();
if (result.success) {
task.status = 'completed';
task.message = t('taskQueue.updated');
if (typeof addGlobalNotification === 'function') {
addGlobalNotification('success', `Completed: ${folderName}`, result.message, 'Explorer');
}
} else {
task.status = 'failed';
task.message = result.error || t('taskQueue.failed');
if (typeof addGlobalNotification === 'function') {
addGlobalNotification('error', `Failed: ${folderName}`, result.error || 'Unknown error', 'Explorer');
}
}
} catch (error) {
task.status = 'failed';
task.message = error.message;
if (typeof addGlobalNotification === 'function') {
addGlobalNotification('error', `Error: ${folderName}`, error.message, 'Explorer');
}
} finally {
delete isSidebarTaskRunning[taskId];
renderSidebarUpdateTasks();
updateCliTabBadge();
// Refresh tree to show updated CLAUDE.md files
if (typeof loadExplorerTree === 'function' && typeof explorerCurrentPath !== 'undefined') {
loadExplorerTree(explorerCurrentPath);
}
}
}
/**
* Stop/cancel a running update task (if possible)
*/
function stopSidebarUpdateTask(taskId) {
// Currently just removes the task - actual cancellation would need AbortController
const task = sidebarUpdateTasks.find(t => t.id === taskId);
if (task && task.status === 'running') {
task.status = 'failed';
task.message = 'Cancelled';
delete isSidebarTaskRunning[taskId];
renderSidebarUpdateTasks();
updateCliTabBadge();
}
}
/**
* Render update task queue list
*/
function renderSidebarUpdateTasks() {
const listEl = document.getElementById('updateTasksList');
if (!listEl) return;
if (sidebarUpdateTasks.length === 0) {
listEl.innerHTML = `
<div class="update-tasks-empty">
<span>${t('taskQueue.noTasks')}</span>
<p>${t('taskQueue.noTasksHint')}</p>
</div>
`;
return;
}
listEl.innerHTML = sidebarUpdateTasks.map(task => {
const folderName = task.path.split('/').pop() || task.path;
const strategyIcon = task.strategy === 'multi-layer' ? '📂' : '📄';
const strategyLabel = task.strategy === 'multi-layer'
? t('taskQueue.withSubdirs')
: t('taskQueue.currentOnly');
const statusIcon = {
'pending': '⏳',
'running': '🔄',
'completed': '✅',
'failed': '❌'
}[task.status];
const isPending = task.status === 'pending';
const isRunning = task.status === 'running';
return `
<div class="update-task-item status-${task.status}" data-task-id="${task.id}">
<div class="update-task-header">
<span class="update-task-status">${statusIcon}</span>
<span class="update-task-name" title="${escapeHtml(task.path)}">${escapeHtml(folderName)}</span>
<span class="update-task-strategy" title="${strategyLabel}">${strategyIcon}</span>
</div>
<div class="update-task-controls">
<select class="update-task-cli-select"
onchange="updateSidebarTaskCliTool(${task.id}, this.value)"
${!isPending ? 'disabled' : ''}>
<option value="gemini" ${task.tool === 'gemini' ? 'selected' : ''}>Gemini</option>
<option value="qwen" ${task.tool === 'qwen' ? 'selected' : ''}>Qwen</option>
<option value="codex" ${task.tool === 'codex' ? 'selected' : ''}>Codex</option>
<option value="claude" ${task.tool === 'claude' ? 'selected' : ''}>Claude</option>
</select>
${isPending ? `
<button class="update-task-btn update-task-start" onclick="executeSidebarUpdateTask(${task.id})" title="${t('taskQueue.startAll')}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
</button>
<button class="update-task-btn update-task-remove" onclick="removeUpdateTask(${task.id})" title="Remove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
` : ''}
${isRunning ? `
<button class="update-task-btn update-task-stop" onclick="stopSidebarUpdateTask(${task.id})" title="Stop">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12"/>
</svg>
</button>
` : ''}
</div>
${task.message ? `<div class="update-task-message">${escapeHtml(task.message)}</div>` : ''}
</div>
`;
}).join('');
}
/**
* Update CLI tab badge with pending update tasks count
*/
function updateCliTabBadge() {
const pendingCount = sidebarUpdateTasks.filter(t => t.status === 'pending' || t.status === 'running').length;
const cliTabBadge = document.getElementById('cliTabBadge');
if (cliTabBadge) {
const totalCount = pendingCount + cliQueueData.length;
cliTabBadge.textContent = totalCount;
cliTabBadge.style.display = totalCount > 0 ? 'inline' : 'none';
}
}

View File

@@ -569,6 +569,9 @@ const i18n = {
'memory.memoryGraph': 'Memory Graph',
'memory.nodes': 'nodes',
'memory.resetView': 'Reset View',
'memory.zoomIn': 'Zoom In',
'memory.zoomOut': 'Zoom Out',
'memory.fitView': 'Fit to View',
'memory.file': 'File',
'memory.module': 'Module',
'memory.component': 'Component',
@@ -580,6 +583,7 @@ const i18n = {
'memory.noRecentActivity': 'No recent activity',
'memory.reads': 'Reads',
'memory.edits': 'Edits',
'memory.mentions': 'Mentions',
'memory.prompts': 'Prompts',
'memory.nodeDetails': 'Node Details',
'memory.heat': 'Heat',
@@ -590,6 +594,25 @@ const i18n = {
'memory.justNow': 'Just now',
'memory.minutesAgo': 'minutes ago',
'memory.hoursAgo': 'hours ago',
'memory.title': 'Memory',
'memory.activeMemory': 'Active Memory',
'memory.active': 'Active',
'memory.inactive': 'Inactive',
'memory.syncNow': 'Sync Now',
'memory.syncComplete': 'Sync complete',
'memory.syncError': 'Sync failed',
'memory.filesAnalyzed': 'files analyzed',
'memory.activeMemoryEnabled': 'Active Memory enabled',
'memory.activeMemoryDisabled': 'Active Memory disabled',
'memory.activeMemoryError': 'Failed to toggle Active Memory',
'memory.interval': 'Interval',
'memory.intervalManual': 'Manual',
'memory.minutes': 'min',
'memory.cliTool': 'CLI',
'memory.lastSync': 'Last sync',
'memory.autoSyncActive': 'Auto-sync',
'memory.configUpdated': 'Configuration updated',
'memory.configError': 'Failed to update configuration',
// Common
'common.cancel': 'Cancel',
@@ -1170,6 +1193,9 @@ const i18n = {
'memory.memoryGraph': '记忆图谱',
'memory.nodes': '节点',
'memory.resetView': '重置视图',
'memory.zoomIn': '放大',
'memory.zoomOut': '缩小',
'memory.fitView': '自适应',
'memory.file': '文件',
'memory.module': '模块',
'memory.component': '组件',
@@ -1181,6 +1207,7 @@ const i18n = {
'memory.noRecentActivity': '无最近活动',
'memory.reads': '读取',
'memory.edits': '编辑',
'memory.mentions': '提及',
'memory.prompts': '提示',
'memory.nodeDetails': '节点详情',
'memory.heat': '热度',
@@ -1191,6 +1218,25 @@ const i18n = {
'memory.justNow': '刚刚',
'memory.minutesAgo': '分钟前',
'memory.hoursAgo': '小时前',
'memory.title': '记忆',
'memory.activeMemory': '活动记忆',
'memory.active': '已启用',
'memory.inactive': '未启用',
'memory.syncNow': '立即同步',
'memory.syncComplete': '同步完成',
'memory.syncError': '同步失败',
'memory.filesAnalyzed': '个文件已分析',
'memory.activeMemoryEnabled': '活动记忆已启用',
'memory.activeMemoryDisabled': '活动记忆已禁用',
'memory.activeMemoryError': '切换活动记忆失败',
'memory.interval': '间隔',
'memory.intervalManual': '手动',
'memory.minutes': '分钟',
'memory.cliTool': 'CLI',
'memory.lastSync': '上次同步',
'memory.autoSyncActive': '自动同步',
'memory.configUpdated': '配置已更新',
'memory.configError': '配置更新失败',
// Common
'common.cancel': '取消',

View File

@@ -104,6 +104,7 @@ async function renderExplorer() {
<option value="gemini">Gemini</option>
<option value="qwen">Qwen</option>
<option value="codex">Codex</option>
<option value="claude">Claude</option>
</select>
</div>
<div class="task-queue-actions">
@@ -707,12 +708,17 @@ function addUpdateTask(path, tool = 'gemini', strategy = 'single-layer') {
* Add task from folder context (right-click or button)
*/
function addFolderToQueue(folderPath, strategy = 'single-layer') {
// Use the selected CLI tool from the queue panel
addUpdateTask(folderPath, defaultCliTool, strategy);
// Show task queue if not visible
if (!isTaskQueueVisible) {
toggleTaskQueue();
// Use the sidebar queue instead of floating panel
if (typeof addUpdateTaskToSidebar === 'function') {
addUpdateTaskToSidebar(folderPath, defaultCliTool, strategy);
} else {
// Fallback to local queue
addUpdateTask(folderPath, defaultCliTool, strategy);
// Show task queue if not visible
if (!isTaskQueueVisible) {
toggleTaskQueue();
}
}
}

View File

@@ -7,6 +7,13 @@ var memoryGraphData = null;
var recentContext = [];
var memoryTimeFilter = 'all'; // 'today', 'week', 'all'
var selectedNode = null;
var activeMemoryEnabled = false;
var activeMemoryStatus = null;
var activeMemoryConfig = {
interval: 'manual', // manual, 5, 15, 30, 60 (minutes)
tool: 'gemini' // gemini, qwen
};
var activeMemorySyncTimer = null; // Timer for automatic periodic sync
// ========== Main Render Function ==========
async function renderMemoryView() {
@@ -29,11 +36,20 @@ async function renderMemoryView() {
await Promise.all([
loadMemoryStats(),
loadMemoryGraph(),
loadRecentContext()
loadRecentContext(),
loadActiveMemoryStatus()
]);
// Render three-column layout
// Render layout with Active Memory header
container.innerHTML = '<div class="memory-view">' +
'<div class="memory-header">' +
'<div class="memory-header-left">' +
'<h2><i data-lucide="brain" class="w-5 h-5"></i> ' + t('memory.title') + '</h2>' +
'</div>' +
'<div class="memory-header-right">' +
renderActiveMemoryControls() +
'</div>' +
'</div>' +
'<div class="memory-columns">' +
'<div class="memory-column left" id="memory-hotspots"></div>' +
'<div class="memory-column center" id="memory-graph"></div>' +
@@ -50,6 +66,56 @@ async function renderMemoryView() {
if (window.lucide) lucide.createIcons();
}
function renderActiveMemoryControls() {
var html = '<div class="active-memory-controls">' +
'<div class="active-memory-toggle">' +
'<span class="toggle-label">' + t('memory.activeMemory') + '</span>' +
'<label class="toggle-switch">' +
'<input type="checkbox" id="activeMemorySwitch" ' + (activeMemoryEnabled ? 'checked' : '') + ' onchange="toggleActiveMemory(this.checked)">' +
'<span class="toggle-slider"></span>' +
'</label>' +
(activeMemoryEnabled ? '<span class="toggle-status active"><i data-lucide="zap" class="w-3 h-3"></i> ' + t('memory.active') + '</span>' : '<span class="toggle-status">' + t('memory.inactive') + '</span>') +
'</div>';
if (activeMemoryEnabled) {
var isAutoSync = activeMemoryConfig.interval !== 'manual';
html += '<div class="active-memory-config">' +
// Interval selector
'<div class="config-item">' +
'<label>' + t('memory.interval') + '</label>' +
'<select id="activeMemoryInterval" onchange="updateActiveMemoryConfig(\'interval\', this.value)">' +
'<option value="manual"' + (activeMemoryConfig.interval === 'manual' ? ' selected' : '') + '>' + t('memory.intervalManual') + '</option>' +
'<option value="5"' + (activeMemoryConfig.interval === '5' ? ' selected' : '') + '>5 ' + t('memory.minutes') + '</option>' +
'<option value="15"' + (activeMemoryConfig.interval === '15' ? ' selected' : '') + '>15 ' + t('memory.minutes') + '</option>' +
'<option value="30"' + (activeMemoryConfig.interval === '30' ? ' selected' : '') + '>30 ' + t('memory.minutes') + '</option>' +
'<option value="60"' + (activeMemoryConfig.interval === '60' ? ' selected' : '') + '>60 ' + t('memory.minutes') + '</option>' +
'</select>' +
'</div>' +
// CLI tool selector
'<div class="config-item">' +
'<label>' + t('memory.cliTool') + '</label>' +
'<select id="activeMemoryCli" onchange="updateActiveMemoryConfig(\'tool\', this.value)">' +
'<option value="gemini"' + (activeMemoryConfig.tool === 'gemini' ? ' selected' : '') + '>Gemini</option>' +
'<option value="qwen"' + (activeMemoryConfig.tool === 'qwen' ? ' selected' : '') + '>Qwen</option>' +
'</select>' +
'</div>' +
// Auto-sync indicator
(isAutoSync ? '<div class="auto-sync-indicator"><i data-lucide="timer" class="w-3 h-3"></i> ' + t('memory.autoSyncActive') + '</div>' : '') +
'</div>' +
// Sync button and status
'<div class="active-memory-actions">' +
'<button class="btn-icon btn-sync" onclick="syncActiveMemory()" title="' + t('memory.syncNow') + '">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i>' +
'</button>' +
(activeMemoryStatus && activeMemoryStatus.lastSync ?
'<span class="last-sync">' + t('memory.lastSync') + ': ' + formatTimestamp(activeMemoryStatus.lastSync) + '</span>' : '') +
'</div>';
}
html += '</div>';
return html;
}
// ========== Data Loading ==========
async function loadMemoryStats() {
try {
@@ -93,6 +159,168 @@ async function loadRecentContext() {
}
}
// ========== Active Memory Functions ==========
// Timer management for automatic sync
function startActiveMemorySyncTimer() {
// Clear any existing timer
stopActiveMemorySyncTimer();
// Only start timer if interval is not manual
if (activeMemoryConfig.interval === 'manual' || !activeMemoryEnabled) {
return;
}
var intervalMs = parseInt(activeMemoryConfig.interval, 10) * 60 * 1000; // Convert minutes to ms
console.log('[ActiveMemory] Starting auto-sync timer:', activeMemoryConfig.interval, 'minutes');
activeMemorySyncTimer = setInterval(function() {
console.log('[ActiveMemory] Auto-sync triggered');
syncActiveMemory();
}, intervalMs);
}
function stopActiveMemorySyncTimer() {
if (activeMemorySyncTimer) {
console.log('[ActiveMemory] Stopping auto-sync timer');
clearInterval(activeMemorySyncTimer);
activeMemorySyncTimer = null;
}
}
async function loadActiveMemoryStatus() {
try {
var response = await fetch('/api/memory/active/status');
if (!response.ok) throw new Error('Failed to load active memory status');
var data = await response.json();
activeMemoryEnabled = data.enabled || false;
activeMemoryStatus = data.status || null;
// Load config if available
if (data.config) {
activeMemoryConfig = Object.assign(activeMemoryConfig, data.config);
}
// Start timer if active memory is enabled and interval is not manual
if (activeMemoryEnabled && activeMemoryConfig.interval !== 'manual') {
startActiveMemorySyncTimer();
}
return data;
} catch (err) {
console.error('Failed to load active memory status:', err);
activeMemoryEnabled = false;
activeMemoryStatus = null;
return { enabled: false };
}
}
async function toggleActiveMemory(enabled) {
try {
var response = await fetch('/api/memory/active/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: enabled,
config: activeMemoryConfig
})
});
if (!response.ok) throw new Error('Failed to toggle active memory');
var data = await response.json();
activeMemoryEnabled = data.enabled;
// Manage auto-sync timer based on enabled state
if (activeMemoryEnabled) {
startActiveMemorySyncTimer();
} else {
stopActiveMemorySyncTimer();
}
// Show notification
if (window.showToast) {
showToast(enabled ? t('memory.activeMemoryEnabled') : t('memory.activeMemoryDisabled'), 'success');
}
// Re-render the view to update UI
renderMemoryView();
} catch (err) {
console.error('Failed to toggle active memory:', err);
if (window.showToast) {
showToast(t('memory.activeMemoryError'), 'error');
}
// Revert checkbox state
var checkbox = document.getElementById('activeMemorySwitch');
if (checkbox) checkbox.checked = !enabled;
}
}
async function updateActiveMemoryConfig(key, value) {
activeMemoryConfig[key] = value;
try {
var response = await fetch('/api/memory/active/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: activeMemoryConfig })
});
if (!response.ok) throw new Error('Failed to update config');
// Restart timer if interval changed and active memory is enabled
if (key === 'interval' && activeMemoryEnabled) {
startActiveMemorySyncTimer();
}
if (window.showToast) {
showToast(t('memory.configUpdated'), 'success');
}
} catch (err) {
console.error('Failed to update active memory config:', err);
if (window.showToast) {
showToast(t('memory.configError'), 'error');
}
}
}
async function syncActiveMemory() {
var syncBtn = document.querySelector('.btn-sync');
if (syncBtn) {
syncBtn.classList.add('syncing');
syncBtn.disabled = true;
}
try {
var response = await fetch('/api/memory/active/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool: activeMemoryConfig.tool
})
});
if (!response.ok) throw new Error('Failed to sync active memory');
var data = await response.json();
if (window.showToast) {
showToast(t('memory.syncComplete') + ' (' + (data.filesAnalyzed || 0) + ' ' + t('memory.filesAnalyzed') + ')', 'success');
}
// Refresh data and update last sync time
await loadActiveMemoryStatus();
// Update last sync display without full re-render
var lastSyncEl = document.querySelector('.last-sync');
if (lastSyncEl && activeMemoryStatus && activeMemoryStatus.lastSync) {
lastSyncEl.textContent = t('memory.lastSync') + ': ' + formatTimestamp(activeMemoryStatus.lastSync);
}
} catch (err) {
console.error('Failed to sync active memory:', err);
if (window.showToast) {
showToast(t('memory.syncError'), 'error');
}
} finally {
if (syncBtn) {
syncBtn.classList.remove('syncing');
syncBtn.disabled = false;
}
}
}
// ========== Left Column: Context Hotspots ==========
function renderHotspotsColumn() {
var container = document.getElementById('memory-hotspots');
@@ -163,6 +391,12 @@ function renderHotspotList(items, type) {
}
// ========== Center Column: Memory Graph ==========
// Store graph state for zoom/pan
var graphZoom = null;
var graphSvg = null;
var graphGroup = null;
var graphSimulation = null;
function renderGraphColumn() {
var container = document.getElementById('memory-graph');
if (!container) return;
@@ -173,10 +407,19 @@ function renderGraphColumn() {
'<h3><i data-lucide="network" class="w-4 h-4"></i> ' + t('memory.memoryGraph') + '</h3>' +
'<span class="section-count">' + (memoryGraphData.nodes || []).length + ' ' + t('memory.nodes') + '</span>' +
'</div>' +
'<div class="section-header-actions">' +
'<button class="btn-icon" onclick="resetGraphView()" title="' + t('memory.resetView') + '">' +
'<div class="section-header-actions graph-controls">' +
'<button class="btn-icon" onclick="zoomGraphIn()" title="' + t('memory.zoomIn') + '">' +
'<i data-lucide="zoom-in" class="w-4 h-4"></i>' +
'</button>' +
'<button class="btn-icon" onclick="zoomGraphOut()" title="' + t('memory.zoomOut') + '">' +
'<i data-lucide="zoom-out" class="w-4 h-4"></i>' +
'</button>' +
'<button class="btn-icon" onclick="fitGraphToView()" title="' + t('memory.fitView') + '">' +
'<i data-lucide="maximize-2" class="w-4 h-4"></i>' +
'</button>' +
'<button class="btn-icon" onclick="resetGraphView()" title="' + t('memory.resetView') + '">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'</div>' +
'<div class="memory-graph-container" id="memoryGraphSvg"></div>' +
@@ -223,81 +466,140 @@ function renderMemoryGraph(graphData) {
if (!container) return;
var width = container.clientWidth || 600;
var height = container.clientHeight || 500;
var height = container.clientHeight || 400;
// Clear existing
container.innerHTML = '';
var svg = d3.select('#memoryGraphSvg')
// Filter and clean nodes - remove invalid names (like JSON data)
var cleanNodes = graphData.nodes.filter(function(node) {
var name = node.name || node.id || '';
// Filter out JSON-like data, error messages, and very long strings
if (name.length > 100) return false;
if (name.includes('"status"') || name.includes('"content"')) return false;
if (name.includes('"todos"') || name.includes('"activeForm"')) return false;
if (name.startsWith('{') || name.startsWith('[')) return false;
// Allow all valid node types: file, module, component
return true;
}).map(function(node) {
// Truncate long names for display
var displayName = node.name || node.id || 'Unknown';
if (displayName.length > 25) {
displayName = displayName.substring(0, 22) + '...';
}
return Object.assign({}, node, { displayName: displayName });
});
// Filter edges to only include valid nodes
var nodeIds = new Set(cleanNodes.map(function(n) { return n.id; }));
var cleanEdges = graphData.edges.filter(function(edge) {
var sourceId = typeof edge.source === 'object' ? edge.source.id : edge.source;
var targetId = typeof edge.target === 'object' ? edge.target.id : edge.target;
return nodeIds.has(sourceId) && nodeIds.has(targetId);
});
// Create SVG with zoom support
graphSvg = d3.select('#memoryGraphSvg')
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('class', 'memory-graph-svg');
.attr('class', 'memory-graph-svg')
.attr('viewBox', [0, 0, width, height]);
// Create a group for zoom/pan transformations
graphGroup = graphSvg.append('g').attr('class', 'graph-content');
// Setup zoom behavior
graphZoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', function(event) {
graphGroup.attr('transform', event.transform);
});
graphSvg.call(graphZoom);
// Create force simulation
var simulation = d3.forceSimulation(graphData.nodes)
.force('link', d3.forceLink(graphData.edges).id(function(d) { return d.id; }).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
graphSimulation = d3.forceSimulation(cleanNodes)
.force('link', d3.forceLink(cleanEdges).id(function(d) { return d.id; }).distance(80))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(function(d) { return (d.heat || 10) + 5; }));
.force('collision', d3.forceCollide().radius(function(d) { return Math.max(15, (d.heat || 10) + 10); }))
.force('x', d3.forceX(width / 2).strength(0.05))
.force('y', d3.forceY(height / 2).strength(0.05));
// Draw edges
var link = svg.append('g')
var link = graphGroup.append('g')
.attr('class', 'graph-links')
.selectAll('line')
.data(graphData.edges)
.data(cleanEdges)
.enter()
.append('line')
.attr('class', 'graph-edge')
.attr('stroke-width', function(d) { return Math.sqrt(d.weight || 1); });
// Draw nodes
var node = svg.append('g')
.selectAll('circle')
.data(graphData.nodes)
var node = graphGroup.append('g')
.attr('class', 'graph-nodes')
.selectAll('g')
.data(cleanNodes)
.enter()
.append('circle')
.attr('class', function(d) { return 'graph-node ' + (d.type || 'file'); })
.attr('r', function(d) { return (d.heat || 10); })
.attr('data-id', function(d) { return d.id; })
.append('g')
.attr('class', function(d) { return 'graph-node-group ' + (d.type || 'file'); })
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
.on('click', function(event, d) {
event.stopPropagation();
selectNode(d);
});
// Node labels
var label = svg.append('g')
.selectAll('text')
.data(graphData.nodes)
.enter()
.append('text')
// Add circles to nodes
node.append('circle')
.attr('class', function(d) { return 'graph-node ' + (d.type || 'file'); })
.attr('r', function(d) { return Math.max(8, Math.min(20, (d.heat || 10))); })
.attr('data-id', function(d) { return d.id; });
// Add labels to nodes
node.append('text')
.attr('class', 'graph-label')
.text(function(d) { return d.name || d.id; })
.attr('x', 8)
.attr('y', 3);
.text(function(d) {
// Show file count for modules
if (d.type === 'module' && d.fileCount) {
return d.displayName + ' (' + d.fileCount + ')';
}
return d.displayName;
})
.attr('x', function(d) { return Math.max(10, (d.heat || 10)) + 4; })
.attr('y', 4)
.attr('font-size', '11px');
// Update positions on simulation tick
simulation.on('tick', function() {
graphSimulation.on('tick', function() {
link
.attr('x1', function(d) { return d.source.x; })
.attr('y1', function(d) { return d.source.y; })
.attr('x2', function(d) { return d.target.x; })
.attr('y2', function(d) { return d.target.y; });
node
.attr('cx', function(d) { return d.x; })
.attr('cy', function(d) { return d.y; });
label
.attr('x', function(d) { return d.x + 8; })
.attr('y', function(d) { return d.y + 3; });
node.attr('transform', function(d) {
return 'translate(' + d.x + ',' + d.y + ')';
});
});
// Auto-fit after simulation stabilizes
graphSimulation.on('end', function() {
fitGraphToView();
});
// Also fit after initial layout
setTimeout(function() {
fitGraphToView();
}, 1000);
// Drag functions
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
if (!event.active) graphSimulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
@@ -308,18 +610,94 @@ function renderMemoryGraph(graphData) {
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
if (!event.active) graphSimulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
}
// ========== Graph Zoom Controls ==========
function zoomGraphIn() {
if (graphSvg && graphZoom) {
graphSvg.transition().duration(300).call(graphZoom.scaleBy, 1.3);
}
}
function zoomGraphOut() {
if (graphSvg && graphZoom) {
graphSvg.transition().duration(300).call(graphZoom.scaleBy, 0.7);
}
}
function fitGraphToView() {
if (!graphSvg || !graphGroup || !graphZoom) return;
var container = document.getElementById('memoryGraphSvg');
if (!container) return;
var width = container.clientWidth || 600;
var height = container.clientHeight || 400;
// Get the bounds of all nodes
var bounds = graphGroup.node().getBBox();
if (bounds.width === 0 || bounds.height === 0) return;
// Calculate scale to fit with padding
var padding = 40;
var scale = Math.min(
(width - padding * 2) / bounds.width,
(height - padding * 2) / bounds.height
);
scale = Math.min(Math.max(scale, 0.2), 2); // Clamp scale between 0.2 and 2
// Calculate translation to center
var tx = (width - bounds.width * scale) / 2 - bounds.x * scale;
var ty = (height - bounds.height * scale) / 2 - bounds.y * scale;
// Apply transform with animation
graphSvg.transition()
.duration(500)
.call(graphZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
function centerGraphOnNode(nodeId) {
if (!graphSvg || !graphGroup || !graphZoom) return;
var container = document.getElementById('memoryGraphSvg');
if (!container) return;
var width = container.clientWidth || 600;
var height = container.clientHeight || 400;
// Find the node
var nodeData = null;
graphGroup.selectAll('.graph-node-group').each(function(d) {
if (d.id === nodeId) nodeData = d;
});
if (!nodeData || nodeData.x === undefined) return;
// Calculate translation to center on node
var scale = 1.2;
var tx = width / 2 - nodeData.x * scale;
var ty = height / 2 - nodeData.y * scale;
graphSvg.transition()
.duration(500)
.call(graphZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
function selectNode(node) {
selectedNode = node;
// Highlight in graph
d3.selectAll('.graph-node').classed('selected', false);
d3.selectAll('.graph-node[data-id="' + node.id + '"]').classed('selected', true);
if (graphGroup) {
graphGroup.selectAll('.graph-node').classed('selected', false);
graphGroup.selectAll('.graph-node[data-id="' + node.id + '"]').classed('selected', true);
}
// Center graph on selected node
centerGraphOnNode(node.id);
// Show node details in context column
showNodeDetails(node);
@@ -329,19 +707,15 @@ function highlightNode(path) {
var node = memoryGraphData.nodes.find(function(n) { return n.path === path || n.id === path; });
if (node) {
selectNode(node);
// Center graph on node if possible
if (typeof d3 !== 'undefined') {
var container = document.getElementById('memoryGraphSvg');
if (container) {
container.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
}
function resetGraphView() {
selectedNode = null;
d3.selectAll('.graph-node').classed('selected', false);
if (graphGroup) {
graphGroup.selectAll('.graph-node').classed('selected', false);
}
fitGraphToView();
renderContextColumn();
}
@@ -382,13 +756,14 @@ function renderContextTimeline(prompts) {
}
return '<div class="context-timeline">' +
prompts.map(function(item) {
prompts.map(function(item, index) {
var timestamp = item.timestamp ? formatTimestamp(item.timestamp) : 'Unknown time';
var type = item.type || 'unknown';
var typeIcon = type === 'read' ? 'eye' : type === 'edit' ? 'pencil' : 'file-text';
var typeIcon = type === 'read' ? 'eye' : type === 'write' ? 'pencil' : type === 'edit' ? 'pencil' : 'file-text';
var files = item.files || [];
var description = item.prompt || item.description || 'No description';
return '<div class="timeline-item">' +
return '<div class="timeline-item" data-index="' + index + '" onclick="toggleTimelineItem(this)">' +
'<div class="timeline-icon ' + type + '">' +
'<i data-lucide="' + typeIcon + '" class="w-3.5 h-3.5"></i>' +
'</div>' +
@@ -397,14 +772,13 @@ function renderContextTimeline(prompts) {
'<span class="timeline-type">' + escapeHtml(type.charAt(0).toUpperCase() + type.slice(1)) + '</span>' +
'<span class="timeline-time">' + timestamp + '</span>' +
'</div>' +
'<div class="timeline-prompt">' + escapeHtml(item.prompt || item.description || 'No description') + '</div>' +
'<div class="timeline-prompt">' + escapeHtml(description) + '</div>' +
(files.length > 0 ? '<div class="timeline-files">' +
files.slice(0, 3).map(function(f) {
return '<span class="file-tag" onclick="highlightNode(\'' + escapeHtml(f) + '\')">' +
files.map(function(f) {
return '<span class="file-tag" onclick="event.stopPropagation(); highlightNode(\'' + escapeHtml(f) + '\')">' +
'<i data-lucide="file" class="w-3 h-3"></i> ' + escapeHtml(f.split('/').pop().split('\\').pop()) +
'</span>';
}).join('') +
(files.length > 3 ? '<span class="file-tag more">+' + (files.length - 3) + ' more</span>' : '') +
'</div>' : '') +
'</div>' +
'</div>';
@@ -412,10 +786,17 @@ function renderContextTimeline(prompts) {
'</div>';
}
/**
* Toggle timeline item expansion
*/
function toggleTimelineItem(element) {
element.classList.toggle('expanded');
}
function renderContextStats() {
var totalReads = recentContext.filter(function(c) { return c.type === 'read'; }).length;
var totalEdits = recentContext.filter(function(c) { return c.type === 'edit'; }).length;
var totalPrompts = recentContext.filter(function(c) { return c.type === 'prompt'; }).length;
var totalEdits = recentContext.filter(function(c) { return c.type === 'edit' || c.type === 'write'; }).length;
var totalMentions = recentContext.filter(function(c) { return c.type === 'mention'; }).length;
return '<div class="context-stats">' +
'<div class="context-stat-item">' +
@@ -430,8 +811,8 @@ function renderContextStats() {
'</div>' +
'<div class="context-stat-item">' +
'<i data-lucide="message-square" class="w-4 h-4"></i>' +
'<span class="stat-label">' + t('memory.prompts') + '</span>' +
'<span class="stat-value">' + totalPrompts + '</span>' +
'<span class="stat-label">' + t('memory.mentions') + '</span>' +
'<span class="stat-value">' + totalMentions + '</span>' +
'</div>' +
'</div>';
}