fix(memory): fix insights parsing + modernize Memory, Insights, Todos, and agent Memory UI
- parseInsightsContent stripped bullet prefixes before filtering for them, so every
insights category rendered as one blob and counts were wrong (5 shown vs 84 real)
- drop dead GET /memory and GET /memory/stats mount fetches from useMemoryData and
stop refetching the file list on every file selection
- Memory view: full-width layout, accent tabs, 2-column Engines card grid, remove
duplicated capability badges, correct spacing-token-as-font-size rules
- Todos: single-row items with quiet inline action cluster (stacked on narrow/mobile)
- Insights: flat card list (no card-in-card), 28px/16px actions muted until hover
- Agent Memory tab: shared FileEditor (CodeMirror) for memory files, per-section save
actions, distinct inline-toggle aria-labels, fix {{date}} i18n interpolation
- PR screenshots under docs/assets/memory-ui-review-2026-07/
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7
.changeset/memory-insights-todo-ui-cleanup.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix Memory insights parsing and modernize the Memory, Insights, Todos, and agent Memory views.
|
||||
category: fix
|
||||
dev: parseInsightsContent filtered bullets after stripping their prefix, collapsing every category into one blob; useMemoryData drops the dead GET /memory and /memory/stats mount fetches and no longer refetches the file list on selection; Engines tab is a 2-column card grid; Todo items are single-row with a quiet inline action cluster; the agent Memory tab uses the shared FileEditor with per-section save actions and fixes the agents.memoryFileMeta {{date}} interpolation.
|
||||
BIN
docs/assets/memory-ui-review-2026-07/after-agent-memory-mid.jpg
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
docs/assets/memory-ui-review-2026-07/after-agent-memory2.jpg
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/assets/memory-ui-review-2026-07/after-insights.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
docs/assets/memory-ui-review-2026-07/after-memory-engines.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/assets/memory-ui-review-2026-07/after-memory-insights.jpg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/assets/memory-ui-review-2026-07/after-todos.jpg
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
docs/assets/memory-ui-review-2026-07/before-agent-memory.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/assets/memory-ui-review-2026-07/before-insights.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
docs/assets/memory-ui-review-2026-07/before-memory-engines.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
docs/assets/memory-ui-review-2026-07/before-memory-insights.jpg
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
docs/assets/memory-ui-review-2026-07/before-todos.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
@@ -1436,7 +1436,7 @@ type MemorySettings = {
|
||||
* Resolve the appropriate memory backend based on settings.
|
||||
*
|
||||
* @param settings - Project settings object
|
||||
* @returns The memory backend to use, defaulting to file backend
|
||||
* @returns The memory backend to use, defaulting to DEFAULT_MEMORY_BACKEND (qmd)
|
||||
*/
|
||||
export function resolveMemoryBackend(settings?: MemorySettings): MemoryBackend {
|
||||
const backendType = (settings?.[MEMORY_BACKEND_SETTINGS_KEYS.MEMORY_BACKEND_TYPE] as string) || DEFAULT_MEMORY_BACKEND;
|
||||
@@ -1444,7 +1444,7 @@ export function resolveMemoryBackend(settings?: MemorySettings): MemoryBackend {
|
||||
if (backend) {
|
||||
return backend;
|
||||
}
|
||||
// Fall back to file backend if unknown type
|
||||
// Fall back to the default (qmd) backend if the configured type is unknown
|
||||
return backendRegistry.get(DEFAULT_MEMORY_BACKEND)!;
|
||||
}
|
||||
|
||||
|
||||
@@ -2211,3 +2211,29 @@ FNXC:AgentDetailView 2026-06-26-01:00:
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:AgentMemory 2026-07-11-00:20:
|
||||
The agent Memory tab's file editor is the shared FileEditor (CodeMirror). The frame is
|
||||
bounded like the project Memory view editor — a visible floor so it never collapses,
|
||||
and a viewport cap so long memory files scroll inside the editor instead of stretching
|
||||
the tab into endless page scroll.
|
||||
*/
|
||||
.agent-memory-file-editor {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 320px;
|
||||
max-height: 55vh;
|
||||
}
|
||||
|
||||
.agent-memory-file-editor .cm-editor {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.agent-memory-file-editor .cm-scroller {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||
import { AgentTaskBadge } from "./AgentTaskBadge";
|
||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||
@@ -2577,7 +2578,6 @@ function MemoryTab({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showFilePreview, setShowFilePreview] = useState(false);
|
||||
|
||||
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
|
||||
const [memoryFilesLoading, setMemoryFilesLoading] = useState(false);
|
||||
@@ -2652,7 +2652,6 @@ function MemoryTab({
|
||||
setMemory(agent.memory ?? "");
|
||||
setJustSaved(false);
|
||||
setShowPreview(false);
|
||||
setShowFilePreview(false);
|
||||
setFileSwitchHint("");
|
||||
setSelectedFileJustSaved(false);
|
||||
void loadMemoryFiles();
|
||||
@@ -2751,12 +2750,18 @@ function MemoryTab({
|
||||
</span>
|
||||
<div className="agent-content-toolbar">
|
||||
<div className="agent-content-mode-toggle">
|
||||
{/*
|
||||
FNXC:AgentMemory 2026-07-11-00:20:
|
||||
The inline-memory Edit/Preview toggle needs aria-labels distinct from the shared
|
||||
FileEditor toolbar below (which also exposes "Edit mode"/"Preview mode"); two
|
||||
identically-named controls in one tab are ambiguous for assistive tech.
|
||||
*/}
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
className={`btn btn-sm ${!showPreview ? "btn-primary" : ""}`}
|
||||
onClick={() => setShowPreview(false)}
|
||||
disabled={!showPreview}
|
||||
aria-label={t("common.editMode", "Edit mode")}
|
||||
aria-label={t("agents.inlineMemoryEditMode", "Inline memory edit mode")}
|
||||
>
|
||||
<FileEdit size={14} />
|
||||
{t("common.edit", "Edit")}
|
||||
@@ -2766,7 +2771,7 @@ function MemoryTab({
|
||||
className={`btn btn-sm ${showPreview ? "btn-primary" : ""}`}
|
||||
onClick={() => setShowPreview(true)}
|
||||
disabled={showPreview}
|
||||
aria-label={t("common.previewMode", "Preview mode")}
|
||||
aria-label={t("agents.inlineMemoryPreviewMode", "Inline memory preview mode")}
|
||||
>
|
||||
<Eye size={14} />
|
||||
{t("common.preview", "Preview")}
|
||||
@@ -2803,6 +2808,34 @@ function MemoryTab({
|
||||
{!showPreview && (
|
||||
<span className="config-hint">{t("agents.inlineMemoryFieldHint", "This is the inline memory field on the agent JSON record. Max 50,000 characters.")}</span>
|
||||
)}
|
||||
|
||||
{!showPreview && (
|
||||
<div className="config-actions">
|
||||
<button
|
||||
className="btn btn-task-create"
|
||||
disabled={!hasInlineChanges || isSaving || isReadOnly}
|
||||
onClick={() => void handleSaveInlineMemory()}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("common.saving", "Saving…")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} />
|
||||
{t("agents.saveMemory", "Save Memory")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!hasInlineChanges && justSaved && (
|
||||
<span className="config-saved-indicator">
|
||||
<CheckCircle size={14} />
|
||||
{t("agents.memorySaved", "Memory saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
@@ -2845,62 +2878,37 @@ function MemoryTab({
|
||||
<div className="config-hint config-hint--top-spacing">
|
||||
<strong>{({ "long-term": t("agents.memoryLayerLongTerm", "Long-term"), daily: t("agents.memoryLayerDaily", "Daily"), dreams: t("agents.memoryLayerDreams", "Dreams") } as Record<string, string>)[selectedMemoryFile.layer] ?? selectedMemoryFile.layer}</strong> · {selectedLayerDescription}
|
||||
<br />
|
||||
{t("agents.memoryFileMeta", "{{size}} bytes · Updated {{time}}", { size: selectedMemoryFile.size.toLocaleString(), time: relativeTime(selectedMemoryFile.updatedAt, t) })}
|
||||
{/*
|
||||
FNXC:AgentMemory 2026-07-11-00:20:
|
||||
i18n fix: every locale defines agents.memoryFileMeta with a {{date}} placeholder, but this
|
||||
call passed the value as {{time}}, so the UI rendered the literal string "updated {{date}}".
|
||||
The interpolation variable must be named `date` to match the locale files.
|
||||
*/}
|
||||
{t("agents.memoryFileMeta", "{{size}} bytes · updated {{date}}", { size: selectedMemoryFile.size.toLocaleString(), date: relativeTime(selectedMemoryFile.updatedAt, t) })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="agent-content-toolbar config-textarea-top-spacing">
|
||||
<div className="agent-content-mode-toggle">
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
className={`btn btn-sm ${!showFilePreview ? "btn-primary" : ""}`}
|
||||
onClick={() => setShowFilePreview(false)}
|
||||
disabled={!showFilePreview}
|
||||
aria-label={t("agents.memoryFileEditMode", "Memory file edit mode")}
|
||||
>
|
||||
<FileEdit size={14} />
|
||||
{t("common.edit", "Edit")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-sm ${showFilePreview ? "btn-primary" : ""}`}
|
||||
onClick={() => setShowFilePreview(true)}
|
||||
disabled={showFilePreview}
|
||||
aria-label={t("agents.memoryFilePreviewMode", "Memory file preview mode")}
|
||||
>
|
||||
<Eye size={14} />
|
||||
{t("common.preview", "Preview")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilePreview ? (
|
||||
selectedFileContent.trim() ? (
|
||||
<div className="agent-content-preview markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{selectedFileContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-content-preview agent-content-placeholder">
|
||||
{t("agents.memoryFileEmptyPreview", "No memory file content yet. Switch to Edit mode to add content.")}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<textarea
|
||||
className="input config-textarea-mono"
|
||||
rows={14}
|
||||
placeholder={t("agents.memoryFilePlaceholder", "Select a memory file to view and edit its content...")}
|
||||
value={selectedFileContent}
|
||||
readOnly={isReadOnly || !selectedFilePath || selectedFileLoading}
|
||||
onChange={(e) => {
|
||||
setSelectedFileContent(e.target.value);
|
||||
{/*
|
||||
FNXC:AgentMemory 2026-07-11-00:20:
|
||||
The memory-file editor uses the shared FileEditor (CodeMirror with the Edit/Preview/Wrap
|
||||
toolbar) instead of a bare <textarea> with a hand-rolled Edit/Preview toggle, so agent
|
||||
memory files get the same markdown editing experience as the project Memory view.
|
||||
Toolbar actions stay visible to avoid the unlabeled chevron-only collapsed bar.
|
||||
*/}
|
||||
<div className="agent-memory-file-editor config-textarea-top-spacing">
|
||||
<FileEditor
|
||||
content={selectedFileContent}
|
||||
onChange={(content) => {
|
||||
setSelectedFileContent(content);
|
||||
setSelectedFileDirty(true);
|
||||
setSelectedFileJustSaved(false);
|
||||
setFileSwitchHint("");
|
||||
}}
|
||||
readOnly={isReadOnly || !selectedFilePath || selectedFileLoading}
|
||||
filePath={selectedFilePath || "MEMORY.md"}
|
||||
forceToolbarActionsVisible
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedFileLoading && (
|
||||
<span className="config-hint config-hint--inline-loader">
|
||||
@@ -2914,60 +2922,39 @@ function MemoryTab({
|
||||
{fileSwitchHint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-actions">
|
||||
{!showPreview && (
|
||||
<button
|
||||
className="btn btn-task-create"
|
||||
disabled={!hasInlineChanges || isSaving || isReadOnly}
|
||||
onClick={() => void handleSaveInlineMemory()}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("common.saving", "Saving…")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} />
|
||||
{t("agents.saveMemory", "Save Memory")}
|
||||
</>
|
||||
{/*
|
||||
FNXC:AgentMemory 2026-07-11-00:20:
|
||||
Each memory surface owns its save action: "Save Memory File" sits directly under the
|
||||
file editor and "Save Inline Memory" under the inline field, instead of the two
|
||||
ambiguously-named buttons sharing one action row at the bottom of the tab.
|
||||
*/}
|
||||
<div className="config-actions">
|
||||
<button
|
||||
className="btn btn-task-create"
|
||||
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
|
||||
onClick={() => void handleSaveSelectedMemoryFile()}
|
||||
>
|
||||
{savingSelectedFile ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("agents.savingFile", "Saving file…")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} />
|
||||
{t("agents.saveMemoryFile", "Save Memory File")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!selectedFileDirty && selectedFileJustSaved && (
|
||||
<span className="config-saved-indicator">
|
||||
<CheckCircle size={14} />
|
||||
{t("agents.memoryFileSaved", "Memory file saved")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!showFilePreview && (
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!selectedFileDirty || savingSelectedFile || !selectedFilePath || isReadOnly}
|
||||
onClick={() => void handleSaveSelectedMemoryFile()}
|
||||
>
|
||||
{savingSelectedFile ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("agents.savingFile", "Saving file…")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={16} />
|
||||
{t("agents.saveMemoryFile", "Save Memory File")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!hasInlineChanges && justSaved && (
|
||||
<span className="config-saved-indicator">
|
||||
<CheckCircle size={14} />
|
||||
{t("agents.memorySaved", "Memory saved")}
|
||||
</span>
|
||||
)}
|
||||
{!selectedFileDirty && selectedFileJustSaved && (
|
||||
<span className="config-saved-indicator">
|
||||
<CheckCircle size={14} />
|
||||
{t("agents.memoryFileSaved", "Memory file saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -275,21 +275,28 @@ Header migrated to the shared ViewHeader component (.view-header). The old .insi
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Section (single, in detail pane) */
|
||||
/*
|
||||
FNXC:Insights 2026-07-10-23:30:
|
||||
Modernization pass: the detail pane previously nested cards inside a card (an outer
|
||||
.insights-section card wrapping per-insight cards), which doubled the borders and
|
||||
padding around every insight. The outer section is now transparent chrome — a plain
|
||||
header row above the flat list of insight cards — so each insight reads as one card
|
||||
at a consistent depth with the rest of the dashboard.
|
||||
*/
|
||||
.insights-section {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.insights-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--surface);
|
||||
border-bottom: var(--chrome-divider-width, 1px) solid var(--insights-divider-color);
|
||||
padding: 0 0 var(--space-md);
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.insights-section-title {
|
||||
@@ -318,7 +325,7 @@ Header migrated to the shared ViewHeader component (.view-header). The old .insi
|
||||
}
|
||||
|
||||
.insights-section-content {
|
||||
padding: var(--space-md);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Insights list */
|
||||
@@ -328,21 +335,22 @@ Header migrated to the shared ViewHeader component (.view-header). The old .insi
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Individual insight item */
|
||||
.insight-item {
|
||||
padding: var(--space-md);
|
||||
background: var(--surface);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: var(--space-xs) solid var(--accent);
|
||||
border-left: 3px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast);
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.insight-item:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.insight-item-header {
|
||||
@@ -367,20 +375,31 @@ Header migrated to the shared ViewHeader component (.view-header). The old .insi
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Borderless icon-prominent action buttons inside insight items */
|
||||
/*
|
||||
FNXC:Insights 2026-07-10-23:30:
|
||||
Action buttons were 32px boxes with 20px icons, which visually competed with the
|
||||
insight titles. They shrink to 28px boxes with 16px icons and stay muted until the
|
||||
insight row is hovered, keeping the title as the dominant element.
|
||||
*/
|
||||
.insight-item-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(var(--space-lg) * 2);
|
||||
height: calc(var(--space-lg) * 2);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
opacity: 0.65;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.insight-item:hover .insight-item-action-btn,
|
||||
.insight-item:focus-within .insight-item-action-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.insight-item-action-btn:hover:not(:disabled) {
|
||||
|
||||
@@ -374,7 +374,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
>
|
||||
<div className="insights-section-header">
|
||||
<div className="insights-section-title">
|
||||
<IconComponent size={20} className="insights-section-icon" />
|
||||
<IconComponent size={18} className="insights-section-icon" />
|
||||
<h3>{activeSection.label}</h3>
|
||||
<span className="insights-section-count">{activeSection.items.length}</span>
|
||||
</div>
|
||||
@@ -414,7 +414,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
aria-label={t("insights.unarchiveLabel", "Unarchive this insight")}
|
||||
data-testid={`unarchive-${insight.id}`}
|
||||
>
|
||||
{isUnarchiveInFlight ? <RefreshCw size={20} className="spin" /> : <ArchiveRestore size={20} />}
|
||||
{isUnarchiveInFlight ? <RefreshCw size={16} className="spin" /> : <ArchiveRestore size={16} />}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
@@ -426,7 +426,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
aria-label={t("insights.createTaskLabel", "Create task from this insight")}
|
||||
data-testid={`create-task-${insight.id}`}
|
||||
>
|
||||
{isCreateInFlight ? <RefreshCw size={20} className="spin" /> : <Plus size={20} />}
|
||||
{isCreateInFlight ? <RefreshCw size={16} className="spin" /> : <Plus size={16} />}
|
||||
</button>
|
||||
<button
|
||||
className="insight-item-action-btn"
|
||||
@@ -436,7 +436,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
aria-label={t("insights.archiveLabel", "Archive this insight")}
|
||||
data-testid={`archive-${insight.id}`}
|
||||
>
|
||||
{isArchiveInFlight ? <RefreshCw size={20} className="spin" /> : <Archive size={20} />}
|
||||
{isArchiveInFlight ? <RefreshCw size={16} className="spin" /> : <Archive size={16} />}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -449,9 +449,9 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
data-testid={`dismiss-${insight.id}`}
|
||||
>
|
||||
{isDismissInFlight ? (
|
||||
<RefreshCw size={20} className="spin" />
|
||||
<RefreshCw size={16} className="spin" />
|
||||
) : (
|
||||
<X size={20} />
|
||||
<X size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,15 @@ The title row now comes from the shared .view-header (which supplies the --space
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:30:
|
||||
The view must always claim the full main-panel width. Without an explicit width the
|
||||
Engines/Insights tabs (whose cards have no intrinsic width) let the whole view shrink
|
||||
to content width, leaving the right half of the main panel as empty background.
|
||||
*/
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -45,9 +54,14 @@ After the header migrated to the shared .view-header (which is flex-shrink:0), t
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:30:
|
||||
Active tab underline uses the shared --accent token (was --todo, the Todos view color)
|
||||
so Memory matches the accent language of the other main-content views.
|
||||
*/
|
||||
.memory-view-tab--active {
|
||||
color: var(--text);
|
||||
border-bottom: 2px solid var(--todo);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -171,7 +185,7 @@ The memory editor box is CAPPED to about a page (max-height: 60vh) so a long mem
|
||||
}
|
||||
|
||||
.memory-category-count {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--text-muted) 15%, transparent);
|
||||
padding: 2px 8px;
|
||||
@@ -180,28 +194,60 @@ The memory editor box is CAPPED to about a page (max-height: 60vh) so a long mem
|
||||
|
||||
.memory-category-items {
|
||||
padding-top: var(--space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:30:
|
||||
Parsed insights render as quiet cards (surface background + accent hairline) instead of
|
||||
bare border-left text rows, matching the InsightsView card language.
|
||||
*/
|
||||
.memory-insight-item {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
margin-bottom: var(--space-xs);
|
||||
border-left: 3px solid var(--border);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:30:
|
||||
Engines tab modernization: the status cards previously stacked in one full-width column
|
||||
with --space-xl padding, producing very wide, mostly-empty cards. The tab is now a
|
||||
two-column card grid (single column under 900px container width via the media fallback
|
||||
below); the Run Audit action bar and the settings note span the full row.
|
||||
*/
|
||||
.memory-engines-tab {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-lg);
|
||||
align-content: start;
|
||||
/* Cards keep their intrinsic height instead of stretching to the tallest card in the row. */
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.memory-engines-tab > .memory-action-bar,
|
||||
.memory-engines-tab > .memory-settings-note,
|
||||
.memory-engines-tab > .memory-empty-state {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.memory-engine-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.memory-engine-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -248,7 +294,7 @@ The memory editor box is CAPPED to about a page (max-height: 60vh) so a long mem
|
||||
}
|
||||
|
||||
.memory-stat-value--updated {
|
||||
font-size: var(--space-lg);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.memory-capability-row {
|
||||
@@ -275,15 +321,22 @@ The memory editor box is CAPPED to about a page (max-height: 60vh) so a long mem
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:30:
|
||||
These rules previously used spacing tokens (--space-md/--space-lg) as font sizes, so the
|
||||
label/detail text scaled with the spacing scale instead of the type scale. They now use
|
||||
explicit type sizes consistent with the stat-card labels.
|
||||
*/
|
||||
.memory-health-label {
|
||||
font-size: var(--space-md);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.memory-health-detail {
|
||||
font-size: var(--space-md);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -314,8 +367,8 @@ The memory editor box is CAPPED to about a page (max-height: 60vh) so a long mem
|
||||
}
|
||||
|
||||
.memory-settings-note {
|
||||
margin-top: var(--space-lg);
|
||||
font-size: var(--space-md);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -491,6 +544,16 @@ Scoped under .memory-working-tab so these rules out-specify same-name form rules
|
||||
/* === Memory View Styles === */
|
||||
|
||||
/* Mobile responsive for memory view */
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:30:
|
||||
Between 769px and 1100px the two-column engines grid gets cramped; collapse to one column.
|
||||
*/
|
||||
@media (max-width: 1100px) and (min-width: 769px) {
|
||||
.memory-engines-tab {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* ViewHeader supplies its own responsive padding; the body blocks tighten their horizontal inset here. */
|
||||
.memory-view-tabs {
|
||||
@@ -518,6 +581,10 @@ Scoped under .memory-working-tab so these rules out-specify same-name form rules
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.memory-engines-tab {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.memory-engine-card {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,16 @@ interface ParsedInsightCategory {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:00:
|
||||
Insights parsing bug fix: the old implementation stripped the "- " bullet prefix BEFORE
|
||||
filtering for lines that start with "- ", so no markdown bullet ever survived the filter.
|
||||
Every category then collapsed into a single giant blob item and the "Total Insights" stat
|
||||
undercounted (~1 per category), contradicting the server-computed insight count on the
|
||||
Engines health card. Bullets must be FILTERED first, then stripped. HTML comments in the
|
||||
section body (extraction markers like "recurring themes that work well") are metadata,
|
||||
not insights, and are removed before parsing.
|
||||
*/
|
||||
/** Parse insights markdown content into categorized sections */
|
||||
function parseInsightsContent(content: string | null): ParsedInsightCategory[] {
|
||||
if (!content) return [];
|
||||
@@ -66,13 +76,17 @@ function parseInsightsContent(content: string | null): ParsedInsightCategory[] {
|
||||
if (match) {
|
||||
const header = match[1].trim();
|
||||
const key = CATEGORY_HEADERS[header] ?? header.toLowerCase();
|
||||
const body = trimmed.slice(match[0].length).trim();
|
||||
const body = trimmed
|
||||
.slice(match[0].length)
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.trim();
|
||||
|
||||
// Extract bullet points
|
||||
// Extract bullet points: filter bullet lines first, then strip the prefix
|
||||
const items = body
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^-\s+/, "").trim())
|
||||
.filter((line) => line.length > 0 && (line.startsWith("- ") || line.startsWith("* ")));
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^[-*]\s+/.test(line))
|
||||
.map((line) => line.replace(/^[-*]\s+/, ""));
|
||||
|
||||
if (items.length > 0 || body.length > 0) {
|
||||
categories.push({
|
||||
@@ -692,7 +706,7 @@ export function MemoryView({ projectId, addToast, onSendSelectionToTask }: Memor
|
||||
content={insightsEditorContent ?? ""}
|
||||
onChange={setInsightsEditorContent}
|
||||
readOnly={false}
|
||||
filePath=".fusion/memory/INSIGHTS.md"
|
||||
filePath=".fusion/memory/memory-insights.md"
|
||||
forceToolbarActionsVisible
|
||||
onSendSelectionToTask={onSendSelectionToTask}
|
||||
/>
|
||||
@@ -860,20 +874,12 @@ export function MemoryView({ projectId, addToast, onSendSelectionToTask }: Memor
|
||||
<span className="memory-char-count">{t("memory.qmdCheckingAvailability", "Checking qmd availability…")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="memory-capability-row">
|
||||
{backendStatus?.capabilities?.readable && (
|
||||
<span className="memory-capability-badge">{t("memory.capReadable", "Readable")}</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.writable && (
|
||||
<span className="memory-capability-badge">{t("memory.capWritable", "Writable")}</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.supportsAtomicWrite && (
|
||||
<span className="memory-capability-badge">{t("memory.capAtomicWrites", "Atomic Writes")}</span>
|
||||
)}
|
||||
{backendStatus?.capabilities?.persistent && (
|
||||
<span className="memory-capability-badge">{t("memory.capPersistent", "Persistent")}</span>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
FNXC:MemoryView 2026-07-10-23:30:
|
||||
The capability badge row (Readable/Writable/Atomic Writes/Persistent) belongs to the
|
||||
Current Backend card only; it was duplicated verbatim on this QMD card, showing the
|
||||
same four badges twice on the Engines tab. QMD availability is this card's whole story.
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* Memory Retrieval Test Card */}
|
||||
|
||||
@@ -567,29 +567,43 @@ Redesign Todos to fit the rest of the dashboard theme: full-height tokenized wor
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TodosStyling 2026-07-10-23:30:
|
||||
Modernization pass: todo items collapse from the two-row card (text row + full-width
|
||||
divided action row, which repeated 7 large buttons under every item and made each card
|
||||
~2x taller than its content) into a SINGLE compact row — checkbox + text on the left,
|
||||
the quiet action cluster right-aligned on the same line. Actions stay always visible
|
||||
(operator requirement: "visible but quiet action bars") but render muted and compact,
|
||||
sharpening to full contrast when the row is hovered or focused. The narrow-container
|
||||
stack and the 768px mobile breakpoint return to a stacked layout with the action row
|
||||
under the text because a phone-width row cannot fit text plus seven controls.
|
||||
*/
|
||||
.todo-item {
|
||||
padding: var(--space-md);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) var(--space-sm) var(--space-xs) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-sm, 0 1px 2px color-mix(in srgb, var(--bg) 70%, transparent));
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast);
|
||||
min-height: 44px;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.todo-item:hover {
|
||||
background: var(--surface);
|
||||
border-color: color-mix(in srgb, var(--todo) 28%, var(--border));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.todo-item-main-row {
|
||||
align-items: flex-start;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.todo-item-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.todo-item-text {
|
||||
@@ -603,9 +617,25 @@ Redesign Todos to fit the rest of the dashboard theme: full-height tokenized wor
|
||||
|
||||
.todo-item-actions {
|
||||
align-items: center;
|
||||
margin-left: calc(var(--space-lg) + var(--space-sm));
|
||||
padding-top: var(--space-xs);
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.todo-item:hover .todo-item-actions,
|
||||
.todo-item:focus-within .todo-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.todo-item-actions .todo-icon-btn svg,
|
||||
.todo-item-reorder-btn svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.todo-item-reorder-btns {
|
||||
@@ -678,8 +708,18 @@ NARROW container (right dock): collapse the side-by-side split into a single-pan
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Stack text over the action row: a phone-width row cannot fit text plus seven controls. */
|
||||
.todo-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.todo-item-actions {
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.todo-add-item-row {
|
||||
@@ -762,8 +802,18 @@ NARROW container (right dock): collapse the side-by-side split into a single-pan
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Stack text over the action row on mobile; mirror the narrow-container stack. */
|
||||
.todo-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.todo-item-actions {
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.todo-item-checkbox {
|
||||
|
||||
@@ -721,8 +721,8 @@ describe("Memory Tab", () => {
|
||||
await navigateToMemory(user);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Edit mode" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Inline memory edit mode" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Inline memory preview mode" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -731,12 +731,12 @@ describe("Memory Tab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||
await user.click(screen.getByRole("button", { name: "Inline memory preview mode" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Agent Memory")).not.toBeInTheDocument();
|
||||
expect(document.querySelector(".markdown-body")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Edit mode" }));
|
||||
await user.click(screen.getByRole("button", { name: "Inline memory edit mode" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Agent Memory")).toBeInTheDocument();
|
||||
});
|
||||
@@ -746,7 +746,7 @@ describe("Memory Tab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||
await user.click(screen.getByRole("button", { name: "Inline memory preview mode" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No agent memory defined yet. Switch to Edit mode to add memory content.")).toBeInTheDocument();
|
||||
});
|
||||
@@ -757,7 +757,7 @@ describe("Memory Tab", () => {
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
expect(screen.getByText("Save Memory")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||
await user.click(screen.getByRole("button", { name: "Inline memory preview mode" }));
|
||||
await waitFor(() => expect(screen.queryByText("Save Memory")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -767,8 +767,8 @@ describe("Memory Tab", () => {
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Preview mode" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit mode" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Inline memory preview mode" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Inline memory edit mode" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -777,46 +777,50 @@ describe("Memory Tab", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
await user.click(screen.getByRole("button", { name: "Preview mode" }));
|
||||
await user.click(screen.getByRole("button", { name: "Inline memory preview mode" }));
|
||||
await waitFor(() => expect(document.querySelector(".markdown-body")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders memory file preview markdown and toggles back to edit", async () => {
|
||||
it("renders the memory file in the shared FileEditor and toggles preview", async () => {
|
||||
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "# Heading\n\n- entry" } as any);
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
|
||||
/*
|
||||
FNXC:AgentMemory 2026-07-11-00:20:
|
||||
The memory-file editor is the shared FileEditor (CodeMirror + Edit/Preview/Wrap toolbar);
|
||||
the FileEditor toolbar owns the unqualified "Edit mode"/"Preview mode" labels while the
|
||||
inline-memory toggle uses "Inline memory ..." labels.
|
||||
*/
|
||||
await user.click(await screen.findByRole("button", { name: "Preview mode" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText("Select a memory file to view and edit its content...")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Heading")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Save Memory File")).not.toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Memory file edit mode" }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit mode" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Select a memory file to view and edit its content...")).toBeInTheDocument();
|
||||
expect(document.querySelector(".agent-memory-file-editor .file-editor-codemirror")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows memory file preview placeholder when selected file is empty", async () => {
|
||||
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "" } as any);
|
||||
it("keeps the Save Memory File action visible and enabled only when the file is dirty", async () => {
|
||||
mockFetchAgentMemoryFile.mockResolvedValue({ path: ".fusion/agent-memory/agent-001/MEMORY.md", content: "content" } as any);
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
await user.click(await screen.findByRole("button", { name: "Memory file preview mode" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No memory file content yet. Switch to Edit mode to add content.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save Memory File" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides memory file edit button and disables save button for running agents", async () => {
|
||||
it("hides all edit buttons and disables save for running agents", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await navigateToMemory(user);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Memory file preview mode" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Memory file edit mode" })).not.toBeInTheDocument();
|
||||
// FileEditor is readOnly (forced preview, its Edit button hidden) and the inline Edit toggle is hidden.
|
||||
expect(screen.queryByRole("button", { name: "Edit mode" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Inline memory edit mode" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save Memory File" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("MemoryView", () => {
|
||||
expect(capturedFileEditorProps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filePath: ".fusion/memory/INSIGHTS.md",
|
||||
filePath: ".fusion/memory/memory-insights.md",
|
||||
onSendSelectionToTask,
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -2,6 +2,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useMemoryData } from "../useMemoryData";
|
||||
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:00:
|
||||
The legacy single-file working-memory surface (fetchMemory/saveMemory) and the unused
|
||||
fetchMemoryStats call were removed from useMemoryData — MemoryView is a multi-file editor
|
||||
(fetchMemoryFiles/fetchMemoryFile/saveMemoryFile) and the Engines card uses the audit
|
||||
report. These tests cover the multi-file flow, and assert the removed endpoints are no
|
||||
longer called on mount.
|
||||
*/
|
||||
|
||||
// Mock API functions
|
||||
vi.mock("../../api", () => ({
|
||||
fetchMemory: vi.fn(),
|
||||
@@ -44,152 +53,144 @@ vi.mock("../useMemoryBackendStatus", () => ({
|
||||
// Import mocked functions
|
||||
import {
|
||||
fetchMemory,
|
||||
saveMemory,
|
||||
fetchMemoryStats,
|
||||
fetchMemoryInsights,
|
||||
saveMemoryInsights,
|
||||
triggerInsightExtraction,
|
||||
fetchMemoryAudit,
|
||||
compactMemory,
|
||||
fetchMemoryFiles,
|
||||
fetchMemoryFile,
|
||||
saveMemoryFile,
|
||||
fetchSettings,
|
||||
triggerMemoryDreams,
|
||||
} from "../../api";
|
||||
|
||||
const MEMORY_FILES = [
|
||||
{
|
||||
path: ".fusion/memory/MEMORY.md",
|
||||
label: "Long-term memory",
|
||||
layer: "long-term" as const,
|
||||
size: 100,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
path: ".fusion/memory/2024-01-01.md",
|
||||
label: "Daily memory",
|
||||
layer: "daily" as const,
|
||||
size: 40,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const AUDIT_REPORT = {
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 100, sectionCount: 2 },
|
||||
insightsMemory: { exists: true, size: 50, insightCount: 5, categories: { pattern: 3 } },
|
||||
extraction: {
|
||||
runAt: "2024-01-01T00:00:00.000Z",
|
||||
success: true,
|
||||
insightCount: 5,
|
||||
duplicateCount: 0,
|
||||
skippedCount: 0,
|
||||
summary: "Extracted 5 insights",
|
||||
},
|
||||
pruning: { applied: false, reason: "No pruning needed", sizeDelta: 0, originalSize: 50, newSize: 50 },
|
||||
checks: [],
|
||||
health: "healthy" as const,
|
||||
};
|
||||
|
||||
function mockDefaults(): void {
|
||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: "## Patterns\n- Pattern 1", exists: true });
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue(AUDIT_REPORT);
|
||||
vi.mocked(fetchMemoryFiles).mockResolvedValue({ files: MEMORY_FILES });
|
||||
vi.mocked(fetchMemoryFile).mockResolvedValue({ path: ".fusion/memory/MEMORY.md", content: "# Long-term" });
|
||||
vi.mocked(fetchSettings).mockResolvedValue({ memoryEnabled: true } as Awaited<ReturnType<typeof fetchSettings>>);
|
||||
}
|
||||
|
||||
describe("useMemoryData", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDefaults();
|
||||
});
|
||||
|
||||
it("fetches working memory, insights, and audit on mount", async () => {
|
||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "# Working Memory\n\nSome content" });
|
||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({
|
||||
content: "## Patterns\n- Pattern 1",
|
||||
exists: true,
|
||||
});
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 100, sectionCount: 2 },
|
||||
insightsMemory: { exists: true, size: 50, insightCount: 5, categories: { pattern: 3 } },
|
||||
extraction: {
|
||||
runAt: "2024-01-01T00:00:00.000Z",
|
||||
success: true,
|
||||
insightCount: 5,
|
||||
duplicateCount: 0,
|
||||
skippedCount: 0,
|
||||
summary: "Extracted 5 insights",
|
||||
},
|
||||
pruning: { applied: false, reason: "No pruning needed", sizeDelta: 0, originalSize: 50, newSize: 50 },
|
||||
checks: [],
|
||||
health: "healthy",
|
||||
});
|
||||
|
||||
it("fetches memory files, insights, and audit on mount — and does NOT call the removed legacy endpoints", async () => {
|
||||
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
|
||||
|
||||
// Initially loading
|
||||
expect(result.current.workingMemoryLoading).toBe(true);
|
||||
expect(result.current.insightsLoading).toBe(true);
|
||||
expect(result.current.auditLoading).toBe(true);
|
||||
|
||||
// Wait for all data to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.workingMemoryLoading).toBe(false);
|
||||
expect(result.current.insightsLoading).toBe(false);
|
||||
expect(result.current.auditLoading).toBe(false);
|
||||
expect(result.current.memoryFilesLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify working memory was fetched
|
||||
expect(fetchMemory).toHaveBeenCalledWith("test-project");
|
||||
|
||||
// Verify insights were fetched
|
||||
expect(fetchMemoryFiles).toHaveBeenCalledWith("test-project");
|
||||
expect(fetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/MEMORY.md", "test-project");
|
||||
expect(fetchMemoryInsights).toHaveBeenCalledWith("test-project");
|
||||
|
||||
// Verify audit was fetched
|
||||
expect(fetchMemoryAudit).toHaveBeenCalledWith("test-project");
|
||||
|
||||
// Verify state is updated
|
||||
expect(result.current.workingMemory).toBe("# Working Memory\n\nSome content");
|
||||
// Removed legacy surface: no single-file working-memory or stats fetch on mount
|
||||
expect(fetchMemory).not.toHaveBeenCalled();
|
||||
expect(fetchMemoryStats).not.toHaveBeenCalled();
|
||||
|
||||
expect(result.current.memoryFiles).toHaveLength(2);
|
||||
expect(result.current.selectedFilePath).toBe(".fusion/memory/MEMORY.md");
|
||||
expect(result.current.selectedFileContent).toBe("# Long-term");
|
||||
expect(result.current.insightsContent).toBe("## Patterns\n- Pattern 1");
|
||||
expect(result.current.insightsExists).toBe(true);
|
||||
expect(result.current.auditReport).not.toBeNull();
|
||||
expect(result.current.auditReport?.health).toBe("healthy");
|
||||
});
|
||||
|
||||
it("marks working memory as dirty when content changes", async () => {
|
||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "Initial content" });
|
||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 20, sectionCount: 1 },
|
||||
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||
checks: [],
|
||||
health: "warning",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMemoryData());
|
||||
it("selectFile loads the new file without refetching the whole file list", async () => {
|
||||
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.workingMemoryLoading).toBe(false);
|
||||
expect(result.current.memoryFilesLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.workingMemoryDirty).toBe(false);
|
||||
expect(fetchMemoryFiles).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkingMemory("New content");
|
||||
vi.mocked(fetchMemoryFile).mockResolvedValue({ path: ".fusion/memory/2024-01-01.md", content: "daily notes" });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.selectFile(".fusion/memory/2024-01-01.md");
|
||||
});
|
||||
|
||||
expect(result.current.workingMemoryDirty).toBe(true);
|
||||
expect(result.current.workingMemory).toBe("New content");
|
||||
expect(result.current.selectedFilePath).toBe(".fusion/memory/2024-01-01.md");
|
||||
expect(result.current.selectedFileContent).toBe("daily notes");
|
||||
expect(result.current.selectedFileDirty).toBe(false);
|
||||
// Selection changes must not re-trigger the mount-time file list fetch
|
||||
expect(fetchMemoryFiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("saveWorkingMemory calls API and clears dirty flag", async () => {
|
||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "Initial content" });
|
||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 20, sectionCount: 1 },
|
||||
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||
checks: [],
|
||||
health: "warning",
|
||||
});
|
||||
vi.mocked(saveMemory).mockResolvedValue({ success: true });
|
||||
it("marks the selected file dirty on edit and saveSelectedFile persists it", async () => {
|
||||
vi.mocked(saveMemoryFile).mockResolvedValue({ success: true });
|
||||
|
||||
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.workingMemoryLoading).toBe(false);
|
||||
expect(result.current.memoryFilesLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Make changes
|
||||
act(() => {
|
||||
result.current.setWorkingMemory("Modified content");
|
||||
result.current.setSelectedFileContent("# Long-term (edited)");
|
||||
});
|
||||
|
||||
expect(result.current.workingMemoryDirty).toBe(true);
|
||||
expect(result.current.selectedFileDirty).toBe(true);
|
||||
|
||||
// Save
|
||||
await act(async () => {
|
||||
await result.current.saveWorkingMemory();
|
||||
await result.current.saveSelectedFile();
|
||||
});
|
||||
|
||||
expect(saveMemory).toHaveBeenCalledWith("Modified content", "test-project");
|
||||
expect(result.current.workingMemoryDirty).toBe(false);
|
||||
expect(result.current.savingWorkingMemory).toBe(false);
|
||||
expect(saveMemoryFile).toHaveBeenCalledWith(".fusion/memory/MEMORY.md", "# Long-term (edited)", "test-project");
|
||||
expect(result.current.selectedFileDirty).toBe(false);
|
||||
expect(result.current.savingSelectedFile).toBe(false);
|
||||
});
|
||||
|
||||
it("extractInsights calls API then refreshes insights and audit", async () => {
|
||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "Working memory content" });
|
||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 20, sectionCount: 1 },
|
||||
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||
checks: [],
|
||||
health: "warning",
|
||||
});
|
||||
vi.mocked(triggerInsightExtraction).mockResolvedValue({
|
||||
success: true,
|
||||
summary: "Extracted 3 insights",
|
||||
@@ -203,7 +204,6 @@ describe("useMemoryData", () => {
|
||||
expect(result.current.auditLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Extract insights
|
||||
let extractResult: { success: boolean; summary: string } | undefined;
|
||||
await act(async () => {
|
||||
extractResult = await result.current.extractInsights();
|
||||
@@ -213,93 +213,61 @@ describe("useMemoryData", () => {
|
||||
expect(extractResult).toEqual({ success: true, summary: "Extracted 3 insights" });
|
||||
expect(result.current.extracting).toBe(false);
|
||||
|
||||
// Verify insights and audit were refreshed
|
||||
expect(fetchMemoryInsights).toHaveBeenCalledTimes(2); // Initial + refresh
|
||||
expect(fetchMemoryAudit).toHaveBeenCalledTimes(2); // Initial + refresh
|
||||
});
|
||||
|
||||
it("compactMemory calls API and updates working memory with returned content", async () => {
|
||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "Long memory content that needs compaction" });
|
||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 200, sectionCount: 5 },
|
||||
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||
checks: [],
|
||||
health: "warning",
|
||||
});
|
||||
it("compactMemory compacts the selected file in place and reloads the file list", async () => {
|
||||
vi.mocked(compactMemory).mockResolvedValue({
|
||||
path: ".fusion/memory/MEMORY.md",
|
||||
content: "Compacted memory content",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.workingMemoryLoading).toBe(false);
|
||||
expect(result.current.memoryFilesLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Compact memory
|
||||
await act(async () => {
|
||||
await result.current.compactMemory();
|
||||
await result.current.compactMemory(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
expect(compactMemory).toHaveBeenCalledWith("test-project");
|
||||
expect(result.current.workingMemory).toBe("Compacted memory content");
|
||||
expect(result.current.workingMemoryDirty).toBe(true);
|
||||
expect(compactMemory).toHaveBeenCalledWith(".fusion/memory/MEMORY.md", "test-project");
|
||||
expect(result.current.selectedFileContent).toBe("Compacted memory content");
|
||||
expect(result.current.selectedFileDirty).toBe(false);
|
||||
expect(result.current.compacting).toBe(false);
|
||||
expect(fetchMemoryFiles).toHaveBeenCalledTimes(2); // Initial + reload after compaction
|
||||
});
|
||||
|
||||
it("saveInsights calls API then refreshes insights", async () => {
|
||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "Working memory" });
|
||||
vi.mocked(fetchMemoryInsights)
|
||||
.mockResolvedValueOnce({ content: null, exists: false })
|
||||
.mockResolvedValueOnce({ content: "New insights content", exists: true });
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 20, sectionCount: 1 },
|
||||
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||
checks: [],
|
||||
health: "warning",
|
||||
});
|
||||
vi.mocked(saveMemoryInsights).mockResolvedValue({ success: true });
|
||||
|
||||
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.workingMemoryLoading).toBe(false);
|
||||
expect(result.current.insightsLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Save insights
|
||||
await act(async () => {
|
||||
await result.current.saveInsights("New insights content");
|
||||
});
|
||||
|
||||
expect(saveMemoryInsights).toHaveBeenCalledWith("New insights content", "test-project");
|
||||
expect(fetchMemoryInsights).toHaveBeenCalledTimes(2); // Initial + refresh
|
||||
expect(result.current.insightsContent).toBe("New insights content");
|
||||
});
|
||||
|
||||
it("triggerDreamNow calls triggerMemoryDreams", async () => {
|
||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "Initial content" });
|
||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 20, sectionCount: 1 },
|
||||
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||
checks: [],
|
||||
health: "warning",
|
||||
});
|
||||
vi.mocked(triggerMemoryDreams).mockResolvedValue({ success: true, summary: "done" });
|
||||
|
||||
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.workingMemoryLoading).toBe(false);
|
||||
expect(result.current.memoryFilesLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -311,17 +279,6 @@ describe("useMemoryData", () => {
|
||||
});
|
||||
|
||||
it("triggerDreamNow propagates API errors and resets state", async () => {
|
||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "Initial content" });
|
||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 20, sectionCount: 1 },
|
||||
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||
checks: [],
|
||||
health: "warning",
|
||||
});
|
||||
vi.mocked(triggerMemoryDreams).mockRejectedValue(
|
||||
new Error("Memory dreams are disabled. Enable dream processing in memory settings first."),
|
||||
);
|
||||
@@ -329,7 +286,7 @@ describe("useMemoryData", () => {
|
||||
const { result } = renderHook(() => useMemoryData({ projectId: "test-project" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.workingMemoryLoading).toBe(false);
|
||||
expect(result.current.memoryFilesLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -341,50 +298,4 @@ describe("useMemoryData", () => {
|
||||
expect(triggerMemoryDreams).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.dreamRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("sets correct loading states during async operations", async () => {
|
||||
vi.mocked(fetchMemory).mockResolvedValue({ content: "Initial content" });
|
||||
vi.mocked(fetchMemoryInsights).mockResolvedValue({ content: null, exists: false });
|
||||
vi.mocked(fetchMemoryAudit).mockResolvedValue({
|
||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||
workingMemory: { exists: true, size: 20, sectionCount: 1 },
|
||||
insightsMemory: { exists: false, size: 0, insightCount: 0, categories: {} },
|
||||
extraction: { runAt: "", success: false, insightCount: 0, duplicateCount: 0, skippedCount: 0, summary: "" },
|
||||
pruning: { applied: false, reason: "", sizeDelta: 0, originalSize: 0, newSize: 0 },
|
||||
checks: [],
|
||||
health: "warning",
|
||||
});
|
||||
vi.mocked(saveMemory).mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMemoryData());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.workingMemoryLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Make changes and save
|
||||
act(() => {
|
||||
result.current.setWorkingMemory("Modified content");
|
||||
});
|
||||
|
||||
expect(result.current.workingMemoryDirty).toBe(true);
|
||||
expect(result.current.savingWorkingMemory).toBe(false);
|
||||
|
||||
// Start save operation
|
||||
const savePromise = act(async () => {
|
||||
await result.current.saveWorkingMemory();
|
||||
});
|
||||
|
||||
// During save, savingWorkingMemory should be true
|
||||
// Note: This is a bit tricky to test because the state update happens synchronously
|
||||
// but the actual save is async. The loading state might not be visible in tests.
|
||||
|
||||
await savePromise;
|
||||
|
||||
expect(result.current.savingWorkingMemory).toBe(false);
|
||||
expect(result.current.workingMemoryDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
fetchMemory,
|
||||
saveMemory,
|
||||
fetchMemoryInsights,
|
||||
saveMemoryInsights,
|
||||
triggerInsightExtraction,
|
||||
fetchMemoryAudit,
|
||||
fetchMemoryStats,
|
||||
compactMemory as compactMemoryApi,
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
@@ -43,15 +40,14 @@ interface MemorySettingsState {
|
||||
memoryDreamsSchedule: string;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:00:
|
||||
The legacy single-file working-memory surface (GET/PUT /api/memory) and the lightweight
|
||||
GET /api/memory/stats fetch were removed from this hook: MemoryView switched to the
|
||||
multi-file editor (/memory/files + /memory/file) and the Engines health card renders the
|
||||
richer audit report, so both requests ran on every mount with their results discarded.
|
||||
*/
|
||||
interface UseMemoryDataResult {
|
||||
// Working memory
|
||||
workingMemory: string;
|
||||
workingMemoryLoading: boolean;
|
||||
workingMemoryDirty: boolean;
|
||||
setWorkingMemory: (content: string) => void;
|
||||
saveWorkingMemory: () => Promise<void>;
|
||||
savingWorkingMemory: boolean;
|
||||
|
||||
// Insights
|
||||
insightsContent: string | null;
|
||||
insightsLoading: boolean;
|
||||
@@ -96,16 +92,13 @@ interface UseMemoryDataResult {
|
||||
refreshAudit: () => Promise<void>;
|
||||
|
||||
// Compact
|
||||
compactMemory: (path?: string) => Promise<void>;
|
||||
compactMemory: (path: string) => Promise<void>;
|
||||
compacting: boolean;
|
||||
|
||||
// QMD integration
|
||||
installQmdAction: () => Promise<QmdInstallResult>;
|
||||
installingQmd: boolean;
|
||||
testRetrieval: (query: string) => Promise<MemoryRetrievalTestResult>;
|
||||
|
||||
// Stats
|
||||
stats: { workingMemorySize: number; insightsSize: number; insightsExists: boolean } | null;
|
||||
}
|
||||
|
||||
function extractMemorySettings(source: {
|
||||
@@ -139,12 +132,6 @@ function pickDefaultMemoryPath(files: MemoryFileInfo[], currentPath: string): st
|
||||
export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryDataResult {
|
||||
const { projectId } = options;
|
||||
|
||||
// Working memory state
|
||||
const [workingMemory, setWorkingMemoryRaw] = useState("");
|
||||
const [workingMemoryLoading, setWorkingMemoryLoading] = useState(true);
|
||||
const [workingMemoryDirty, setWorkingMemoryDirty] = useState(false);
|
||||
const [savingWorkingMemory, setSavingWorkingMemory] = useState(false);
|
||||
|
||||
// Insights state
|
||||
const [insightsContent, setInsightsContent] = useState<string | null>(null);
|
||||
const [insightsLoading, setInsightsLoading] = useState(true);
|
||||
@@ -180,9 +167,6 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
// QMD state
|
||||
const [installingQmd, setInstallingQmd] = useState(false);
|
||||
|
||||
// Stats state
|
||||
const [stats, setStats] = useState<{ workingMemorySize: number; insightsSize: number; insightsExists: boolean } | null>(null);
|
||||
|
||||
// Backend status from existing hook
|
||||
const {
|
||||
status: backendStatus,
|
||||
@@ -229,32 +213,6 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
}
|
||||
}, [projectId, selectedFilePath, loadMemoryFileContent]);
|
||||
|
||||
// Fetch working memory on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadWorkingMemory() {
|
||||
try {
|
||||
const data = await fetchMemory(projectId);
|
||||
if (!cancelled) {
|
||||
setWorkingMemoryRaw(data.content);
|
||||
setWorkingMemoryLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setWorkingMemoryRaw("");
|
||||
setWorkingMemoryLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadWorkingMemory();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch insights on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -312,6 +270,18 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
/*
|
||||
FNXC:MemoryView 2026-07-10-23:00:
|
||||
The initial files+content load must run once per project, NOT on every selection change.
|
||||
It previously depended on selectedFilePath, so each selectFile() triggered a redundant
|
||||
refetch of the whole file list plus a second fetch of the just-loaded file. The current
|
||||
selection is read through a ref to keep the effect keyed on projectId only.
|
||||
*/
|
||||
const selectedFilePathRef = useRef(selectedFilePath);
|
||||
useEffect(() => {
|
||||
selectedFilePathRef.current = selectedFilePath;
|
||||
}, [selectedFilePath]);
|
||||
|
||||
// Fetch memory files and initial selected file content
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -333,7 +303,7 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPath = pickDefaultMemoryPath(files, selectedFilePath);
|
||||
const nextPath = pickDefaultMemoryPath(files, selectedFilePathRef.current);
|
||||
const { content } = await fetchMemoryFile(nextPath, projectId);
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -361,7 +331,7 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, selectedFilePath]);
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch audit on mount
|
||||
useEffect(() => {
|
||||
@@ -389,49 +359,6 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch lightweight stats on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const data = await fetchMemoryStats(projectId);
|
||||
if (!cancelled) {
|
||||
setStats(data);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStats(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadStats();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Set working memory with dirty tracking
|
||||
const setWorkingMemory = useCallback((content: string) => {
|
||||
setWorkingMemoryRaw(content);
|
||||
setWorkingMemoryDirty(true);
|
||||
}, []);
|
||||
|
||||
// Save working memory
|
||||
const saveWorkingMemory = useCallback(async () => {
|
||||
if (!workingMemoryDirty) return;
|
||||
|
||||
setSavingWorkingMemory(true);
|
||||
try {
|
||||
await saveMemory(workingMemory, projectId);
|
||||
setWorkingMemoryDirty(false);
|
||||
} finally {
|
||||
setSavingWorkingMemory(false);
|
||||
}
|
||||
}, [workingMemory, workingMemoryDirty, projectId]);
|
||||
|
||||
// Save memory settings
|
||||
const saveMemorySettings = useCallback(async (patch: Partial<MemorySettingsState>) => {
|
||||
setSavingMemorySettings(true);
|
||||
@@ -531,40 +458,22 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Compact memory
|
||||
const compactMemoryAction = useCallback(async (path?: string) => {
|
||||
// Compact the selected memory file in place
|
||||
const compactMemoryAction = useCallback(async (path: string) => {
|
||||
setCompacting(true);
|
||||
try {
|
||||
const result = path
|
||||
? await compactMemoryApi(path, projectId)
|
||||
: await compactMemoryApi(projectId);
|
||||
|
||||
if (path) {
|
||||
const nextPath = result.path ?? path;
|
||||
setSelectedFilePath(nextPath);
|
||||
setSelectedFileContentRaw(result.content);
|
||||
setSelectedFileDirty(false);
|
||||
await reloadMemoryFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy behavior for single-file working memory editor
|
||||
setWorkingMemoryRaw(result.content);
|
||||
setWorkingMemoryDirty(true);
|
||||
const result = await compactMemoryApi(path, projectId);
|
||||
const nextPath = result.path ?? path;
|
||||
setSelectedFilePath(nextPath);
|
||||
setSelectedFileContentRaw(result.content);
|
||||
setSelectedFileDirty(false);
|
||||
await reloadMemoryFiles();
|
||||
} finally {
|
||||
setCompacting(false);
|
||||
}
|
||||
}, [projectId, reloadMemoryFiles]);
|
||||
|
||||
return {
|
||||
// Working memory
|
||||
workingMemory,
|
||||
workingMemoryLoading,
|
||||
workingMemoryDirty,
|
||||
setWorkingMemory,
|
||||
saveWorkingMemory,
|
||||
savingWorkingMemory,
|
||||
|
||||
// Insights
|
||||
insightsContent,
|
||||
insightsLoading,
|
||||
@@ -616,8 +525,5 @@ export function useMemoryData(options: UseMemoryDataOptions = {}): UseMemoryData
|
||||
installQmdAction,
|
||||
installingQmd,
|
||||
testRetrieval: testRetrievalAction,
|
||||
|
||||
// Stats
|
||||
stats,
|
||||
};
|
||||
}
|
||||
|
||||