Skip to content
59 changes: 54 additions & 5 deletions frontend/src/v2/__tests__/V2AgentMemory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ describe('V2AgentMemory', () => {
);
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);
expect(document.querySelectorAll('.v2m-saved')).toHaveLength(2);
expect(document.querySelectorAll('.v2m-mnote .v2m-saved')).toHaveLength(0);
});

it('keeps the agent header, tabs and nav when the viewer cannot read entries, with the empty state inside', async () => {
Expand All @@ -123,8 +123,57 @@ describe('V2AgentMemory', () => {
);
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Profile' })).toBeInTheDocument();
expect(document.querySelector('.v2-agent-memory__nav')).not.toBeNull();
expect(document.querySelector('.v2-agent-memory__body [role="status"]')).not.toBeNull();
expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument();
expect(document.querySelector('.v2m-mnav')).not.toBeNull();
expect(document.querySelector('.v2m-body [role="status"]')).not.toBeNull();
});

it('Talk to opens the agent room and navigates to it', async () => {
axios.get.mockResolvedValue({ data: { viewerRole: 'owner', totalEntries: 1, sections: [section('daily', 1)] } });
axios.post.mockResolvedValue({ data: { room: { _id: 'room1' } } });
render(
<MemoryRouter initialEntries={['/v2/agents/maya-claude/default/memory']}>
<Routes>
<Route path="/v2/agents/:agentName/:instanceId/memory" element={<V2AgentMemory />} />
<Route path="/v2/pods/:id" element={<div>room opened</div>} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /maya-claude/ }));
await waitFor(() => expect(screen.getByText('room opened')).toBeInTheDocument());
expect(axios.post).toHaveBeenCalledWith('/api/agents/runtime/room', { agentName: 'maya-claude', instanceId: 'default' });
});

it('shows an error and stays on the page when the room cannot be opened', async () => {
axios.get.mockResolvedValue({ data: { viewerRole: 'owner', totalEntries: 1, sections: [section('daily', 1)] } });
axios.post.mockRejectedValue(new Error('500'));
render(
<MemoryRouter initialEntries={['/v2/agents/maya-claude/default/memory']}>
<Routes>
<Route path="/v2/agents/:agentName/:instanceId/memory" element={<V2AgentMemory />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /maya-claude/ }));
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
expect(screen.getByRole('button', { name: /maya-claude/ })).not.toBeDisabled();
});

it('marks one nav item current: the first with notes, then the one clicked', async () => {
axios.get.mockResolvedValue({ data: { viewerRole: 'owner', totalEntries: 3, sections: [named('long_term', ['Rules', 'Host notes']), section('daily', 1)] } });
render(
<MemoryRouter initialEntries={['/v2/agents/maya-claude/default/memory']}>
<Routes>
<Route path="/v2/agents/:agentName/:instanceId/memory" element={<V2AgentMemory />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByRole('heading', { name: /Journal/ })).toBeInTheDocument());
const current = () => Array.from(document.querySelectorAll('.v2m-mnav a[aria-current]')).map((a) => a.textContent);
expect(current()).toEqual(['Rules1']);
fireEvent.click(screen.getByRole('link', { name: /Journal/ }));
expect(current()).toEqual(['Journal1']);
});
});
35 changes: 21 additions & 14 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5099,28 +5099,35 @@ 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');
describe('agent Memory tab (TASK-732 lift of d215 #s-memory)', () => {
const css = read('../agents/V2AgentMemory.css');
const desktop = blockContaining(css, '@media (min-width: 768px)', '.v2m-grp > summary');
const phone = blockContaining(css, '@media (max-width: 767px)', '.v2m-mnav 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)');
expect(ruleBody(css, '.v2m-mem')).toContain('grid-template-columns: 200px minmax(0, 1fr)');
});
test('the page spans the pane instead of a centred column', () => {
const page = ruleBody(css, '.v2-agent-memory-page');
const page = ruleBody(css, '.v2m-page');
expect(page).toContain('width: 100%');
expect(page).toContain('max-width: none');
expect(page).toContain('align-self: stretch');
expect(page).not.toContain('max-width');
});
test('the header is the frame agent bar: 76px tall, 28px left padding', () => {
const ph = ruleBody(css, '.v2m-page .v2p-ph');
expect(ph).toContain('height: 76px');
expect(ph).toContain('padding-left: 28px');
});
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('phone hides the group nav and makes the groups collapsible rows', () => {
expect(ruleBody(css, '.v2m-mnav ul', { within: phone })).toContain('display: none');
expect(ruleBody(css, '.v2m-grp > summary', { within: phone })).toContain('cursor: pointer');
expect(ruleBody(css, '.v2m-gtitle', { within: phone })).toContain('height: 44px');
});
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');
expect(ruleBody(css, '.v2m-grp > summary', { within: desktop })).toContain('pointer-events: none');
expect(ruleBody(css, '.v2m-gtitle .v2m-chev')).toContain('display: none');
});
test('the old memory rules are gone from the agent settings sheet', () => {
expect(read('../agents/v2-agent-settings.css')).not.toContain('v2-agent-memory');
});
});

