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>
This commit is contained in:
gsxdsm
2026-07-10 23:32:53 -07:00
parent 7f418b7d10
commit 1d2d73ba5c
24 changed files with 498 additions and 515 deletions

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -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)!;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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 */}

View File

@@ -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 {

View File

@@ -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();
});
});

View File

@@ -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,
}),
]),

View File

@@ -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);
});
});

View File

@@ -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,
};
}