From 92762196815e2145df39d23bb812974c5c27a3f8 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 31 Oct 2026 06:19:25 -0700
Subject: [PATCH 1/6] feat(v2): agent Memory tab grouped as Rules / How I work
/ Journal (TASK-690 slice 1)
Adds /v2/agents/:agent/:instance/memory, reached from the profile and
settings tab navs. Owner/admin memory index is grouped by section key;
the mapping lives in MEMORY_GROUPS. Non-owners see a denied notice.
Co-Authored-By: Claude Haiku 5.5
Co-authored-by: lark
Agent-Model: claude/claude-haiku-5-5/default
---
frontend/src/v2/V2App.tsx | 5 +
.../src/v2/__tests__/V2AgentMemory.test.tsx | 67 ++++++++++++
frontend/src/v2/agents/V2AgentMemory.tsx | 101 ++++++++++++++++++
frontend/src/v2/agents/V2AgentProfile.tsx | 1 +
.../src/v2/agents/V2AgentSettingsPage.tsx | 1 +
frontend/src/v2/agents/v2-agent-settings.css | 8 ++
6 files changed, 183 insertions(+)
create mode 100644 frontend/src/v2/__tests__/V2AgentMemory.test.tsx
create mode 100644 frontend/src/v2/agents/V2AgentMemory.tsx
diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx
index a3a5119d81..ead02a8084 100644
--- a/frontend/src/v2/V2App.tsx
+++ b/frontend/src/v2/V2App.tsx
@@ -35,6 +35,7 @@ const V2CommunityRedirect = React.lazy(() => import('./components/V2CommunityRed
const V2SettingsPage = React.lazy(() => import('./components/V2SettingsPage'));
const V2AgentProfile = React.lazy(() => import('./agents/V2AgentProfile'));
const V2AgentSettingsPage = React.lazy(() => import('./agents/V2AgentSettingsPage'));
+const V2AgentMemory = React.lazy(() => import('./agents/V2AgentMemory'));
const UserProfile = React.lazy(() => import('../components/UserProfile'));
const V2AgentBYO = React.lazy(() => import('./components/V2AgentBYO'));
const V2ConnectPage = React.lazy(() => import('./components/V2ConnectPage'));
@@ -293,6 +294,10 @@ const V2App: React.FC = () => {
path="agents/:agentName/:instanceId"
element={feature('Agent settings', , false, false)}
/>
+ , false, false)}
+ />
}
diff --git a/frontend/src/v2/__tests__/V2AgentMemory.test.tsx b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx
new file mode 100644
index 0000000000..7c884d6ea7
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx
@@ -0,0 +1,67 @@
+// @ts-nocheck
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import V2AgentMemory, { groupSections } from '../agents/V2AgentMemory';
+
+jest.mock('axios', () => {
+ const mock = {
+ get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn(),
+ defaults: { baseURL: '', headers: { common: {} } },
+ interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
+ };
+ return { __esModule: true, default: mock, ...mock };
+});
+const axios = jest.requireMock('axios').default;
+
+const section = (key, notes = 1) => ({
+ key, label: key, kind: 'x', notes: Array.from({ length: notes }, (_, i) => ({ header: `${key} ${i}`, snippet: '' })),
+});
+
+describe('groupSections', () => {
+ it('puts each known section in its group', () => {
+ const groups = groupSections([section('soul'), section('long_term'), section('shared'), section('daily')]);
+ const byId = Object.fromEntries(groups.map((g) => [g.id, g.sections.map((s) => s.key)]));
+ expect(byId.rules).toEqual(['soul', 'long_term']);
+ expect(byId.how).toEqual(['shared']);
+ expect(byId.journal).toEqual(['daily']);
+ });
+
+ it('falls back to How I work for an unknown section so nothing is hidden', () => {
+ const groups = groupSections([section('brand_new_key')]);
+ expect(groups.find((g) => g.id === 'how').sections.map((s) => s.key)).toEqual(['brand_new_key']);
+ });
+});
+
+describe('V2AgentMemory', () => {
+ it('renders the three groups from the owner index', async () => {
+ axios.get.mockResolvedValue({ data: {
+ viewerRole: 'owner', totalEntries: 3,
+ sections: [section('long_term', 2), section('daily', 1)],
+ } });
+ render(
+
+
+ } />
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByRole('heading', { name: /Rules/ })).toBeInTheDocument());
+ expect(screen.getByRole('heading', { name: /How I work/ })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument();
+ expect(axios.get).toHaveBeenCalledWith('/api/agent-memory/maya-claude/default');
+ });
+
+ it('shows a denied notice instead of the index when the viewer is not owner or admin', async () => {
+ axios.get.mockRejectedValue(new Error('403'));
+ render(
+
+
+ } />
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
+ expect(screen.queryByRole('heading', { name: /Rules/ })).toBeNull();
+ });
+});
diff --git a/frontend/src/v2/agents/V2AgentMemory.tsx b/frontend/src/v2/agents/V2AgentMemory.tsx
new file mode 100644
index 0000000000..f79498c984
--- /dev/null
+++ b/frontend/src/v2/agents/V2AgentMemory.tsx
@@ -0,0 +1,101 @@
+import React, { useEffect, useState } from 'react';
+import { Link, useParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import axios from '../../utils/axiosConfig';
+import V2PageHead from '../components/V2PageHead';
+import './v2-agent-settings.css';
+
+// Owner/admin memory index, as served by GET /api/agent-memory/:agentName/:instanceId.
+interface MemoryNote { header: string; snippet: string }
+interface MemorySection { key: string; label: string; kind: string; notes: MemoryNote[] }
+interface MemoryIndex { viewerRole: 'owner' | 'admin'; totalEntries: number; sections: MemorySection[] }
+
+// Which backend sections land in which group. Rules are what the agent is told
+// to do; How I work is how it runs; Journal is what happened. An unknown section
+// key falls into How I work so nothing is hidden. Change the mapping here only.
+export const MEMORY_GROUPS = [
+ { id: 'rules', label: 'Rules', keys: ['soul', 'long_term'] },
+ { id: 'how', label: 'How I work', keys: ['shared', 'runtime_meta', 'relationships'] },
+ { id: 'journal', label: 'Journal', keys: ['daily', 'cycles'] },
+] as const;
+
+const FALLBACK_GROUP = 'how';
+
+export const groupSections = (sections: MemorySection[]) => {
+ const byKey = new Map();
+ MEMORY_GROUPS.forEach((g) => g.keys.forEach((k) => byKey.set(k, g.id)));
+ return MEMORY_GROUPS.map((g) => ({
+ ...g,
+ sections: sections.filter((s) => (byKey.get(s.key) || FALLBACK_GROUP) === g.id),
+ }));
+};
+
+const V2AgentMemory: React.FC = () => {
+ const { t } = useTranslation();
+ const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>();
+ const [index, setIndex] = useState(null);
+ const [state, setState] = useState<'loading' | 'ready' | 'denied'>('loading');
+
+ useEffect(() => {
+ let cancelled = false;
+ setState('loading');
+ axios
+ .get(`/api/agent-memory/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId || 'default')}`)
+ .then((res) => {
+ if (cancelled) return;
+ setIndex(res.data);
+ setState('ready');
+ })
+ .catch(() => {
+ if (!cancelled) setState('denied');
+ });
+ return () => { cancelled = true; };
+ }, [agentName, instanceId]);
+
+ const profileHref = `/v2/agent/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`;
+ const settingsHref = `/v2/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`;
+
+ return (
+
+
+
+
+ {state === 'loading' && {t('common.loading', { defaultValue: 'Loading…' })}
}
+ {state === 'denied' && (
+
+ {t('agentProfile.memory.none', { defaultValue: 'Memory is visible to the agent owner and admins.' })}
+
+ )}
+ {state === 'ready' && index && groupSections(index.sections).map((group) => (
+
+
+ {group.label}
+ {group.sections.reduce((n, s) => n + s.notes.length, 0)}
+
+ {group.sections.length === 0 && (
+ {t('agentProfile.memory.none', { defaultValue: 'Nothing here yet.' })}
+ )}
+ {group.sections.map((sec) => (
+
+
{sec.label}
+
+ {sec.notes.map((n, i) => (
+ -
+ {n.header}
+ {n.snippet && {n.snippet}}
+
+ ))}
+
+
+ ))}
+
+ ))}
+
+ );
+};
+
+export default V2AgentMemory;
diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx
index 2b0151b159..3aae3d0b54 100644
--- a/frontend/src/v2/agents/V2AgentProfile.tsx
+++ b/frontend/src/v2/agents/V2AgentProfile.tsx
@@ -452,6 +452,7 @@ const V2AgentProfile: React.FC = () => {
)}
diff --git a/frontend/src/v2/agents/V2AgentSettingsPage.tsx b/frontend/src/v2/agents/V2AgentSettingsPage.tsx
index 6c9f9ed47c..254c0429c0 100644
--- a/frontend/src/v2/agents/V2AgentSettingsPage.tsx
+++ b/frontend/src/v2/agents/V2AgentSettingsPage.tsx
@@ -258,6 +258,7 @@ const V2AgentSettingsPage: React.FC = () => {
diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css
index aa1c2a3799..79a074c38b 100644
--- a/frontend/src/v2/agents/v2-agent-settings.css
+++ b/frontend/src/v2/agents/v2-agent-settings.css
@@ -126,3 +126,11 @@
.v2-agent-settings__section-head { flex-direction: column; align-items: flex-start; }
.v2-agent-settings__key-meta { display: grid; gap: 4px; }
}
+
+/* Agent memory tab (TASK-690 slice 1): grouped index, one column at 390. */
+.v2-agent-memory__sec { margin-top: 12px; }
+.v2-agent-memory__sec-title { font-size: 14px; margin: 0 0 6px; }
+.v2-agent-memory__notes { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.v2-agent-memory__note { display: grid; gap: 2px; }
+.v2-agent-memory__note-h { font-weight: 600; }
+.v2-agent-memory__note-s { opacity: 0.75; font-size: 13px; }
From 4aa61859a5d067d7b34cc44f4d365cbb00517103 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 31 Oct 2026 06:28:32 -0700
Subject: [PATCH 2/6] feat(v2): split agent long-term memory into Rules and How
I work by heading (TASK-690)
Rule-named headings (Rules, Never, Always, Don't) go under Rules; other
headings and the Overview preamble go under How I work. Soul leads Rules.
Empty groups are hidden. No API or schema change.
Co-Authored-By: Claude Haiku 5.5
Co-authored-by: lark
Agent-Model: claude/claude-haiku-5-5/default
---
.../src/v2/__tests__/V2AgentMemory.test.tsx | 30 +++++++---
frontend/src/v2/agents/V2AgentMemory.tsx | 59 ++++++++++++++-----
2 files changed, 66 insertions(+), 23 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2AgentMemory.test.tsx b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx
index 7c884d6ea7..bcd6a318bc 100644
--- a/frontend/src/v2/__tests__/V2AgentMemory.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx
@@ -17,19 +17,33 @@ const axios = jest.requireMock('axios').default;
const section = (key, notes = 1) => ({
key, label: key, kind: 'x', notes: Array.from({ length: notes }, (_, i) => ({ header: `${key} ${i}`, snippet: '' })),
});
+const named = (key, headers) => ({
+ key, label: key, kind: 'doc', notes: headers.map((h) => ({ header: h, snippet: '' })),
+});
+const headersOf = (groups, id) => groups.find((g) => g.id === id).sections.flatMap((s) => s.notes.map((n) => n.header));
describe('groupSections', () => {
- it('puts each known section in its group', () => {
- const groups = groupSections([section('soul'), section('long_term'), section('shared'), section('daily')]);
- const byId = Object.fromEntries(groups.map((g) => [g.id, g.sections.map((s) => s.key)]));
- expect(byId.rules).toEqual(['soul', 'long_term']);
- expect(byId.how).toEqual(['shared']);
- expect(byId.journal).toEqual(['daily']);
+ it('puts soul first in Rules and journal sections in Journal', () => {
+ const groups = groupSections([section('daily'), section('soul'), section('cycles')]);
+ expect(headersOf(groups, 'rules')).toEqual(['soul 0']);
+ expect(headersOf(groups, 'journal')).toEqual(['daily 0', 'cycles 0']);
+ });
+
+ it('splits long_term by heading: rule-named headings go to Rules, the rest to How I work', () => {
+ const groups = groupSections([named('long_term', ['Overview', 'Rules', 'Never force-push', 'Host notes', 'Don\'t run find /'])]);
+ expect(headersOf(groups, 'rules')).toEqual(['Rules', 'Never force-push', 'Don\'t run find /']);
+ expect(headersOf(groups, 'how')).toEqual(['Overview', 'Host notes']);
+ });
+
+ it('shows a single untitled long_term note under How I work with no empty Rules group', () => {
+ const groups = groupSections([named('long_term', ['Overview'])]);
+ expect(groups.find((g) => g.id === 'rules').sections).toEqual([]);
+ expect(headersOf(groups, 'how')).toEqual(['Overview']);
});
it('falls back to How I work for an unknown section so nothing is hidden', () => {
const groups = groupSections([section('brand_new_key')]);
- expect(groups.find((g) => g.id === 'how').sections.map((s) => s.key)).toEqual(['brand_new_key']);
+ expect(headersOf(groups, 'how')).toEqual(['brand_new_key 0']);
});
});
@@ -37,7 +51,7 @@ describe('V2AgentMemory', () => {
it('renders the three groups from the owner index', async () => {
axios.get.mockResolvedValue({ data: {
viewerRole: 'owner', totalEntries: 3,
- sections: [section('long_term', 2), section('daily', 1)],
+ sections: [named('long_term', ['Rules', 'Host notes']), section('daily', 1)],
} });
render(
diff --git a/frontend/src/v2/agents/V2AgentMemory.tsx b/frontend/src/v2/agents/V2AgentMemory.tsx
index f79498c984..08009c4b70 100644
--- a/frontend/src/v2/agents/V2AgentMemory.tsx
+++ b/frontend/src/v2/agents/V2AgentMemory.tsx
@@ -10,23 +10,55 @@ interface MemoryNote { header: string; snippet: string }
interface MemorySection { key: string; label: string; kind: string; notes: MemoryNote[] }
interface MemoryIndex { viewerRole: 'owner' | 'admin'; totalEntries: number; sections: MemorySection[] }
-// Which backend sections land in which group. Rules are what the agent is told
-// to do; How I work is how it runs; Journal is what happened. An unknown section
-// key falls into How I work so nothing is hidden. Change the mapping here only.
+// Which backend sections land in which group. Journal is the dated log. Soul
+// is a rule-like section and sits at the top of Rules. Long-term memory is split
+// by its own headings (see splitLongTerm). Any other section lands in How I work
+// so nothing is hidden. Change the mapping here only.
export const MEMORY_GROUPS = [
- { id: 'rules', label: 'Rules', keys: ['soul', 'long_term'] },
- { id: 'how', label: 'How I work', keys: ['shared', 'runtime_meta', 'relationships'] },
- { id: 'journal', label: 'Journal', keys: ['daily', 'cycles'] },
+ { id: 'rules', label: 'Rules' },
+ { id: 'how', label: 'How I work' },
+ { id: 'journal', label: 'Journal' },
] as const;
-const FALLBACK_GROUP = 'how';
+type GroupId = typeof MEMORY_GROUPS[number]['id'];
+interface GroupedSection { key: string; label: string; notes: MemoryNote[] }
+
+const JOURNAL_KEYS = new Set(['daily', 'cycles']);
+// A long_term heading whose first word names a rule goes under Rules.
+const RULE_HEADING = /^(rules?|never|always|don'?t)\b/i;
+
+const sectionGroup = (key: string): GroupId => (JOURNAL_KEYS.has(key) ? 'journal' : 'how');
+
+// Splits a long_term section's headings into Rules and How I work. The
+// "Overview" note (text before the first heading) is How I work.
+export const splitLongTerm = (section: MemorySection): Record<'rules' | 'how', GroupedSection | null> => {
+ const rules = section.notes.filter((n) => RULE_HEADING.test(n.header));
+ const how = section.notes.filter((n) => !RULE_HEADING.test(n.header));
+ const make = (notes: MemoryNote[]): GroupedSection | null => (
+ notes.length ? { key: section.key, label: section.label, notes } : null
+ );
+ return { rules: make(rules), how: make(how) };
+};
export const groupSections = (sections: MemorySection[]) => {
- const byKey = new Map();
- MEMORY_GROUPS.forEach((g) => g.keys.forEach((k) => byKey.set(k, g.id)));
+ const byGroup: Record = { rules: [], how: [], journal: [] };
+ // Soul first, so it leads Rules.
+ const soul = sections.find((s) => s.key === 'soul');
+ if (soul) byGroup.rules.push({ key: soul.key, label: soul.label, notes: soul.notes });
+ for (const s of sections) {
+ if (s.key === 'soul') continue;
+ if (s.key === 'long_term') {
+ const split = splitLongTerm(s);
+ if (split.rules) byGroup.rules.push(split.rules);
+ if (split.how) byGroup.how.push(split.how);
+ continue;
+ }
+ byGroup[sectionGroup(s.key)].push({ key: s.key, label: s.label, notes: s.notes });
+ }
return MEMORY_GROUPS.map((g) => ({
- ...g,
- sections: sections.filter((s) => (byKey.get(s.key) || FALLBACK_GROUP) === g.id),
+ id: g.id,
+ label: g.label,
+ sections: byGroup[g.id],
}));
};
@@ -70,15 +102,12 @@ const V2AgentMemory: React.FC = () => {
{t('agentProfile.memory.none', { defaultValue: 'Memory is visible to the agent owner and admins.' })}
)}
- {state === 'ready' && index && groupSections(index.sections).map((group) => (
+ {state === 'ready' && index && groupSections(index.sections).filter((g) => g.sections.length > 0).map((group) => (
{group.label}
{group.sections.reduce((n, s) => n + s.notes.length, 0)}
- {group.sections.length === 0 && (
- {t('agentProfile.memory.none', { defaultValue: 'Nothing here yet.' })}
- )}
{group.sections.map((sec) => (
{sec.label}
From 39d3dffa087b1ad2352723760f67e29f01132fc3 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 31 Oct 2026 14:35:02 -0700
Subject: [PATCH 3/6] feat(v2): memory tab 1440 layout: group nav, search,
notes as rows (TASK-690 slice 1)
Frame without tags and recency chips: the memory index has no tag or
updated-at data yet (slice 2 adds it).
Co-Authored-By: Claude Haiku 5.5
Co-authored-by: lark
Agent-Model: claude/claude-haiku-5-5/default
---
.../src/v2/__tests__/V2AgentMemory.test.tsx | 20 ++++-
frontend/src/v2/agents/V2AgentMemory.tsx | 76 ++++++++++++++-----
frontend/src/v2/agents/v2-agent-settings.css | 16 +++-
3 files changed, 93 insertions(+), 19 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2AgentMemory.test.tsx b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx
index bcd6a318bc..02897e92c6 100644
--- a/frontend/src/v2/__tests__/V2AgentMemory.test.tsx
+++ b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx
@@ -1,6 +1,6 @@
// @ts-nocheck
import React from 'react';
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import V2AgentMemory, { groupSections } from '../agents/V2AgentMemory';
@@ -66,6 +66,24 @@ describe('V2AgentMemory', () => {
expect(axios.get).toHaveBeenCalledWith('/api/agent-memory/maya-claude/default');
});
+ it('filters notes by the search box and keeps only groups that still match', async () => {
+ axios.get.mockResolvedValue({ data: {
+ viewerRole: 'owner', totalEntries: 3,
+ sections: [named('long_term', ['Rules', 'Host notes']), section('daily', 1)],
+ } });
+ render(
+
+
+ } />
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument());
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'host' } });
+ expect(screen.getByText('Host notes')).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: /Journal/ })).toBeNull();
+ });
+
it('shows a denied notice instead of the index when the viewer is not owner or admin', async () => {
axios.get.mockRejectedValue(new Error('403'));
render(
diff --git a/frontend/src/v2/agents/V2AgentMemory.tsx b/frontend/src/v2/agents/V2AgentMemory.tsx
index 08009c4b70..d5efd25dfa 100644
--- a/frontend/src/v2/agents/V2AgentMemory.tsx
+++ b/frontend/src/v2/agents/V2AgentMemory.tsx
@@ -67,6 +67,7 @@ const V2AgentMemory: React.FC = () => {
const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>();
const [index, setIndex] = useState(null);
const [state, setState] = useState<'loading' | 'ready' | 'denied'>('loading');
+ const [query, setQuery] = useState('');
useEffect(() => {
let cancelled = false;
@@ -102,27 +103,68 @@ const V2AgentMemory: React.FC = () => {
{t('agentProfile.memory.none', { defaultValue: 'Memory is visible to the agent owner and admins.' })}
)}
- {state === 'ready' && index && groupSections(index.sections).filter((g) => g.sections.length > 0).map((group) => (
-
-
- {group.label}
- {group.sections.reduce((n, s) => n + s.notes.length, 0)}
-
- {group.sections.map((sec) => (
-
-
{sec.label}
-
- {sec.notes.map((n, i) => (
- -
- {n.header}
- {n.snippet && {n.snippet}}
+ {state === 'ready' && index && (() => {
+ const needle = query.trim().toLowerCase();
+ const groups = groupSections(index.sections)
+ .map((group) => ({
+ ...group,
+ sections: group.sections
+ .map((sec) => ({
+ ...sec,
+ notes: needle
+ ? sec.notes.filter((n) => `${n.header} ${n.snippet}`.toLowerCase().includes(needle))
+ : sec.notes,
+ }))
+ .filter((sec) => sec.notes.length > 0),
+ }));
+ return (
+
+
+
+ {groups.filter((g) => g.sections.length > 0).map((group) => (
+
+
+ {group.label}
+ {group.sections.reduce((n, s) => n + s.notes.length, 0)}
+
+ {group.sections.map((sec) => (
+
+
{sec.label}
+
+ {sec.notes.map((n, i) => (
+ -
+ {n.header}
+ {n.snippet && {n.snippet}}
+
+ ))}
+
+
+ ))}
+
+ ))}
- ))}
-
- ))}
+
+ );
+ })()}
);
};
diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css
index 79a074c38b..d82d1eddb4 100644
--- a/frontend/src/v2/agents/v2-agent-settings.css
+++ b/frontend/src/v2/agents/v2-agent-settings.css
@@ -127,7 +127,21 @@
.v2-agent-settings__key-meta { display: grid; gap: 4px; }
}
-/* Agent memory tab (TASK-690 slice 1): grouped index, one column at 390. */
+/* Agent memory tab (TASK-690 slice 1): group nav beside the notes at 1440,
+ stacked below 768. Tags and recency are not drawn: the API has no data
+ for them yet (slice 2). */
+.v2-agent-memory { display: grid; gap: 20px; }
+@media (min-width: 768px) {
+ .v2-agent-memory { grid-template-columns: 200px minmax(0, 1fr); align-items: start; }
+}
+.v2-agent-memory__nav { display: grid; gap: 8px; }
+.v2-agent-memory__search { width: 100%; height: 32px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; border: 1px solid var(--v2-border, #d0d4dc); background: transparent; color: inherit; font: inherit; font-size: 13px; }
+.v2-agent-memory__nav ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
+.v2-agent-memory__nav a { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; border-radius: 8px; color: inherit; text-decoration: none; font-size: 13.5px; }
+.v2-agent-memory__nav a:hover { background: var(--v2-hover, rgba(127, 127, 127, 0.12)); }
+.v2-agent-memory__nav a .v2-aprofile__count { margin-left: auto; }
+.v2-agent-memory__group { display: grid; gap: 8px; }
+.v2-agent-memory__group-title { display: flex; align-items: center; gap: 8px; font-size: 15px; margin: 0; }
.v2-agent-memory__sec { margin-top: 12px; }
.v2-agent-memory__sec-title { font-size: 14px; margin: 0 0 6px; }
.v2-agent-memory__notes { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
From f89c584ded8225fb411a30b95f7430e9eb39947a Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:04:12 -0700
Subject: [PATCH 4/6] fix(i18n): add agentSettings.memory to en and zh-CN
(TASK-690)
The memory tab link added in slice 1 used the key without a locale entry,
which failed the migrated-component key manifest.
Co-Authored-By: Claude Haiku 5.5
Co-authored-by: lark
Agent-Model: claude/claude-haiku-5-5/default
---
frontend/src/i18n/locales/en.json | 1 +
frontend/src/i18n/locales/zh-CN.json | 1 +
2 files changed, 2 insertions(+)
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 9f4722335c..39c1a57761 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -2765,6 +2765,7 @@
"tabs": "Agent tabs",
"profile": "Profile",
"settings": "Settings",
+ "memory": "Memory",
"key": "Key",
"regenerate": "Regenerate key",
"wrapperReconnect": "Replace this computer's key from Control.",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 86754ad1e5..fc92278ff3 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -2768,6 +2768,7 @@
"tabs": "智能体标签页",
"profile": "资料",
"settings": "设置",
+ "memory": "记忆",
"key": "密钥",
"regenerate": "重新生成密钥",
"wrapperReconnect": "请在「控制」中替换这台电脑的密钥。",
From dc971dedaecb490c63d405a1438884fe2caab759 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 31 Oct 2026 16:35:43 -0700
Subject: [PATCH 5/6] fix(v2): space the memory groups at 1440 (TASK-690 slice
1)
Found on a headless 1440 render of the tab: the three groups ran together
with no gap in the body column.
Co-Authored-By: Claude Haiku 5.5
Co-authored-by: lark
Agent-Model: claude/claude-haiku-5-5/default
---
frontend/src/v2/agents/v2-agent-settings.css | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css
index d82d1eddb4..913abb5deb 100644
--- a/frontend/src/v2/agents/v2-agent-settings.css
+++ b/frontend/src/v2/agents/v2-agent-settings.css
@@ -140,6 +140,7 @@
.v2-agent-memory__nav a { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; border-radius: 8px; color: inherit; text-decoration: none; font-size: 13.5px; }
.v2-agent-memory__nav a:hover { background: var(--v2-hover, rgba(127, 127, 127, 0.12)); }
.v2-agent-memory__nav a .v2-aprofile__count { margin-left: auto; }
+.v2-agent-memory__body { display: grid; gap: 28px; min-width: 0; }
.v2-agent-memory__group { display: grid; gap: 8px; }
.v2-agent-memory__group-title { display: flex; align-items: center; gap: 8px; font-size: 15px; margin: 0; }
.v2-agent-memory__sec { margin-top: 12px; }
From 7a479bc58e795e3aed9cd9adadb0178a50dd72ad Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 31 Oct 2026 18:40:14 -0700
Subject: [PATCH 6/6] fix(v2): match the D215 memory frame at 1440 (TASK-690
slice 1)
Search moves to the foot of the group nav, group icons and bordered list
cards follow the frame, and rows drop the per-note heading split.
Co-Authored-By: Claude Haiku 5.5
Co-authored-by: lark
Agent-Model: claude/claude-haiku-5-5/default
---
frontend/src/v2/agents/V2AgentMemory.tsx | 72 +++++++++++++-------
frontend/src/v2/agents/v2-agent-settings.css | 46 ++++++++-----
2 files changed, 76 insertions(+), 42 deletions(-)
diff --git a/frontend/src/v2/agents/V2AgentMemory.tsx b/frontend/src/v2/agents/V2AgentMemory.tsx
index d5efd25dfa..2f815c8c35 100644
--- a/frontend/src/v2/agents/V2AgentMemory.tsx
+++ b/frontend/src/v2/agents/V2AgentMemory.tsx
@@ -62,6 +62,22 @@ export const groupSections = (sections: MemorySection[]) => {
}));
};
+// Frame icons (docs/design/artboards/d215, #s-memory): stroke-only, 1.8 weight.
+const ICON_PATHS: Record<'pin' | 'brain' | 'cal' | 'search' | 'info', React.ReactNode> = {
+ pin: ,
+ brain: ,
+ cal: <>>,
+ search: <>>,
+ info: <>>,
+};
+const MemIcon: React.FC<{ name: keyof typeof ICON_PATHS; size?: number }> = ({ name, size = 16 }) => (
+
+);
+const GROUP_ICON: Record = { rules: 'pin', how: 'brain', journal: 'cal' };
+
const V2AgentMemory: React.FC = () => {
const { t } = useTranslation();
const { agentName = '', instanceId = '' } = useParams<{ agentName: string; instanceId: string }>();
@@ -120,45 +136,53 @@ const V2AgentMemory: React.FC = () => {
return (
{groups.filter((g) => g.sections.length > 0).map((group) => (
-
+
+
{group.label}
- {group.sections.reduce((n, s) => n + s.notes.length, 0)}
- {group.sections.map((sec) => (
-
-
{sec.label}
-
- {sec.notes.map((n, i) => (
- -
- {n.header}
- {n.snippet && {n.snippet}}
-
- ))}
-
-
- ))}
+
+ {group.sections.flatMap((sec) => sec.notes).map((n, i) => (
+ group.id === 'journal' ? (
+ -
+ {n.header}
+ {n.snippet}
+
+ ) : (
+ -
+
+
+
{n.header}
+ {n.snippet &&
{n.snippet}
}
+
+
+ )
+ ))}
+
))}
diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css
index 913abb5deb..cacad78458 100644
--- a/frontend/src/v2/agents/v2-agent-settings.css
+++ b/frontend/src/v2/agents/v2-agent-settings.css
@@ -127,25 +127,35 @@
.v2-agent-settings__key-meta { display: grid; gap: 4px; }
}
-/* Agent memory tab (TASK-690 slice 1): group nav beside the notes at 1440,
- stacked below 768. Tags and recency are not drawn: the API has no data
- for them yet (slice 2). */
+/* Agent memory tab (TASK-690 slice 1): frame metrics from docs/design/artboards/d215
+ #s-memory at 1440. Group nav at 200px beside the lists, stacked below 768.
+ Tags and recency are not drawn: the API has no data for them (slice 2 adds only
+ the section-level update time). */
.v2-agent-memory { display: grid; gap: 20px; }
@media (min-width: 768px) {
- .v2-agent-memory { grid-template-columns: 200px minmax(0, 1fr); align-items: start; }
+ .v2-agent-memory { grid-template-columns: 200px minmax(0, 1fr); gap: 24px; align-items: start; }
}
-.v2-agent-memory__nav { display: grid; gap: 8px; }
-.v2-agent-memory__search { width: 100%; height: 32px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; border: 1px solid var(--v2-border, #d0d4dc); background: transparent; color: inherit; font: inherit; font-size: 13px; }
+.v2-agent-memory__nav { display: grid; gap: 2px; align-content: start; }
.v2-agent-memory__nav ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
-.v2-agent-memory__nav a { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; border-radius: 8px; color: inherit; text-decoration: none; font-size: 13.5px; }
-.v2-agent-memory__nav a:hover { background: var(--v2-hover, rgba(127, 127, 127, 0.12)); }
-.v2-agent-memory__nav a .v2-aprofile__count { margin-left: auto; }
-.v2-agent-memory__body { display: grid; gap: 28px; min-width: 0; }
-.v2-agent-memory__group { display: grid; gap: 8px; }
-.v2-agent-memory__group-title { display: flex; align-items: center; gap: 8px; font-size: 15px; margin: 0; }
-.v2-agent-memory__sec { margin-top: 12px; }
-.v2-agent-memory__sec-title { font-size: 14px; margin: 0 0 6px; }
-.v2-agent-memory__notes { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
-.v2-agent-memory__note { display: grid; gap: 2px; }
-.v2-agent-memory__note-h { font-weight: 600; }
-.v2-agent-memory__note-s { opacity: 0.75; font-size: 13px; }
+.v2-agent-memory__nav a { display: flex; align-items: center; gap: 9px; height: 34px; padding: 0 10px; border-radius: 8px; color: var(--v2-text-secondary); text-decoration: none; font-size: 13.5px; }
+.v2-agent-memory__nav a:hover { background: var(--v2-surface-hover); }
+.v2-agent-memory__icon { flex: none; }
+.v2-agent-memory__nav a .v2-agent-memory__icon { width: 16px; height: 16px; }
+.v2-agent-memory__count { margin-left: auto; font-size: 11px; color: var(--v2-text-tertiary); }
+.v2-agent-memory__search { display: flex; align-items: center; gap: 7px; height: 34px; margin-top: 12px; box-sizing: border-box; padding: 0 8px 0 10px; border-radius: 10px; border: 1px solid var(--v2-border-soft); background: var(--v2-surface); color: var(--v2-text-tertiary); font-size: 13px; }
+.v2-agent-memory__search .v2-agent-memory__icon { width: 15px; height: 15px; }
+.v2-agent-memory__search input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--v2-text-primary); font: inherit; }
+.v2-agent-memory__search:focus-within { box-shadow: var(--v2-focus-ring); }
+.v2-agent-memory__body { display: grid; min-width: 0; }
+.v2-agent-memory__group { margin-bottom: 18px; }
+.v2-agent-memory__gtitle { display: flex; align-items: center; gap: 8px; margin: 0 4px 8px; font: 500 11.5px/1 var(--v2-font-mono); color: var(--v2-text-tertiary); }
+.v2-agent-memory__gtitle .v2-agent-memory__icon { width: 13px; height: 13px; }
+.v2-agent-memory__list { list-style: none; margin: 0; padding: 0; border-radius: 12px; border: 1px solid var(--v2-border-soft); background: var(--v2-surface); overflow: hidden; }
+.v2-agent-memory__list > li { border-top: 1px solid var(--v2-border-soft); }
+.v2-agent-memory__list > li:first-child { border-top: 0; }
+.v2-agent-memory__mnote { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 10px; align-items: start; padding: 11px 14px; }
+.v2-agent-memory__mnote .v2-agent-memory__icon { width: 16px; height: 16px; color: var(--v2-text-secondary); margin-top: 2px; }
+.v2-agent-memory__tx { font-size: 13.5px; line-height: 20px; color: var(--v2-text-primary); }
+.v2-agent-memory__snip { font-size: 12.5px; line-height: 18px; color: var(--v2-text-secondary); }
+.v2-agent-memory__jday { display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 12px; padding: 10px 14px; font-size: 13.5px; color: var(--v2-text-secondary); }
+.v2-agent-memory__jdate { font-size: 11.5px; color: var(--v2-text-tertiary); line-height: 20px; }