From 25de43a13bf229d959bc368defa40153d80bc112 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:35:12 -0700 Subject: [PATCH 1/3] feat(v2): agent Memory tab on the D215 frame, rules / how I work / journal (TASK-732) Carries the slice 1 and slice 2 memory work (#3085, #3131) onto main without the old-structure drafts. Adds the owner Memory route, the group split (Rules, How I work, Journal), the search, the section update line per group, the collapsible 390 layout, and the additive section updatedAt on the owner memory index. The frame restyle and the staging screenshot diff are not in this commit yet. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: lark Agent-Model: claude/claude-haiku-5-5/default --- .../service/agent-memory-editor.test.js | 27 +++ backend/routes/agentMemoryView.ts | 15 +- frontend/src/i18n/locales/en.json | 2 + frontend/src/i18n/locales/zh-CN.json | 2 + frontend/src/v2/V2App.tsx | 5 + .../src/v2/__tests__/V2AgentMemory.test.tsx | 127 +++++++++++ frontend/src/v2/agents/V2AgentMemory.tsx | 210 ++++++++++++++++++ frontend/src/v2/agents/V2AgentProfile.tsx | 1 + .../src/v2/agents/V2AgentSettingsPage.tsx | 1 + frontend/src/v2/agents/v2-agent-settings.css | 48 ++++ 10 files changed, 434 insertions(+), 4 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2AgentMemory.test.tsx create mode 100644 frontend/src/v2/agents/V2AgentMemory.tsx 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/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..4d2ce16462 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: 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); 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: 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; } From 502c2875927df106819e07a59baed5e6fb1371ba Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:12:25 -0700 Subject: [PATCH 2/3] test(v2): presence tests for the agent Memory layout (TASK-732) The memory CSS (200px desktop grid, phone nav hide, collapsible groups, desktop title pointer-events) was guarded by nothing. Adds presence tests to v2-layout-invariants per the CLAUDE.md layout rule. Addresses the CodeRabbit thread on v2-agent-settings.css. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: lark Agent-Model: claude/claude-haiku-5-5/default --- .../v2/__tests__/v2-layout-invariants.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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', () => { From 24db6f2af9ad12fa6c2ef7f08d7020a968131982 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:24:25 -0700 Subject: [PATCH 3/3] style(v2): memory tab radii and search elevation on Signal tokens (TASK-732) 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 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/agents/v2-agent-settings.css b/frontend/src/v2/agents/v2-agent-settings.css index 4d2ce16462..51d0ad260f 100644 --- a/frontend/src/v2/agents/v2-agent-settings.css +++ b/frontend/src/v2/agents/v2-agent-settings.css @@ -137,12 +137,12 @@ } .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: 8px; color: var(--v2-text-secondary); text-decoration: none; font-size: 13.5px; } +.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: 10px; border: 1px solid var(--v2-border-soft); background: var(--v2-surface); color: var(--v2-text-tertiary); font-size: 13px; } +.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); } @@ -165,7 +165,7 @@ .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: 12px; border: 1px solid var(--v2-border-soft); background: var(--v2-surface); overflow: hidden; } +.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; }