Expand Down
17 changes: 11 additions & 6 deletions frontend/src/v2/__tests__/v2-phone-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,21 @@ describe('D223 phone shell', () => {
test('agent settings and memory pages get the same 52px bar inside the 760px query', () => {
const agentCss = fs.readFileSync(path.join(__dirname, '../agents/v2-agent-settings.css'), 'utf8');
const tail = agentCss.slice(agentCss.indexOf('D223 (TASK-730)'));
expect(tail).toMatch(/\.v2-agent-settings > \.v2-page-head,\s*\.v2-agent-settings > \.v2-agent-memory__head\s*\{[^}]*min-height:\s*52px/);
expect(tail).toMatch(/\.v2-agent-settings > \.v2-page-head\s*\{[^}]*min-height:\s*52px/);
expect(agentCss).not.toContain('v2-agent-memory');
const at = agentCss.indexOf('D223 (TASK-730)');
expect(agentCss.slice(0, at).lastIndexOf('@media')).toBe(agentCss.lastIndexOf('@media (max-width: 760px)', at));
});

test('the memory bar lets the name shrink and drops the tab nav margin so it stays 52px', () => {
const agentCss = fs.readFileSync(path.join(__dirname, '../agents/v2-agent-settings.css'), 'utf8');
const tail = agentCss.slice(agentCss.indexOf('D223 (TASK-730)'));
expect(tail).toMatch(/__ident\s*\{\s*min-width:\s*0/);
expect(tail).toMatch(/__seg\s*\{[^}]*margin-top:\s*0/);
test('the memory bar is the same 52px phone bar, in the memory sheet on its v2m names', () => {
const memCss = fs.readFileSync(path.join(__dirname, '../agents/V2AgentMemory.css'), 'utf8');
const start = memCss.indexOf('@media (max-width: 767px)');
const phoneBlock = memCss.slice(start, memCss.indexOf('@media', start + 1));
expect(phoneBlock).not.toContain('min-width: 768px');
expect(phoneBlock).toMatch(/\.v2m-page \.v2p-ph\s*\{[^}]*min-height:\s*52px[^}]*flex-wrap:\s*nowrap/);
expect(phoneBlock).toMatch(/\.v2m-ahead\s*\{\s*min-width:\s*0/);
expect(phoneBlock).toMatch(/\.v2m-ahead h1\s*\{[^}]*white-space:\s*nowrap[^}]*text-overflow:\s*ellipsis/);
expect(phoneBlock).toMatch(/\.v2p-seg\s*\{[^}]*flex:\s*none/);
});

test('the settings head spans the pane and only the body scrolls', () => {
Expand Down
90 changes: 90 additions & 0 deletions frontend/src/v2/agents/V2AgentMemory.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/* Agent memory (TASK-732, D223 lift). Lifted from docs/design/artboards/d215
#s-memory: the artboard's .ahead, .mem, .mnav, .gtitle, .list, .mnote, .jday,
details.grp and .info blocks, with the frame's values and the --v2-* token
mapping from primitives.css. Class names keep the frame's names under a v2m-
prefix so they cannot collide with existing v2 rules. The header, segment,
list and group title come from the v2p- primitives. */

.v2m-page { width: 100%; min-width: 0; align-self: stretch; display: flex; flex-direction: column; box-sizing: border-box; }
.v2m-page .v2p-ph { height: 76px; padding-left: 28px; }
.v2m-ahead { display: block; min-width: 0; }
.v2m-ahead h1 { font: 800 24px/1 var(--v2-font-display); letter-spacing: -0.03em; margin: 0; white-space: nowrap; }
.v2m-ahead .st { display: flex; align-items: center; gap: 6px; font: 500 11.5px/1 var(--v2-font-mono); color: var(--v2-text-muted); margin: 5px 0 0; }
.v2m-pbody { flex: 1; min-height: 0; padding: 24px 28px; box-sizing: border-box; }

.v2m-mem { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 24px; align-items: start; }
.v2m-mnav { display: grid; gap: 2px; align-content: start; }
.v2m-mnav ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
.v2m-mnav a,
.v2m-mnav .v2m-navitem { display: flex; align-items: center; gap: 9px; height: 34px; padding: 0 10px; border-radius: var(--v2-r-8); font-size: 13.5px; color: var(--v2-ink-2); text-decoration: none; }
.v2m-mnav a.on { background: var(--v2-surface-tint); font-weight: 600; color: var(--v2-text-primary); }
.v2m-mnav a:hover { background: var(--v2-surface-tint); }
.v2m-mnav a:focus-visible { box-shadow: var(--v2-focus-ring); outline: none; }
.v2m-mnav svg.i { width: 16px; height: 16px; flex: none; color: var(--v2-text-secondary); }
.v2m-mnav .mo { margin-left: auto; font-size: 11px; color: var(--v2-text-muted); }
.v2m-search { display: flex; align-items: center; gap: 7px; height: 32px; margin-top: 12px; box-sizing: border-box; padding: 0 8px 0 10px; min-width: 0; border-radius: var(--v2-r-10); background: var(--v2-page-bg); box-shadow: inset 0 0 0 1px var(--v2-border-soft); color: var(--v2-text-placeholder); font-size: 13px; }
.v2m-search svg.i { width: 15px; height: 15px; flex: none; }
.v2m-search input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--v2-text-primary); font: inherit; }
.v2m-search:focus-within { box-shadow: var(--v2-focus-ring); }
.v2m-body { display: grid; min-width: 0; align-content: start; }

/* Group title: the frame's .gtitle, with the saved time and the info tip at the end. */
.v2m-grp { margin-bottom: 18px; }
.v2m-grp > summary { list-style: none; }
.v2m-grp > summary::-webkit-details-marker { display: none; }
.v2m-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-muted); }
.v2m-gheading { display: inline-flex; align-items: center; gap: 8px; }
.v2m-gtitle svg.i { width: 13px; height: 13px; flex: none; }
.v2m-gtitle .v2m-saved { margin-left: auto; font-weight: 400; }
.v2m-gtitle .v2m-chev { display: none; }

.v2m-info { all: unset; cursor: help; position: relative; display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; color: var(--v2-text-placeholder); vertical-align: middle; }
.v2m-info svg.i { width: 15px; height: 15px; }
.v2m-info:hover, .v2m-info:focus-visible { color: var(--v2-text-primary); }
.v2m-info .tip { display: none; position: absolute; z-index: 30; top: 24px; right: -8px; width: 260px; padding: 10px 12px; border-radius: var(--v2-r-10); background: var(--v2-surface); box-shadow: var(--v2-shadow-pop); font: 400 12.5px/1.45 var(--v2-font); color: var(--v2-text-secondary); text-align: left; white-space: normal; }
.v2m-info:hover .tip, .v2m-info:focus-visible .tip { display: block; }

/* Notes: the frame's .list rows. */
.v2m-list { list-style: none; margin: 0; padding: 0; overflow: hidden; }
.v2m-mnote { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 10px; align-items: start; padding: 11px 14px; border-top: 1px solid var(--v2-border-soft); }
.v2m-mnote:first-child { border-top: 0; }
.v2m-mnote > svg.i { width: 16px; height: 16px; margin-top: 2px; color: var(--v2-text-secondary); }
.v2m-mnote .tx { font-size: 13.5px; line-height: 20px; color: var(--v2-text-primary); overflow-wrap: anywhere; }
.v2m-mnote .snip { font-size: 12.5px; line-height: 18px; color: var(--v2-text-secondary); overflow-wrap: anywhere; }
.v2m-jday { display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 12px; padding: 10px 14px; border-top: 1px solid var(--v2-border-soft); font-size: 13.5px; color: var(--v2-ink-2); }
.v2m-jday:first-child { border-top: 0; }
.v2m-jday .mo { font: 500 11.5px/20px var(--v2-font-mono); color: var(--v2-text-muted); }

.v2m-state { margin: 0; padding: 14px; font-size: 13.5px; color: var(--v2-text-secondary); }

/* Phone (frame 390): search first, groups become the frame's details.grp rows. */
@media (max-width: 767px) {
/* D223 (TASK-730): the same 52px phone top bar feature pages get. The segment
stays in the bar, the name shrinks and ellipsizes. */
.v2m-page .v2p-ph { height: auto; min-height: 52px; flex-wrap: nowrap; gap: 8px; padding: 0 10px 0 12px; }
.v2m-page .v2p-ph .sp { display: none; }
.v2m-page .v2p-ph .v2m-ahead { min-width: 0; flex: 1; }
.v2m-page .v2p-ph .v2m-ahead h1 { margin: 0; white-space: nowrap; font: 800 21px/1 var(--v2-font-display); letter-spacing: -0.03em; overflow: hidden; text-overflow: ellipsis; }
.v2m-page .v2p-ph .v2m-ahead .st { display: none; }
.v2m-page .v2p-ph .v2p-seg { flex: none; margin-top: 0; }
.v2m-pbody { padding: 12px; }
.v2m-mem { grid-template-columns: minmax(0, 1fr); gap: 10px; }
.v2m-mnav ul { display: none; }
.v2m-search { margin-top: 0; }
.v2m-body { gap: 10px; }
.v2m-grp { margin-bottom: 0; background: var(--v2-surface); border-radius: var(--v2-r-14); box-shadow: var(--v2-shadow-pane); }
.v2m-grp > summary { cursor: pointer; }
.v2m-gtitle { height: 44px; margin: 0; padding: 0 12px 0 14px; font: 600 13.5px/1 var(--v2-font); color: var(--v2-text-primary); }
.v2m-gtitle svg.i { width: 16px; height: 16px; color: var(--v2-text-secondary); }
.v2m-gtitle .v2m-saved { font: 500 11px/1 var(--v2-font-mono); color: var(--v2-text-muted); }
.v2m-gtitle .v2m-info { display: none; }
.v2m-gtitle .v2m-chev { display: inline-block; margin-left: 4px; color: var(--v2-text-placeholder); transition: transform 160ms; }
.v2m-grp[open] > summary .v2m-chev { transform: rotate(180deg); }
.v2m-grp .v2m-list { border-radius: 0 0 var(--v2-r-12) var(--v2-r-12); box-shadow: none; background: transparent; }
}

/* Desktop: the groups are plain titles, not collapsible; only the info tip takes input. */
@media (min-width: 768px) {
.v2m-grp > summary { pointer-events: none; cursor: default; }
.v2m-grp > summary .v2m-info { pointer-events: auto; }
}
Loading
Loading