diff --git a/backend/__tests__/service/agent-memory-editor.test.js b/backend/__tests__/service/agent-memory-editor.test.js index 6b2ccbabc9..00f83f263e 100644 --- a/backend/__tests__/service/agent-memory-editor.test.js +++ b/backend/__tests__/service/agent-memory-editor.test.js @@ -138,4 +138,31 @@ describe('owner memory editor', () => { expect(memory.sections.long_term.content).toBe('Old note'); expect(memory.content).toBe('Old note'); }); + + it('index sections carry the section update time and no per-note time', async () => { + await AgentMemory.updateOne({ agentName: 'editor-agent', instanceId: 'machine-1' }, { $set: { + 'sections.long_term.content': '## Rules\nNever merge without a GATE line.\n## Overview\nReviews PRs.', + 'sections.daily': [{ date: '2026-10-28', content: '#401 approved' }], + } }); + const index = await request(app).get('/api/agent-memory/editor-agent/machine-1') + .set('Authorization', `Bearer ${token(owner)}`); + expect(index.status).toBe(200); + const longTerm = index.body.sections.find((s) => s.key === 'long_term'); + const daily = index.body.sections.find((s) => s.key === 'daily'); + expect(longTerm.updatedAt).toBe(new Date('2026-01-01').toISOString()); + expect(daily.updatedAt).toBeNull(); + for (const note of [...longTerm.notes, ...daily.notes]) { + expect(Object.keys(note).sort()).toEqual(['header', 'snippet']); + } + }); + + it('index returns a null section update time when the section has none', async () => { + await AgentMemory.updateOne({ agentName: 'editor-agent', instanceId: 'machine-1' }, { + $set: { 'sections.long_term.content': '## Rules\nNever merge.' }, + $unset: { 'sections.long_term.updatedAt': '' }, + }); + const index = await request(app).get('/api/agent-memory/editor-agent/machine-1') + .set('Authorization', `Bearer ${token(owner)}`); + expect(index.body.sections.find((s) => s.key === 'long_term').updatedAt).toBeNull(); + }); }); diff --git a/backend/routes/agentMemoryView.ts b/backend/routes/agentMemoryView.ts index a3d58149e0..844f5d8a94 100644 --- a/backend/routes/agentMemoryView.ts +++ b/backend/routes/agentMemoryView.ts @@ -55,6 +55,12 @@ const snip = (s: unknown): string => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > SNIPPET ? `${t.slice(0, SNIPPET)}…` : t; }; +// Section last-write time as ISO, or null when the stored value is absent or unparseable. +const isoOrNull = (v: unknown): string | null => { + if (v == null || v === '') return null; + const d = new Date(v as string | number | Date); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +}; // Sections that are internal housekeeping, never shown as "memory". Shared with // the service so the public profile's durable/bookkeeping split and this @@ -278,7 +284,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { .select('sections') .lean(); - const sections: Array<{ key: string; label: string; kind: string; notes: unknown[] }> = []; + const sections: Array<{ key: string; label: string; kind: string; updatedAt: string | null; notes: unknown[] }> = []; let totalEntries = 0; const raw = ((record as Record)?.sections || {}) as Record; @@ -291,7 +297,7 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { .reverse() .map((d: Record) => ({ header: String(d.date || ''), snippet: snip(d.content) })) .filter((n) => n.header || n.snippet); - if (notes.length) { sections.push({ key, label: 'Daily journal', kind: 'log', notes }); totalEntries += notes.length; } + if (notes.length) { sections.push({ key, label: 'Daily journal', kind: 'log', updatedAt: null, notes }); totalEntries += notes.length; } } else if (key === 'cycles' && value && typeof value === 'object') { const entries = Array.isArray((value as Record).entries) ? ((value as Record).entries as Array>) @@ -300,12 +306,13 @@ router.get('/:agentName/:instanceId?', auth, async (req: AuthReq, res: Res) => { header: e.ts ? String(e.ts).slice(0, 10) : 'cycle', snippet: snip(e.content), })); - if (notes.length) { sections.push({ key, label: 'Heartbeat cycles', kind: 'log', notes }); totalEntries += notes.length; } + if (notes.length) { sections.push({ key, label: 'Heartbeat cycles', kind: 'log', updatedAt: null, notes }); totalEntries += notes.length; } } else if (value && typeof value === 'object' && 'content' in (value as Record)) { // long_term (and any future doc-shaped section) → index its headers. const notes = indexMarkdown(String((value as Record).content || '')); const label = key === 'long_term' ? 'Long-term memory' : key.replace(/_/g, ' '); - if (notes.length) { sections.push({ key, label, kind: 'doc', notes }); totalEntries += notes.length; } + const updatedAt = isoOrNull((value as Record).updatedAt); + if (notes.length) { sections.push({ key, label, kind: 'doc', updatedAt, notes }); totalEntries += notes.length; } } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index dc348e8b37..50d3d7776b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1955,6 +1955,7 @@ "viewTag": "{{role}} view · private", "indexSummary_one": "{{notes}} notes across {{count}} section{{updated}}. Visible to you; hidden from the public profile.", "indexSummary_other": "{{notes}} notes across {{count}} sections{{updated}}. Visible to you; hidden from the public profile.", + "sectionUpdated": "Section updated {{when}}", "lastSavedClause": " · last saved to {{section}} {{time}}", "more": "+{{count}} more", "entriesRemembered": "entries remembered", @@ -2772,6 +2773,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 ea11b5b13a..7a04b79f20 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1946,6 +1946,7 @@ "viewTag": "{{role}}视图 · 私有", "indexSummary_one": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。", "indexSummary_other": "{{notes}}条笔记,分布在{{count}}个板块{{updated}}。仅你可见;对公开资料页隐藏。", + "sectionUpdated": "板块更新于{{when}}", "lastSavedClause": " · 最后保存至 {{section}},{{time}}", "more": "还有{{count}}条", "entriesRemembered": "条已记住的条目", @@ -2775,6 +2776,7 @@ "tabs": "智能体标签页", "profile": "资料", "settings": "设置", + "memory": "记忆", "key": "密钥", "regenerate": "重新生成密钥", "wrapperReconnect": "请在「控制」中替换这台电脑的密钥。", 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..39676cdc72 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentMemory.test.tsx @@ -0,0 +1,127 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, 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: '' })), +}); +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 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(headersOf(groups, 'how')).toEqual(['brand_new_key 0']); + }); + + it('carries the section update time onto its group sections and splits it with long_term', () => { + const at = '2026-10-30T12:00:00.000Z'; + const groups = groupSections([{ ...named('long_term', ['Rules', 'Overview']), updatedAt: at }]); + expect(groups.find((g) => g.id === 'rules').sections[0].updatedAt).toBe(at); + expect(groups.find((g) => g.id === 'how').sections[0].updatedAt).toBe(at); + }); +}); + +describe('V2AgentMemory', () => { + it('renders the three groups from the owner index', 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: /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('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 the section update time once per group and never on a note', async () => { + axios.get.mockResolvedValue({ data: { + viewerRole: 'owner', totalEntries: 3, + sections: [ + { ...named('long_term', ['Rules', 'Host notes']), updatedAt: '2026-10-30T12:00:00.000Z' }, + section('daily', 1), + ], + } }); + render( + + + } /> + + , + ); + await waitFor(() => expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument()); + // Rules and How I work each carry the long_term time; Journal has none. + expect(document.querySelectorAll('.v2-agent-memory__gmeta')).toHaveLength(2); + expect(document.querySelectorAll('.v2-agent-memory__mnote .v2-agent-memory__gmeta')).toHaveLength(0); + }); + + 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/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 25ec5d2fb6..b2b1f5551a 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5058,6 +5058,25 @@ describe('agent Identity editor', () => { }); }); +describe('agent Memory tab (TASK-690 slice 1 and 2)', () => { + const css = read('../agents/v2-agent-settings.css'); + const desktop = blockContaining(css, '@media (min-width: 768px)', '.v2-agent-memory'); + const phone = blockContaining(css, '@media (max-width: 767px)', '.v2-agent-memory__nav ul'); + test('desktop keeps the group nav beside the notes at 200px', () => { + expect(ruleBody(css, '.v2-agent-memory')).toContain('display: grid'); + expect(ruleBody(css, '.v2-agent-memory', { within: desktop })).toContain('grid-template-columns: 200px minmax(0, 1fr)'); + }); + test('phone hides the group nav and makes the groups collapsible', () => { + expect(ruleBody(css, '.v2-agent-memory__nav ul', { within: phone })).toContain('display: none'); + expect(ruleBody(css, '.v2-agent-memory__gtitle', { within: phone })).toContain('cursor: pointer'); + }); + test('desktop group titles do not take pointer input, and the chevron is phone-only', () => { + const titles = blockContaining(css, '@media (min-width: 768px)', '.v2-agent-memory__gtitle'); + expect(ruleBody(css, '.v2-agent-memory__gtitle', { within: titles })).toContain('pointer-events: none'); + expect(ruleBody(css, '.v2-agent-memory__chev')).toContain('display: none'); + }); +}); + describe('task priority and lease signals (TASK-443)', () => { const css = read('../v2.css'); test('priority remains a monochrome text badge', () => { diff --git a/frontend/src/v2/agents/V2AgentMemory.tsx b/frontend/src/v2/agents/V2AgentMemory.tsx new file mode 100644 index 0000000000..a7e40330bf --- /dev/null +++ b/frontend/src/v2/agents/V2AgentMemory.tsx @@ -0,0 +1,210 @@ +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 { localizeRelativeTime } from '../utils/localizeRelativeTime'; +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; updatedAt?: string | null; notes: MemoryNote[] } +interface MemoryIndex { viewerRole: 'owner' | 'admin'; totalEntries: number; sections: MemorySection[] } + +// 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' }, + { id: 'how', label: 'How I work' }, + { id: 'journal', label: 'Journal' }, +] as const; + +type GroupId = typeof MEMORY_GROUPS[number]['id']; +interface GroupedSection { key: string; label: string; updatedAt: string | null; 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, updatedAt: section.updatedAt ?? null, notes } : null + ); + return { rules: make(rules), how: make(how) }; +}; + +export const groupSections = (sections: MemorySection[]) => { + 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, updatedAt: soul.updatedAt ?? null, 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, updatedAt: s.updatedAt ?? null, notes: s.notes }); + } + return MEMORY_GROUPS.map((g) => ({ + id: g.id, + label: g.label, + sections: byGroup[g.id], + })); +}; + +// Frame icons (docs/design/artboards/d215, #s-memory): stroke-only, 1.8 weight. +const ICON_PATHS: Record<'pin' | 'brain' | 'cal' | 'search' | 'info' | 'chev', React.ReactNode> = { + chev: , + pin: , + brain: , + cal: <>, + search: <>, + info: <>, +}; +const MemIcon: React.FC<{ name: keyof typeof ICON_PATHS; size?: number; className?: string }> = ({ name, size = 16, className }) => ( + +); +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 }>(); + const [index, setIndex] = useState(null); + const [state, setState] = useState<'loading' | 'ready' | 'denied'>('loading'); + const [query, setQuery] = useState(''); + + 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 && (() => { + 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) => { + // One time per group: the newest section update in it. Never per note. + const updated = group.sections.map((s) => s.updatedAt).filter(Boolean).sort().pop() ?? null; + return ( +
+ + + + {group.label} + + {updated && ( + + {t('agentProfile.memory.sectionUpdated', { defaultValue: 'Section updated {{when}}', when: localizeRelativeTime(updated, t) })} + + )} + + +
    + {group.sections.flatMap((sec) => sec.notes).map((n, i) => ( + group.id === 'journal' ? ( +
  • + {n.header} + {n.snippet} +
  • + ) : ( +
  • + +
    +
    {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..51d0ad260f 100644 --- a/frontend/src/v2/agents/v2-agent-settings.css +++ b/frontend/src/v2/agents/v2-agent-settings.css @@ -126,3 +126,51 @@ .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): 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); gap: 24px; align-items: start; } +} +.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: 9px; height: 34px; padding: 0 10px; border-radius: var(--v2-r-8); 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: var(--v2-r-10); border: 1px solid var(--v2-border-soft); background: var(--v2-surface); box-shadow: var(--v2-shadow-pane); 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); list-style: none; } +.v2-agent-memory__gtitle::-webkit-details-marker { display: none; } +.v2-agent-memory__gheading { display: inline-flex; align-items: center; gap: 8px; } +.v2-agent-memory__gtitle .v2-agent-memory__icon { width: 13px; height: 13px; } +.v2-agent-memory__gmeta { margin-left: auto; font-weight: 400; font-size: 11px; } +/* Desktop: the groups are not collapsible, so the summary is a plain title. */ +.v2-agent-memory__chev { display: none; } +@media (min-width: 768px) { + .v2-agent-memory__gtitle { pointer-events: none; } +} +/* Phone: search above the groups, group list hidden, groups collapsible. */ +@media (max-width: 767px) { + .v2-agent-memory__nav ul { display: none; } + .v2-agent-memory__gtitle { cursor: pointer; pointer-events: auto; } + .v2-agent-memory__chev { display: inline-block; margin-left: 4px; transition: transform 0.15s; } + .v2-agent-memory__group[open] > .v2-agent-memory__gtitle .v2-agent-memory__chev { transform: rotate(180deg); } +} +.v2-agent-memory__list { list-style: none; margin: 0; padding: 0; border-radius: var(--v2-r-12); 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; }