Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ui/src/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ function _subInduce(
// Resolve the source graph for a bank+kind. Only `big` uses the large
// synthetic graph; every other bank reuses the existing primary fixtures.
function graphForBank(bank: string, kind: 'memories' | 'entities') {
if (bank === 'empty') return { nodes: [], edges: [] } // empty bank → empty graph
if (kind === 'entities') return buildMemEntityGraph()
// Single source of truth for bank→fact-graph, shared by the full-graph route
// and the subgraph route so ego/top-K slices stay consistent with the whole.
Expand Down Expand Up @@ -541,6 +542,9 @@ const MEM_BANKS = [
{ bank_id: 'hermes', name: 'hermes', mission: 'the bundled home agent', fact_count: 612 },
{ bank_id: 'scratch', name: 'scratch', mission: 'ephemeral working memory', fact_count: 88 },
{ bank_id: 'ingest', name: 'ingest', mission: 'raw document drop', fact_count: 4310 },
// a genuinely empty bank — exercises the graph empty-state shell (no facts,
// no entities) so the explorer's "No graph data" placeholder is testable.
{ bank_id: 'empty', name: 'empty', mission: 'no memories yet', fact_count: 0 },
]

function buildMemoryEngine() {
Expand Down
5 changes: 4 additions & 1 deletion ui/src/dash/memory-overhaul.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@
.mg-meta b { color: var(--fg-2); font-weight: 500; }
.mg-meta-dir { margin-left: 14px; padding-left: 14px; border-left: 1px solid var(--line); }

.mg-host { width: 100%; }
/* position:relative makes the host the containing block for the stage's
absolutely-positioned children, so any empty/loading placeholder stays
inside the graph area instead of escaping to the viewport. */
.mg-host { width: 100%; position: relative; }
.mg-wrap { position: relative; }

.mg-controls { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
Expand Down
8 changes: 7 additions & 1 deletion ui/src/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -2798,7 +2798,13 @@ select.pf-input { cursor: pointer; }
.mem-graph-svg { width: 100%; height: auto; display: block; }
.mem-gnode-g { cursor: pointer; }
.mem-gnode-lbl { font-size: 9px; fill: var(--fg-4); font-family: var(--jbm); pointer-events: none; }
.mem-graph-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
/* Empty/loading placeholder for the graph stage. Kept IN-FLOW (not an
absolute inset:0 overlay): the A/B/C overhaul moved this out of the old
position:relative .mem-graph-stage into the static .mg-host, so an absolute
overlay escaped to the viewport and swallowed every click (the whole UI
"locked up" on any empty bank/search/filter). A min-height gives the
message room without collapsing the host to 0. */
.mem-graph-empty { min-height: 360px; display: flex; align-items: center; justify-content: center; }
.mem-graph-detail { margin-top: 12px; padding: 12px 14px; }
.mem-graph-detail-body { font-size: 11px; color: var(--fg-2); display: flex; flex-direction: column; gap: 6px; }
.mem-graph-detail-label { color: var(--fg); font-size: 12px; }
Expand Down
71 changes: 71 additions & 0 deletions ui/tests/e2e/specs/memory-graph-empty-v3.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* memory-graph-empty-v3 — regression for the empty-bank "lock up".
*
* When a bank with no graph data was selected, the explorer rendered its
* `.mem-graph-empty` placeholder. That placeholder carried `position:absolute;
* inset:0`, but the A/B/C overhaul had moved it from the old position:relative
* `.mem-graph-stage` into the static `.mg-host` — so the absolute box escaped
* to the viewport (full-page, transparent) and intercepted every pointer event.
* The whole dashboard became unclickable: any empty bank/search/filter "locked
* up" the UI.
*
* These specs select the baked `empty` bank (fact_count 0, empty graph) and
* assert the placeholder is shown AND that the toolbar controls above it stay
* hit-testable — i.e. the empty box does not overlap the toolbar.
*/
import { test, expect } from '../fixtures/apiMock'

async function gotoEmptyGraph(page: any) {
await page.addInitScript(() => {
localStorage.setItem('hal0.mem.bank', 'empty')
localStorage.setItem('hal0.mem.dir', 'a')
})
await page.goto('/#memory/graph')
await page.waitForSelector('[data-testid="mem-graph-explorer"]', { timeout: 10_000 })
// ensure the empty bank is the active selection
await page.selectOption('[data-testid="mem-graph-bank"]', 'empty')
await expect(page.locator('[data-testid="mem-graph-bank"]')).toHaveValue('empty')
}

test.describe('Memory graph — empty bank', () => {
test('shows the empty placeholder for a bank with no graph data', async ({ page }) => {
await gotoEmptyGraph(page)
await expect(page.locator('.mem-graph-empty')).toContainText('No graph data')
await expect(page.locator('[data-testid="mem-graph-meta"]')).toContainText('0 nodes')
})

test('empty placeholder does not overlay the toolbar (controls stay clickable)', async ({ page }) => {
await gotoEmptyGraph(page)
await expect(page.locator('.mem-graph-empty')).toBeVisible()

// The placeholder must be contained below the toolbar, not stretched over
// the whole viewport. Assert no vertical overlap between the empty box and
// the toolbar, and that hit-testing the bank picker hits the picker itself.
const overlap = await page.evaluate(() => {
const empty = document.querySelector('.mem-graph-empty') as HTMLElement
const toolbar = document.querySelector('.mg-toolbar') as HTMLElement
const bank = document.querySelector('[data-testid="mem-graph-bank"]') as HTMLElement
const er = empty.getBoundingClientRect()
const tr = toolbar.getBoundingClientRect()
const br = bank.getBoundingClientRect()
const hit = document.elementFromPoint(br.x + br.width / 2, br.y + br.height / 2)
return {
coversToolbar: er.top <= tr.top, // bug: empty box starts at/above the toolbar
emptyTop: Math.round(er.top),
toolbarBottom: Math.round(tr.bottom),
bankHitsItself: hit === bank || bank.contains(hit as Node),
}
})
expect(overlap.coversToolbar).toBe(false)
expect(overlap.emptyTop).toBeGreaterThanOrEqual(overlap.toolbarBottom)
expect(overlap.bankHitsItself).toBe(true)

// And a real click on a toolbar control must succeed (was blocked by the
// overlay). Switching the direction is a representative interaction.
await page.locator('.mg-dirswitch button', { hasText: 'Structured' }).click()
await expect(page.locator('.mg-dirswitch button', { hasText: 'Structured' })).toHaveAttribute(
'aria-pressed',
'true',
)
})
})
6 changes: 3 additions & 3 deletions ui/tests/e2e/specs/memory-view-v3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,9 @@ test.describe('Memory view — Hindsight surface', () => {
await expect(card).toContainText('hindsight')
await expect(card).toContainText('0.7.2')
await expect(card.locator('.chip.ok')).toBeVisible()
// baked forced-mock has 5 banks (primary/big/hermes/scratch/ingest);
// `big` is the FU2 large synthetic bank that exercises the subgraph slice.
await expect(card).toContainText('5 banks')
// baked forced-mock has 6 banks (primary/big/hermes/scratch/ingest/empty);
// `big` exercises the subgraph slice, `empty` the graph empty-state shell.
await expect(card).toContainText('6 banks')
})

test('engine card surfaces a reachability chip without crashing', async ({ page }) => {
Expand Down