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