diff --git a/apps/console/src/App.test.tsx b/apps/console/src/App.test.tsx
index 5598568..c86b9b5 100644
--- a/apps/console/src/App.test.tsx
+++ b/apps/console/src/App.test.tsx
@@ -211,6 +211,37 @@ describe('Mac-first application shell', () => {
expect(document.activeElement).toBe(container.querySelector('.cc-toolbar-leading button'))
})
+ // Real-Chrome regression: SettingsView has its own Escape handler
+ // (focus-trap Tab cycling lives there) alongside the shell's, and the
+ // shell's opener button (`.cc-settings-cta`) is also one of
+ // SETTINGS_FOCUS_FALLBACKS — so opening/closing Settings from that one
+ // button always "worked" even while the restore ran twice, masking the
+ // bug. Opening from an unrelated element (as ⌘, from anywhere does)
+ // exposes it: the second, stale restore fell through to the fallback
+ // selectors and stole focus back onto the sidebar's Settings button
+ // instead of leaving it on the real opener.
+ it('restores focus to the real opener after Escape, even when it is not a settings fallback', async () => {
+ await act(async () => root.render(
+
+
+ ,
+ ))
+ await act(async () => { await Promise.resolve(); await Promise.resolve() })
+
+ await act(async () => button('Sources').click())
+ const opener = button('Browse files')
+ opener.focus()
+
+ await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', metaKey: true, bubbles: true })))
+ expect(container.querySelector('.cc-settings-screen')).toBeTruthy()
+
+ await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
+
+ expect(container.querySelector('.cc-settings-screen')).toBeNull()
+ expect(document.activeElement).toBe(opener)
+ })
+
it('does not open Settings over the Connect Agent dialog', async () => {
window.__CC_DESKTOP = {
getApiToken: async () => 'test',
diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx
index 1e3e4c2..673677b 100644
--- a/apps/console/src/App.tsx
+++ b/apps/console/src/App.tsx
@@ -17,8 +17,17 @@ import { ConnectAgentDialog } from './components/ConnectAgentDialog'
import { SettingsView } from './components/SettingsView'
import type { LiveErrorKind } from './api'
import { CommandPalette, type PaletteCommand } from './components/CommandPalette'
+import { useOpenerFocus } from './components/useOpenerFocus'
import { readBrowserGroupedViews, SEARCHABLE_VIEWS, viewForDestination } from './shell-navigation'
+// Stable across renders (useOpenerFocus's `restore` keys off this array's
+// identity) — must live outside the component, not be re-literaled inline.
+const SETTINGS_FOCUS_FALLBACKS = ['.cc-settings-cta', '.cc-toolbar-leading button']
+// The wizard can auto-open with no trigger at all (first run) or be reopened
+// from a button that only exists in one view (Sources' "Add Source"); the
+// sidebar toggle is the one control guaranteed to be on screen in every view.
+const WIZARD_FOCUS_FALLBACKS = ['.cc-toolbar-leading button']
+
const ERROR_COPY: Record string> = {
unreachable: () => "Can't reach the ContextCake server. Start it with `npm run console:live`, or view the demo.",
'bad-status': (msg) => msg,
@@ -83,7 +92,8 @@ export function App() {
const [drawerOpen, setDrawerOpen] = useState(false)
const [paletteOpen, setPaletteOpen] = useState(false)
const [backgroundAnnouncement, setBackgroundAnnouncement] = useState('')
- const settingsOpener = useRef(null)
+ const settingsFocus = useOpenerFocus(SETTINGS_FOCUS_FALLBACKS)
+ const wizardFocus = useOpenerFocus(WIZARD_FOCUS_FALLBACKS)
const paletteOpener = useRef(null)
const askOpener = useRef(null)
const drawerOpener = useRef(null)
@@ -102,22 +112,23 @@ export function App() {
const isDesktop = typeof window !== 'undefined' && Boolean(window.__CC_DESKTOP)
useEffect(() => {
- if (needsSetup && wizardOpen === undefined) setWizardOpen(true)
- }, [needsSetup, wizardOpen])
+ if (needsSetup && wizardOpen === undefined) { wizardFocus.capture(); setWizardOpen(true) }
+ }, [needsSetup, wizardOpen, wizardFocus])
const showWizard = wizardOpen === true
- const closeWizard = () => setWizardOpen(false)
+ const closeWizard = () => { setWizardOpen(false); wizardFocus.restore() }
// Handlers that reach a memoized child (Sidebar, Header, Sources, ChatPanel)
// are stable identities. A fresh arrow function per render would re-render
// the child through its memo and give the whole split back.
- const reopenWizard = useCallback(() => setWizardOpen(true), [])
+ const reopenWizard = useCallback(() => { wizardFocus.capture(); setWizardOpen(true) }, [wizardFocus])
const openConnect = useCallback(() => {
if (sources.length === 0 && !sourceSetupComplete) {
+ wizardFocus.capture()
setWizardOpen(true)
return
}
setConnectOpen(true)
- }, [sources.length, sourceSetupComplete])
+ }, [sources.length, sourceSetupComplete, wizardFocus])
const openSettings = useCallback(() => {
if (window.__CC_DESKTOP?.windows) {
setDrawerOpen(false)
@@ -126,10 +137,10 @@ export function App() {
window.__CC_DESKTOP.windows.openSettings().catch(() => {})
return
}
- settingsOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
+ settingsFocus.capture()
setDrawerOpen(false)
setSettingsOpen(true)
- }, [])
+ }, [settingsFocus])
const openPalette = () => {
paletteOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setDrawerOpen(false)
@@ -198,27 +209,7 @@ export function App() {
{ id: 'settings', label: 'Open Settings', shortcut: '⌘,', run: openSettings },
{ id: 'sidebar', label: 'Toggle Sidebar', run: toggleSidebar },
], [isDesktop, mode, openAskFromPalette, openConnect, openFilesScope, openSettings, reopenWizard, setView, sources, toggleSidebar])
- const closeSettings = () => {
- const opener = settingsOpener.current
- setSettingsOpen(false)
- window.requestAnimationFrame(() => {
- const candidates = [
- opener,
- document.querySelector('.cc-settings-cta'),
- document.querySelector('.cc-toolbar-leading button'),
- ]
- candidates.find((candidate) => {
- if (!candidate?.isConnected) return false
- if (!candidate.matches('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])')) return false
- const rect = candidate.getBoundingClientRect()
- const hasNoLayout = rect.width === 0 && rect.height === 0
- const visible = hasNoLayout || (rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.bottom > 0 && rect.left < window.innerWidth && rect.top < window.innerHeight)
- if (visible) candidate.focus()
- return visible
- })
- settingsOpener.current = null
- })
- }
+ const closeSettings = () => { setSettingsOpen(false); settingsFocus.restore() }
// Announce transitions, not ticks. A live region that re-read a progress
// counter every 900ms would make the app unusable with a screen reader; the
@@ -442,7 +433,7 @@ export function App() {
return (
<>
-
{body}
+
{body}
{settingsOpen && }
{paletteOpen && }
{showWizard && 0} onClose={closeWizard} onConnectAgent={isDesktop ? () => {
diff --git a/apps/console/src/App.wizard.test.tsx b/apps/console/src/App.wizard.test.tsx
new file mode 100644
index 0000000..6530321
--- /dev/null
+++ b/apps/console/src/App.wizard.test.tsx
@@ -0,0 +1,124 @@
+// @vitest-environment jsdom
+// The setup wizard's dialog contract (F25, F26): closing it returns focus to
+// whatever opened it, and the app shell behind it is inert while it is open —
+// the same contract Settings already had. Live-shaped data source so the
+// wizard opens in "add a source" mode (one source already present) rather
+// than the first-run narrative.
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { App } from './App'
+import { StoreProvider } from './store'
+import { ThemeModeProvider } from './theme-mode'
+
+const mocks = vi.hoisted(() => ({
+ graph: vi.fn(), resolveAll: vi.fn(), status: vi.fn(), conflictResolutions: vi.fn(),
+}))
+
+vi.mock('./api', async () => {
+ const actual = await vi.importActual('./api')
+ return {
+ ...actual,
+ createDataSource: () => ({
+ mode: 'live' as const,
+ graph: mocks.graph,
+ resolveAll: mocks.resolveAll,
+ resolve: vi.fn(),
+ listConcepts: vi.fn(),
+ status: mocks.status,
+ conflictResolutions: mocks.conflictResolutions,
+ resolveConflict: vi.fn(),
+ }),
+ }
+})
+
+let container: HTMLDivElement
+let root: Root
+
+function readyGraph() {
+ return {
+ totals: { sourceTokens: 0, resolvedTokens: 0, concepts: 0, sources: 1 },
+ indexing: false,
+ indexingSources: [],
+ generation: 1,
+ sources: [{
+ name: 'personal', level: 3, kind: 'files', conceptCount: 0, tokens: 0, latestUpdated: null,
+ status: 'ok', error: null,
+ }],
+ concepts: [],
+ }
+}
+
+function button(label: string): HTMLButtonElement {
+ const match = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.trim() === label)
+ if (!match) throw new Error(`Button not found: ${label}`)
+ return match
+}
+
+beforeEach(() => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ window.history.replaceState(null, '', '/#/sources')
+ window.localStorage.clear()
+ delete window.__CC_DESKTOP
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0))
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ for (const mock of Object.values(mocks)) mock.mockReset()
+ mocks.graph.mockResolvedValue(readyGraph())
+ mocks.resolveAll.mockResolvedValue({ concepts: [], errors: [], indexing: false })
+ mocks.conflictResolutions.mockResolvedValue([])
+ mocks.status.mockResolvedValue(null)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ vi.unstubAllGlobals()
+})
+
+describe('the setup wizard as a dialog', () => {
+ it('inerts the app shell while open, and lifts it once closed', async () => {
+ await act(async () => root.render())
+ await act(async () => { await Promise.resolve(); await Promise.resolve() })
+
+ const opener = button('Add Source')
+ opener.focus()
+ await act(async () => opener.click())
+
+ expect(container.querySelector('[aria-label="ContextCake setup"]')).toBeTruthy()
+ expect(container.querySelector('.cc-app-layer')?.hasAttribute('inert')).toBe(true)
+
+ await act(async () => button('Cancel').click())
+ expect(container.querySelector('[aria-label="ContextCake setup"]')).toBeNull()
+ expect(container.querySelector('.cc-app-layer')?.hasAttribute('inert')).toBe(false)
+ })
+
+ it('restores focus to the button that opened it', async () => {
+ await act(async () => root.render())
+ await act(async () => { await Promise.resolve(); await Promise.resolve() })
+
+ const opener = button('Add Source')
+ opener.focus()
+ await act(async () => opener.click())
+ expect(container.querySelector('[aria-label="ContextCake setup"]')).toBeTruthy()
+
+ await act(async () => button('Cancel').click())
+ await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 0)) })
+ expect(document.activeElement).toBe(opener)
+ })
+
+ it('closes on Escape and still restores focus to the opener', async () => {
+ await act(async () => root.render())
+ await act(async () => { await Promise.resolve(); await Promise.resolve() })
+
+ const opener = button('Add Source')
+ opener.focus()
+ await act(async () => opener.click())
+
+ await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
+ await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 0)) })
+ expect(container.querySelector('[aria-label="ContextCake setup"]')).toBeNull()
+ expect(document.activeElement).toBe(opener)
+ })
+})
diff --git a/apps/console/src/api.test.ts b/apps/console/src/api.test.ts
index bb24363..eb65d5d 100644
--- a/apps/console/src/api.test.ts
+++ b/apps/console/src/api.test.ts
@@ -1,10 +1,18 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
- adaptConcept, adaptConflicts, adaptSources, apiFetch, LiveDataError, mergeSourceStatus, selectMode,
+ adaptConcept, adaptConflicts, adaptDiscrepancies, adaptSources, apiFetch, computeLevelBuckets, LiveDataError, mergeSourceStatus, selectMode,
trivialConflictReason,
} from './api'
-import type { GraphSummary, ResolvedConcept } from './types'
+import type { DiscrepancyRecord, GraphSummary, ResolvedConcept } from './types'
+
+// The rank-based level→lane mapping (see computeLevelBuckets in api.ts) needs
+// the full set of levels present across a resolve pass, not just the levels a
+// single test concept happens to carry. Every fixture below uses the
+// canonical trio of levels, so this single buckets value reproduces the old
+// fixed-threshold mapping (0 → company, 2 → team, 3 → personal) exactly —
+// tests that specifically exercise the rank behavior build their own.
+const STANDARD_BUCKETS = computeLevelBuckets([0, 2, 3])
// ---- selectMode -------------------------------------------------------
@@ -106,6 +114,55 @@ describe('LiveSource error taxonomy', () => {
})
})
+describe('LiveSource.search', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn())
+ })
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns hits from /api/search on the happy path', async () => {
+ const { createDataSource } = await import('./api')
+ const hits = [{ id: 'decisions/primary-db', title: 'Primary database', score: 4.2, layers: ['team'], snippet: '...SingleStore for HTAP...' }]
+ vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ hits }), { status: 200 }))
+ const source = createDataSource('live')
+
+ await expect(source.search('singlestore')).resolves.toEqual(hits)
+ const [calledUrl] = vi.mocked(fetch).mock.calls[0]
+ expect(String(calledUrl)).toBe('/api/search?q=singlestore&limit=20')
+ })
+
+ it('encodes the query and honors a custom limit', async () => {
+ const { createDataSource } = await import('./api')
+ vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ hits: [] }), { status: 200 }))
+ const source = createDataSource('live')
+
+ await source.search('primary db?', 5)
+ const [calledUrl] = vi.mocked(fetch).mock.calls[0]
+ expect(String(calledUrl)).toBe(`/api/search?q=${encodeURIComponent('primary db?')}&limit=5`)
+ })
+
+ // The existing older-engine-fallback idiom (see status() above): a 404
+ // means this engine predates /api/search, and the signal is `null`, not a
+ // thrown error — the caller (store.search) decides what to do with that.
+ it('resolves to null on 404 — an engine older than this console', async () => {
+ const { createDataSource } = await import('./api')
+ vi.mocked(fetch).mockResolvedValue({ ok: false, status: 404, json: async () => ({}) } as Response)
+ const source = createDataSource('live')
+
+ await expect(source.search('anything')).resolves.toBeNull()
+ })
+
+ it('rethrows a non-404 failure — only the 404 case is a silent fallback signal here', async () => {
+ const { createDataSource } = await import('./api')
+ vi.mocked(fetch).mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as Response)
+ const source = createDataSource('live')
+
+ await expect(source.search('anything')).rejects.toMatchObject({ kind: 'bad-status', status: 500 })
+ })
+})
+
describe('desktop API credential transport', () => {
afterEach(() => {
delete window.__CC_DESKTOP
@@ -196,6 +253,31 @@ describe('desktop API credential transport', () => {
// ---- Adapters: raw engine types -> console view model -------------------
+describe('computeLevelBuckets', () => {
+ it('ranks the highest level present as personal, the next as team, the rest as company', () => {
+ const buckets = computeLevelBuckets([0, 1, 2, 3])
+ expect(buckets.get(3)).toBe('personal')
+ expect(buckets.get(2)).toBe('team')
+ expect(buckets.get(1)).toBe('company')
+ expect(buckets.get(0)).toBe('company')
+ })
+
+ it('puts the sole level present in the top lane rather than folding it into company', () => {
+ expect(computeLevelBuckets([1]).get(1)).toBe('personal')
+ })
+
+ it('promotes a second-place level to team even when it is not 2', () => {
+ expect(computeLevelBuckets([3, 1]).get(1)).toBe('team')
+ expect(computeLevelBuckets([5, 4]).get(4)).toBe('team')
+ })
+
+ it('ignores duplicate levels when ranking', () => {
+ const buckets = computeLevelBuckets([2, 2, 0, 0])
+ expect(buckets.get(2)).toBe('personal')
+ expect(buckets.get(0)).toBe('team')
+ })
+})
+
describe('adaptConcept', () => {
const sample: ResolvedConcept = {
id: 'decisions/primary-db',
@@ -219,30 +301,37 @@ describe('adaptConcept', () => {
}
it('maps id, title, and type from frontmatter', () => {
- const c = adaptConcept(sample)
+ const c = adaptConcept(sample, STANDARD_BUCKETS)
expect(c.id).toBe('decisions/primary-db')
expect(c.title).toBe('Primary database')
expect(c.type).toBe('decision')
})
it('orders contributing layers by precedence (personal, team, company)', () => {
- const c = adaptConcept(sample)
+ const c = adaptConcept(sample, STANDARD_BUCKETS)
expect(c.layers).toEqual(['team', 'company'])
})
+ it('carries the real contributor source names, winner first — a zero-section concept has no section to read one from', () => {
+ const c = adaptConcept(sample, STANDARD_BUCKETS)
+ expect(c.contributorLayers).toEqual(['team', 'company'])
+ const empty: ResolvedConcept = { ...sample, sections: [] }
+ expect(adaptConcept(empty, STANDARD_BUCKETS).contributorLayers).toEqual(['team', 'company'])
+ })
+
it('marks conflict true when any section has dissents', () => {
- const c = adaptConcept(sample)
+ const c = adaptConcept(sample, STANDARD_BUCKETS)
expect(c.conflict).toBe(true)
})
it('marks draft only from OKF frontmatter (write.mjs stamps auto-captures)', () => {
const stamped: ResolvedConcept = { ...sample, frontmatter: { ...sample.frontmatter, draft: true } }
- expect(adaptConcept(stamped).draft).toBe(true)
- expect(adaptConcept(sample).draft).toBe(false)
+ expect(adaptConcept(stamped, STANDARD_BUCKETS).draft).toBe(true)
+ expect(adaptConcept(sample, STANDARD_BUCKETS).draft).toBe(false)
// A concept owned by a single layer is NOT draft — finished knowledge
// commonly lives in exactly one layer.
const solo: ResolvedConcept = { ...sample, contributors: [sample.contributors[0]], sections: [] }
- expect(adaptConcept(solo).draft).toBe(false)
+ expect(adaptConcept(solo, STANDARD_BUCKETS).draft).toBe(false)
})
it('maps non-canonical layer names via contributor levels, not the name', () => {
@@ -266,16 +355,16 @@ describe('adaptConcept', () => {
},
],
}
- const c = adaptConcept(custom)
+ const c = adaptConcept(custom, STANDARD_BUCKETS)
expect(c.sections[0].winner).toBe('team')
expect(c.layers).toEqual(['team', 'company'])
- const cards = adaptConflicts([custom])
+ const cards = adaptConflicts([custom], [], STANDARD_BUCKETS)
expect(cards[0].winner).toBe('team')
expect(cards[0].contributions[0].layer).toBe('team')
})
it('maps section winner, value, and provenance date', () => {
- const c = adaptConcept(sample)
+ const c = adaptConcept(sample, STANDARD_BUCKETS)
const s = c.sections[0]
expect(s.name).toBe('Choice')
expect(s.winner).toBe('team')
@@ -284,7 +373,7 @@ describe('adaptConcept', () => {
})
it('surfaces dissenting layers on the section, not hidden', () => {
- const c = adaptConcept(sample)
+ const c = adaptConcept(sample, STANDARD_BUCKETS)
const s = c.sections[0]
expect(s.dissents).toHaveLength(1)
expect(s.dissents?.[0]).toMatchObject({ layer: 'company', value: 'Postgres (org standard).', updated: '2025-06-01' })
@@ -295,7 +384,7 @@ describe('adaptConcept', () => {
...sample,
sections: [{ ...sample.sections[0], suppressed: true, conflicts: undefined }],
}
- const c = adaptConcept(suppressed)
+ const c = adaptConcept(suppressed, STANDARD_BUCKETS)
expect(c.sections[0].suppressed).toBe(true)
expect(c.sections[0].dissents).toEqual([])
})
@@ -394,7 +483,7 @@ describe('adaptConcept with headingless documents', () => {
}
it('names a headingless section by its key instead of throwing', () => {
- const concept = adaptConcept(headless)
+ const concept = adaptConcept(headless, STANDARD_BUCKETS)
expect(concept.sections[0].name).toBe('body')
})
@@ -404,7 +493,7 @@ describe('adaptConcept with headingless documents', () => {
contributors: [...headless.contributors, { layer: 'team', level: 2, updated: '2026-02-10' }],
sections: [{ ...headless.sections[0], conflicts: [{ layer: 'team', updated: '2026-02-10', content: 'Talked to Priya on Tuesday.' }] }],
}
- const [conflict] = adaptConflicts([contested])
+ const [conflict] = adaptConflicts([contested], [], STANDARD_BUCKETS)
expect(conflict.section).toBe('body')
expect(conflict.title).toBe('body — 2026-02-11')
})
@@ -539,6 +628,26 @@ describe('adaptSources', () => {
expect(adaptSources(graph)[0].layer).toBe('personal')
})
+ // The fixed-threshold mapping this replaced sent any level < 2 straight to
+ // 'company' — so a level-1 source sat in Company next to level 0, and the
+ // Team lane, with nothing at level 2, sat empty. Ranked among the levels
+ // that actually exist (3 and 1 here), level 1 is the *second* highest and
+ // now lands in 'team'.
+ it('ranks a level-1 source into team, not company, when a higher level exists', () => {
+ const graph: GraphSummary = {
+ totals: { sourceTokens: 10, resolvedTokens: 10, concepts: 2, sources: 2 },
+ sources: [
+ { name: 'personal', level: 3, kind: 'okf-local', conceptCount: 1, tokens: 10, latestUpdated: null, status: 'ok', error: null },
+ { name: 'messy-vault', level: 1, kind: 'files', conceptCount: 1, tokens: 10, latestUpdated: null, status: 'ok', error: null },
+ ],
+ concepts: [],
+ }
+ const [, vault] = adaptSources(graph)
+ expect(vault.name).toBe('messy-vault')
+ expect(vault.layer).toBe('team')
+ expect(vault.layer).not.toBe('company')
+ })
+
it('never paints a zero-concept MCP source as serving (the false green)', () => {
// A dead MCP child answers [] instead of throwing, so its row arrives
// status 'ok' with nothing served — that must not read as healthy.
@@ -622,7 +731,7 @@ describe('adaptConflicts', () => {
],
},
]
- const out = adaptConflicts(concepts)
+ const out = adaptConflicts(concepts, [], STANDARD_BUCKETS)
expect(out).toHaveLength(1)
expect(out[0]).toMatchObject({
id: 'decisions/primary-db::choice',
@@ -644,7 +753,7 @@ describe('adaptConflicts', () => {
sections: [{ key: 'steps', heading: '## Steps {#steps}', content: 'Deploy.', sourceLayer: 'team', sourceUpdated: null }],
},
]
- expect(adaptConflicts(concepts)).toEqual([])
+ expect(adaptConflicts(concepts, [], STANDARD_BUCKETS)).toEqual([])
})
it('classifies formatting-only prose but never guesses when words or code change', () => {
@@ -671,11 +780,66 @@ describe('adaptConflicts', () => {
reason: 'You chose the acme-eng answer.',
actor: 'local-user',
decidedAt: '2026-08-05T00:00:00.000Z',
- }])
+ }], STANDARD_BUCKETS)
expect(resolved.status).toBe('resolved')
expect(resolved.winner).toBe('team')
expect(resolved.contributions.map((item) => item.layer)).toEqual(['team', 'company'])
+ // F13 prerequisite: a resolved card carries the winning source directly,
+ // so the Conflicts source filter can match it even when the contributions
+ // snapshot it carries doesn't happen to include that source by name.
+ expect(resolved.effectiveSource).toBe('acme-eng')
+ })
+})
+
+describe('adaptDiscrepancies', () => {
+ function frontmatterRecord(overrides: Partial = {}): DiscrepancyRecord {
+ return {
+ id: 'frontmatter_value::decisions/primary-db::tags',
+ kind: 'frontmatter_value',
+ originalKind: 'frontmatter_value',
+ conceptId: 'decisions/primary-db',
+ conceptTitle: 'Primary database',
+ conceptType: 'concept',
+ key: 'tags',
+ label: 'tags',
+ revision: 'rev-1',
+ status: 'needs_review',
+ contributions: [
+ { source: 'team', level: 2, updated: '2026-01-01', value: 'oltp', fingerprint: 'fp1', effective: true },
+ { source: 'company', level: 0, updated: '2025-01-01', value: 'oltp', fingerprint: 'fp2', effective: false },
+ ],
+ effectiveSource: 'team',
+ effectiveValue: 'oltp',
+ winnerReason: 'team wins by configured layer precedence.',
+ owner: 'Unassigned',
+ priority: 'unassigned',
+ fresherDissent: false,
+ freshness: { effectiveUpdated: '2026-01-01', newestUpdated: '2026-01-01', hasNewerDissent: false },
+ affectedLinks: [],
+ sourceHealth: [],
+ history: [],
+ matchingRules: [],
+ ...overrides,
+ }
+ }
+
+ it('flags a discrepancy isList when any raw contribution value is an array — the engine 400s compose against it', () => {
+ const record = frontmatterRecord({
+ contributions: [
+ { source: 'team', level: 2, updated: '2026-01-01', value: ['postgres', 'oltp'], fingerprint: 'fp1', effective: true },
+ { source: 'company', level: 0, updated: '2025-01-01', value: ['mysql'], fingerprint: 'fp2', effective: false },
+ ],
+ })
+ const [card] = adaptDiscrepancies([record], true, STANDARD_BUCKETS)
+ expect(card.isList).toBe(true)
+ // The display value is still the honest stringified form, never the raw array.
+ expect(card.contributions[0].value).toBe(JSON.stringify(['postgres', 'oltp'], null, 2))
+ })
+
+ it('never flags isList for an ordinary string-valued frontmatter field', () => {
+ const [card] = adaptDiscrepancies([frontmatterRecord()], true, STANDARD_BUCKETS)
+ expect(card.isList).toBeUndefined()
})
})
@@ -700,9 +864,9 @@ describe('fresherDissent (C-b)', () => {
}
it('carries the section flag through adaptConcept onto the view section', () => {
- const c = adaptConcept(conflicted({ fresherDissent: true }))
+ const c = adaptConcept(conflicted({ fresherDissent: true }), STANDARD_BUCKETS)
expect(c.sections[0].fresherDissent).toBe(true)
- expect(adaptConcept(conflicted({})).sections[0].fresherDissent).toBeUndefined()
+ expect(adaptConcept(conflicted({}), STANDARD_BUCKETS).sections[0].fresherDissent).toBeUndefined()
})
it('marks exactly the strictly-newer dissent contribution on the conflict card', () => {
@@ -714,7 +878,7 @@ describe('fresherDissent (C-b)', () => {
],
})
concept.contributors.push({ layer: 'company', level: 0, updated: '2025-01-01' })
- const [card] = adaptConflicts([concept])
+ const [card] = adaptConflicts([concept], [], STANDARD_BUCKETS)
expect(card.contributions[0].fresherDissent).toBeUndefined() // the winner is never its own dissent
expect(card.contributions[1]).toMatchObject({ layer: 'team', fresherDissent: true })
expect(card.contributions[2].fresherDissent).toBeUndefined() // older dissent stays unmarked
@@ -723,7 +887,7 @@ describe('fresherDissent (C-b)', () => {
it('never marks a dissent when the engine did not flag the section', () => {
// The engine owns the rule (it also knows about suppression and
// formatting-equivalence); the console must not out-guess it.
- const [card] = adaptConflicts([conflicted({})])
+ const [card] = adaptConflicts([conflicted({})], [], STANDARD_BUCKETS)
expect(card.contributions.every((k) => k.fresherDissent === undefined)).toBe(true)
})
@@ -737,7 +901,7 @@ describe('fresherDissent (C-b)', () => {
],
})
concept.contributors.push({ layer: 'company', level: 0, updated: '2026-06-01' })
- const [card] = adaptConflicts([concept])
+ const [card] = adaptConflicts([concept], [], STANDARD_BUCKETS)
expect(card.contributions[1].fresherDissent).toBeUndefined()
expect(card.contributions[2].fresherDissent).toBe(true)
})
@@ -748,13 +912,13 @@ describe('fresherDissent (C-b)', () => {
sourceUpdated: null,
conflicts: [{ layer: 'team', updated: '2026-06-01', content: 'Dated dissent.' }],
})
- const [card] = adaptConflicts([concept])
+ const [card] = adaptConflicts([concept], [], STANDARD_BUCKETS)
expect(card.contributions[1].fresherDissent).toBeUndefined()
const garbled = conflicted({
fresherDissent: true,
conflicts: [{ layer: 'team', updated: 'not-a-date', content: 'Undated dissent.' }],
})
- expect(adaptConflicts([garbled])[0].contributions[1].fresherDissent).toBeUndefined()
+ expect(adaptConflicts([garbled], [], STANDARD_BUCKETS)[0].contributions[1].fresherDissent).toBeUndefined()
})
})
diff --git a/apps/console/src/api.ts b/apps/console/src/api.ts
index 51bfc2c..c817eb0 100644
--- a/apps/console/src/api.ts
+++ b/apps/console/src/api.ts
@@ -15,7 +15,7 @@ import demoBundleRaw from './generated/demo-cascade.json'
import type {
ConflictResolutionRecord, DemoBundle, DiscrepanciesResponse, DiscrepancyDecisionRequest, DiscrepancyRecord,
DiscrepancyRule, DiscrepancyRuleSuggestion, GraphSummary, GraphSource, ResolveConflictRequest,
- ResolvedConcept, ResolvedSection, SourceStatus, StatusSummary,
+ ResolvedConcept, ResolvedSection, SearchHit, SourceStatus, StatusSummary,
} from './types'
import type { Concept, ConceptSection, Conflict, Dissent, Source } from './data'
import type { LayerId } from './theme'
@@ -59,6 +59,14 @@ export interface DataSource {
* to reading progress off the graph.
*/
status(): Promise
+ /**
+ * Full-text search over section content (GET /api/search), for the
+ * Knowledge search box. `null` means the same thing it means for `status()`
+ * above: an engine too old to have the route. Demo mode never calls this —
+ * it has no engine behind it — so `DemoSource` answers `null` unconditionally
+ * rather than reading its own bundle.
+ */
+ search(query: string, limit?: number): Promise
conflictResolutions(): Promise
resolveConflict(request: ResolveConflictRequest): Promise
discrepancies(): Promise
@@ -195,9 +203,12 @@ class DemoSource implements DataSource {
})),
}
}
+ /** Demo mode is pure client-side substring filtering — no engine to search. */
+ async search(): Promise { return null }
async conflictResolutions(): Promise { return this.resolutions }
async discrepancies(): Promise {
- const conflicts = adaptConflicts(this.bundle.concepts, this.resolutions)
+ const buckets = computeLevelBuckets(this.bundle.graph.sources.map((s) => s.level))
+ const conflicts = adaptConflicts(this.bundle.concepts, this.resolutions, buckets)
return {
discrepancies: conflicts.map((conflict) => legacyConflictRecord(conflict)),
coverageComplete: true, indexing: false, indexingSources: [], errors: [], generation: 1,
@@ -327,6 +338,19 @@ class LiveSource implements DataSource {
throw error
}
}
+ async search(query: string, limit = 20): Promise {
+ try {
+ return (await this.get<{ hits: SearchHit[] }>(`/api/search?q=${encodeURIComponent(query)}&limit=${limit}`)).hits
+ } catch (error) {
+ // Same older-engine idiom as status() above: a 404 means this engine has
+ // no /api/search route, and the caller falls back to the substring
+ // filter. Any other failure (network, timeout, malformed body) is the
+ // caller's problem too — it wraps this call and treats every rejection
+ // the same way, so nothing here needs to distinguish them.
+ if (error instanceof LiveDataError && error.kind === 'bad-status' && error.status === 404) return null
+ throw error
+ }
+ }
async conflictResolutions(): Promise {
try {
return (await this.get<{ resolutions: ConflictResolutionRecord[] }>('/api/conflict-resolutions')).resolutions
@@ -420,12 +444,34 @@ export function createDataSource(mode: Mode = selectMode()): DataSource {
const LAYER_IDS: LayerId[] = ['company', 'team', 'personal']
const isLayerId = (s: string): s is LayerId => (LAYER_IDS as string[]).includes(s)
-/** Map a source/layer name (falling back to level) to a console LayerId. */
-function layerOf(name: string, level: number): LayerId {
+/**
+ * Rank-based bucket assignment for one resolve pass. `LayerId` stays a
+ * closed, three-value union — styling in ~8 files depends on it — so an
+ * arbitrary manifest level still needs an honest lane without widening that
+ * type. The highest level actually present becomes 'personal', the next
+ * 'team', and everything else 'company'. That fixes the fixed-threshold bug
+ * where a lone level-1 source (nothing above it) read as 'company' with the
+ * Team lane sitting empty: ranked among the levels that actually exist, level
+ * 1 is the *second* highest and lands in 'team'.
+ *
+ * Must be computed once per resolve pass from every source in play (not per
+ * concept or per record) and threaded through every adapter below — computing
+ * it from a narrower slice would bucket the same source differently
+ * depending on what happened to touch it.
+ */
+export type LevelBuckets = ReadonlyMap
+
+export function computeLevelBuckets(levels: Iterable): LevelBuckets {
+ const distinct = [...new Set(levels)].sort((a, b) => b - a)
+ const buckets = new Map()
+ distinct.forEach((level, rank) => buckets.set(level, rank === 0 ? 'personal' : rank === 1 ? 'team' : 'company'))
+ return buckets
+}
+
+/** Map a source/layer name (falling back to its rank bucket) to a console LayerId. */
+function layerOf(name: string, level: number, buckets: LevelBuckets): LayerId {
if (isLayerId(name)) return name
- if (level >= 3) return 'personal'
- if (level === 2) return 'team'
- return 'company'
+ return buckets.get(level) ?? 'company'
}
/**
@@ -478,10 +524,10 @@ function newerAtDayGranularity(dissentUpdated: string | null, winnerUpdated: str
}
/** A resolved section → the console's ConceptSection (with provenance + dissent). */
-function adaptSection(s: ResolvedSection, levels: Map): ConceptSection {
- const winner = layerOf(s.sourceLayer, levels.get(s.sourceLayer) ?? 0)
+function adaptSection(s: ResolvedSection, levels: Map, buckets: LevelBuckets): ConceptSection {
+ const winner = layerOf(s.sourceLayer, levels.get(s.sourceLayer) ?? 0, buckets)
const dissents: Dissent[] = (s.conflicts ?? []).map((c) => ({
- layer: layerOf(c.layer, levels.get(c.layer) ?? 0),
+ layer: layerOf(c.layer, levels.get(c.layer) ?? 0, buckets),
sourceLayer: c.layer,
value: c.content,
updated: c.updated,
@@ -499,11 +545,12 @@ function adaptSection(s: ResolvedSection, levels: Map): ConceptS
}
}
-/** A resolved concept → the console's Concept. */
-export function adaptConcept(r: ResolvedConcept): Concept {
+/** A resolved concept → the console's Concept. `buckets` is the rank-based
+ * level→lane assignment for this resolve pass (see `computeLevelBuckets`). */
+export function adaptConcept(r: ResolvedConcept, buckets: LevelBuckets): Concept {
const levels = contributorLevels(r)
- const layerIds = orderLayers(r.contributors.map((c) => layerOf(c.layer, c.level)))
- const sections = r.sections.map((s) => adaptSection(s, levels))
+ const layerIds = orderLayers(r.contributors.map((c) => layerOf(c.layer, c.level, buckets)))
+ const sections = r.sections.map((s) => adaptSection(s, levels, buckets))
return {
id: r.id,
title: (r.frontmatter?.title as string) ?? r.id,
@@ -515,6 +562,7 @@ export function adaptConcept(r: ResolvedConcept): Concept {
// draft signal. Owning a concept in a single layer does not make it draft.
draft: r.frontmatter?.draft === true,
sections,
+ contributorLayers: r.contributors.map((c) => c.layer),
}
}
@@ -552,6 +600,7 @@ export function progressPercent(p: { loaded?: number; total?: number | null } |
/** Graph sources → the console's Source[] (coverage/focus/status derived honestly). */
export function adaptSources(g: GraphSummary): Source[] {
+ const buckets = computeLevelBuckets(g.sources.map((s) => s.level))
return g.sources.map((s: GraphSource) => {
const errored = s.status === 'error'
// A remote source that can't reach its API doesn't throw — it answers with
@@ -587,7 +636,7 @@ export function adaptSources(g: GraphSummary): Source[] {
return {
name: s.name,
kind: s.kind === 'mcp' ? 'mcp' : 'okf-local',
- layer: layerOf(s.name, s.level),
+ layer: layerOf(s.name, s.level, buckets),
// A source contributing nothing shouldn't show a full bar, however it
// got there — errored, degraded to empty, or genuinely empty. While it
// indexes the bar tracks real progress instead of standing in for it.
@@ -713,8 +762,10 @@ export function trivialConflictReason(values: string[]): string | null {
: 'The answers use the same words in the same order; only formatting differs.'
}
-/** Derive open conflicts plus resolved decisions retained by the local log. */
-export function adaptConflicts(concepts: ResolvedConcept[], resolutions: ConflictResolutionRecord[] = []): Conflict[] {
+/** Derive open conflicts plus resolved decisions retained by the local log.
+ * `buckets` is the rank-based level→lane assignment for this resolve pass
+ * (see `computeLevelBuckets`). */
+export function adaptConflicts(concepts: ResolvedConcept[], resolutions: ConflictResolutionRecord[] = [], buckets: LevelBuckets): Conflict[] {
const out: Conflict[] = []
const historyByConflict = new Map()
for (const resolution of resolutions) {
@@ -727,13 +778,13 @@ export function adaptConflicts(concepts: ResolvedConcept[], resolutions: Conflic
const levels = contributorLevels(c)
for (const s of c.sections) {
if (!s.conflicts?.length) continue
- const winner = layerOf(s.sourceLayer, levels.get(s.sourceLayer) ?? 0)
+ const winner = layerOf(s.sourceLayer, levels.get(s.sourceLayer) ?? 0, buckets)
const id = `${c.id}::${s.key}`
const history = historyByConflict.get(id) ?? []
const contributions = [
{ layer: winner, sourceLayer: s.sourceLayer, value: s.content, updated: s.sourceUpdated ?? '' },
...s.conflicts.map((k) => ({
- layer: layerOf(k.layer, levels.get(k.layer) ?? 0),
+ layer: layerOf(k.layer, levels.get(k.layer) ?? 0, buckets),
sourceLayer: k.layer,
value: k.content,
updated: k.updated ?? '',
@@ -764,7 +815,7 @@ export function adaptConflicts(concepts: ResolvedConcept[], resolutions: Conflic
if (out.some((item) => item.id === id)) continue
const latest = history[history.length - 1]
const contributions = latest.contributions.map((item) => ({
- layer: layerOf(item.layer, item.level ?? (item.layer === 'personal' ? 3 : item.layer === 'team' ? 2 : 0)),
+ layer: layerOf(item.layer, item.level ?? (item.layer === 'personal' ? 3 : item.layer === 'team' ? 2 : 0), buckets),
sourceLayer: item.layer,
value: item.content,
updated: item.updated ?? '',
@@ -776,10 +827,11 @@ export function adaptConflicts(concepts: ResolvedConcept[], resolutions: Conflic
section: headingText(latest.sectionHeading),
title: `${headingText(latest.sectionHeading)} — ${latest.title}`,
status: 'resolved',
- winner: layerOf(latest.chosen?.layer ?? latest.contributions[0]?.layer ?? '', latest.chosen?.level ?? latest.contributions[0]?.level ?? 0),
+ winner: layerOf(latest.chosen?.layer ?? latest.contributions[0]?.layer ?? '', latest.chosen?.level ?? latest.contributions[0]?.level ?? 0, buckets),
contributions,
safe: false,
history,
+ effectiveSource: latest.chosen?.layer ?? latest.contributions[0]?.layer ?? null,
})
}
return out
@@ -809,11 +861,20 @@ function legacyConflictRecord(conflict: Conflict): DiscrepancyRecord {
}
}
-/** Raw professional discrepancy records → the existing navigator view model. */
-export function adaptDiscrepancies(records: DiscrepancyRecord[], coverageComplete = true): Conflict[] {
+/** Raw professional discrepancy records → the existing navigator view model.
+ * `buckets` is the rank-based level→lane assignment for this resolve pass
+ * (see `computeLevelBuckets`). */
+export function adaptDiscrepancies(records: DiscrepancyRecord[], coverageComplete = true, buckets: LevelBuckets): Conflict[] {
return records.map((record) => {
+ // The raw contribution value carries its real type (the engine never
+ // stringifies a list-typed frontmatter field before serving it) — check
+ // it BEFORE the display value below coerces every non-string into JSON
+ // text. `isList` rides with the discrepancy, not a contribution, because
+ // the engine's own compose guard (service.mjs) rejects the action for the
+ // whole field, not per-contributor.
+ const isList = record.contributions.some((item) => Array.isArray(item.value))
const contributions = record.contributions.map((item) => ({
- layer: layerOf(item.source, item.level), sourceLayer: item.source,
+ layer: layerOf(item.source, item.level, buckets), sourceLayer: item.source,
value: typeof item.value === 'string' ? item.value : JSON.stringify(item.value, null, 2),
updated: item.updated ?? '',
...(record.fresherDissent && !item.effective ? { fresherDissent: true } : {}),
@@ -823,13 +884,14 @@ export function adaptDiscrepancies(records: DiscrepancyRecord[], coverageComplet
id: record.id, concept: record.conceptId, sectionKey: record.key,
section: record.label, title: `${record.label} — ${record.conceptTitle}`,
status: record.status === 'resolved' ? 'resolved' : 'open',
- winner: layerOf(effective?.source ?? '', effective?.level ?? 0),
+ winner: layerOf(effective?.source ?? '', effective?.level ?? 0, buckets),
contributions, safe: false, history: record.history,
kind: record.kind, discrepancyStatus: record.status, revision: record.revision,
owner: record.owner, priority: record.priority, winnerReason: record.winnerReason,
effectiveSource: record.effectiveSource, coverageComplete, sourceHealth: record.sourceHealth,
matchingRules: record.matchingRules, ruleConflict: record.ruleConflict, target: record.target,
affectedLinks: record.affectedLinks,
+ ...(isList ? { isList: true } : {}),
}
})
}
diff --git a/apps/console/src/components/ConceptDetail.test.tsx b/apps/console/src/components/ConceptDetail.test.tsx
new file mode 100644
index 0000000..711605e
--- /dev/null
+++ b/apps/console/src/components/ConceptDetail.test.tsx
@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+// ConceptDetail is shared by the Canvas slide-over and the Knowledge page. A
+// section's provenance line, its "suppressed by" note, and a dissent chip all
+// used to name the three-lane bucket (layerName(winner)/layerName(layer))
+// instead of the real source that produced the value — so two sources sharing
+// a lane (e.g. two personal-level MCP servers) were indistinguishable in the
+// inspector. Every place that used to print a lane name now prints
+// `sourceLayer`, the manifest's own name for the contributor.
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ConceptDetail } from './ConceptDetail'
+import type { Concept } from '../data'
+
+vi.mock('../layer-files', () => ({
+ filesRevalidation: () => 'rev',
+ useLayerFiles: () => ({ layers: [] }),
+}))
+
+const mocks = vi.hoisted(() => ({ store: null as unknown as Record }))
+vi.mock('../store', () => {
+ const store = () => mocks.store
+ return { useStore: store, useStoreData: store, useStoreNav: store, useStoreInput: store }
+})
+
+let container: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ mocks.store = { mode: 'demo', sources: [], reloadKey: 0, openFilesScope: vi.fn() }
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+})
+
+function concept(): Concept {
+ return {
+ id: 'decisions/primary-db',
+ title: 'Primary database',
+ type: 'decision',
+ layers: ['personal', 'team'],
+ sections: [
+ {
+ name: 'Choice',
+ winner: 'personal',
+ sourceLayer: 'maya-notes',
+ value: 'SingleStore for HTAP workloads.',
+ updated: '2026-08-01',
+ dissents: [
+ { layer: 'team', sourceLayer: 'acme-eng', value: 'Postgres (org standard).', updated: '2026-06-01' },
+ ],
+ },
+ {
+ name: 'Rollback plan',
+ winner: 'personal',
+ sourceLayer: 'maya-notes',
+ value: '',
+ suppressed: true,
+ },
+ ],
+ }
+}
+
+describe('ConceptDetail provenance', () => {
+ it('names the real contributing source, not the lane it renders in', async () => {
+ await act(async () => root.render())
+ expect(container.textContent).toContain('maya-notes · 2026-08-01')
+ // The lane bucket name never appears as the section's provenance text —
+ // it stays a color cue (the dot) plus the top-of-panel layer chips.
+ expect(container.querySelector('code')).toBeTruthy()
+ })
+
+ it('names the real source in the suppressed-by note, not "personal"', async () => {
+ await act(async () => root.render())
+ expect(container.textContent).toContain('suppressed by maya-notes')
+ expect(container.textContent).not.toContain('suppressed by personal')
+ })
+
+ it('names the real dissenting source on the dissent chip, keeping the lane color', async () => {
+ await act(async () => root.render())
+ const chip = Array.from(container.querySelectorAll('span')).find((el) => el.textContent === 'acme-eng')
+ expect(chip, 'dissent chip should read the source name, not the lane').toBeTruthy()
+ expect(container.textContent).not.toContain('Team says')
+ })
+})
diff --git a/apps/console/src/components/ConceptDetail.tsx b/apps/console/src/components/ConceptDetail.tsx
index 6682187..6299ac5 100644
--- a/apps/console/src/components/ConceptDetail.tsx
+++ b/apps/console/src/components/ConceptDetail.tsx
@@ -1,6 +1,5 @@
import { useMemo } from 'react'
import { C, css, lc, MONO, conceptTypeStyle } from '../theme'
-import { layerName } from '../data'
import type { Concept } from '../data'
import { filesRevalidation, useLayerFiles } from '../layer-files'
import { useStoreData } from '../store'
@@ -56,6 +55,38 @@ function OpenFile({ layer, path, conceptId }: { layer: string; path: string | un
)
}
+/**
+ * A concept with no sections is a dead end — the resolver produced an id and
+ * some frontmatter, but nothing to read. Rather than rendering an empty
+ * `
` with no explanation, name the situation and, where a source file is
+ * identifiable, offer a way to it: the winning contributor's file, or —
+ * absent a listing for it (an MCP or REST-read contributor keeps no file
+ * here) — a plain way into the Files tab, scoped to that source, so browsing
+ * is still one click away.
+ */
+function EmptyConcept({ concept, fileFor }: { concept: Concept; fileFor: (sourceLayer: string) => string | undefined }) {
+ const { openFilesScope } = useStoreData()
+ const winner = concept.contributorLayers?.[0]
+ const path = winner ? fileFor(winner) : undefined
+ return (
+
+
This concept has no sections — the file may be empty.
+ {winner && (
+ path
+ ?
+ : (
+
+ )
+ )}
+
+ )
+}
+
/** The resolved read of a concept — provenance chips per section + inline dissent.
* Shared by the Concepts view and the Canvas node slide-over. */
export function ConceptDetail({ concept }: { concept: Concept }) {
@@ -77,10 +108,15 @@ export function ConceptDetail({ concept }: { concept: Concept }) {
+ {concept.sections.length === 0 && }
{concept.sections.map((s) => {
const col = lc(s.winner)
const dissents = s.dissents ?? []
- const provenance = `${layerName(s.winner)}${s.updated ? ' · ' + s.updated : ''}`
+ // The real source that won this section, not the three-lane bucket it
+ // renders in — two sources can share a lane, and only the source name
+ // says which one is behind the value. The colored dot beside the
+ // heading already carries the lane; this text carries provenance.
+ const provenance = `${s.sourceLayer}${s.updated ? ' · ' + s.updated : ''}`
return (
)
diff --git a/apps/console/src/components/SettingsView.tsx b/apps/console/src/components/SettingsView.tsx
index 91a185d..181a11e 100644
--- a/apps/console/src/components/SettingsView.tsx
+++ b/apps/console/src/components/SettingsView.tsx
@@ -83,6 +83,12 @@ export function SettingsView({ appMode, onClose, onIndexingChange, surface = 'ov
const root = rootRef.current
focusables(root)[0]?.focus()
const onKey = (event: KeyboardEvent) => {
+ // The shell's own Escape handler (App.tsx) already closes Settings and
+ // calls preventDefault() when settingsOpen — without this check, both
+ // handlers ran on every Escape press (confirmed in real Chrome), so
+ // onClose() fired twice and the focus-restore hook had to be made
+ // idempotent to tolerate it (see useOpenerFocus.ts).
+ if (event.defaultPrevented) return
if (event.key === 'Escape') { event.preventDefault(); onClose?.(); return }
if (event.key !== 'Tab') return
const items = focusables(root)
diff --git a/apps/console/src/components/SetupWizard.test.tsx b/apps/console/src/components/SetupWizard.test.tsx
index a3c86c0..d101219 100644
--- a/apps/console/src/components/SetupWizard.test.tsx
+++ b/apps/console/src/components/SetupWizard.test.tsx
@@ -606,6 +606,39 @@ describe('SetupWizard add-a-source mode', () => {
expect(container.querySelector('#wiz-add-level')?.textContent).toBe('3')
})
+ // F22b: `role="radio"` buttons get no native arrow-key behavior — unlike a
+ // real ``, there is no browser default for the browser
+ // to run — so ChoiceCards implements the APG roving-tabindex pattern by
+ // hand. Selection follows focus and only the selected card is a tab stop.
+ it('moves the source-kind selection with ArrowDown/ArrowRight and ArrowUp/ArrowLeft, wrapping at the ends', async () => {
+ await act(async () => root.render())
+
+ expect(sourceChoice('Markdown folder').getAttribute('tabindex')).toBe('0')
+ expect(sourceChoice('ContextCake folder').getAttribute('tabindex')).toBe('-1')
+
+ await act(async () => sourceChoice('Markdown folder').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })))
+ expect(sourceChoice('ContextCake folder').getAttribute('aria-checked')).toBe('true')
+ expect(sourceChoice('ContextCake folder').getAttribute('tabindex')).toBe('0')
+ expect(sourceChoice('Markdown folder').getAttribute('tabindex')).toBe('-1')
+ expect(document.activeElement).toBe(sourceChoice('ContextCake folder'))
+
+ // ArrowRight is the same direction as ArrowDown for this vertically
+ // stacked group, twice more to reach MCP server (index 3, wrapping never
+ // triggered yet).
+ await act(async () => sourceChoice('ContextCake folder').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })))
+ await act(async () => sourceChoice('GitHub repo').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true })))
+ expect(sourceChoice('MCP server').getAttribute('aria-checked')).toBe('true')
+
+ // Past the last choice, ArrowDown wraps back to the first.
+ await act(async () => sourceChoice('MCP server').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })))
+ expect(sourceChoice('Markdown folder').getAttribute('aria-checked')).toBe('true')
+ expect(document.activeElement).toBe(sourceChoice('Markdown folder'))
+
+ // And ArrowUp from the first wraps back to the last.
+ await act(async () => sourceChoice('Markdown folder').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true })))
+ expect(sourceChoice('MCP server').getAttribute('aria-checked')).toBe('true')
+ })
+
it('accepts a second repo beside team under its own name (EARS)', async () => {
await act(async () => root.render())
diff --git a/apps/console/src/components/SetupWizard.tsx b/apps/console/src/components/SetupWizard.tsx
index ff071e8..411ce19 100644
--- a/apps/console/src/components/SetupWizard.tsx
+++ b/apps/console/src/components/SetupWizard.tsx
@@ -345,7 +345,16 @@ function errorLine(message: string): React.ReactNode {
return
{message}
}
-/** Generic radio-card group (kind pickers, repo access). */
+/**
+ * Generic radio-card group (kind pickers, repo access). `role="radio"`
+ * buttons get no native arrow-key behavior — unlike real ``s, the browser has nothing to do that for — so this
+ * implements the APG roving-tabindex pattern by hand: ArrowDown/ArrowRight
+ * moves to the next card (wrapping), ArrowUp/ArrowLeft to the previous, and
+ * selection follows focus. Only the selected card is a tab stop; the rest
+ * are reachable by arrow key once inside the group, same contract the file
+ * tree and the discrepancy list already use.
+ */
function ChoiceCards({
value, onChange, choices, label,
}: {
@@ -354,8 +363,17 @@ function ChoiceCards({
choices: Array<{ value: T; title: string; detail: string; badge?: string }>
label: string
}) {
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(event.key)) return
+ event.preventDefault()
+ const index = Math.max(0, choices.findIndex((choice) => choice.value === value))
+ const delta = event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 1 : -1
+ const next = choices[(index + delta + choices.length) % choices.length]
+ onChange(next.value)
+ event.currentTarget.querySelector(`[value="${next.value}"]`)?.focus()
+ }
return (
-
+
{choices.map((choice) => {
const selected = value === choice.value
return (
@@ -363,7 +381,9 @@ function ChoiceCards({
key={choice.value}
type="button"
role="radio"
+ value={choice.value}
aria-checked={selected}
+ tabIndex={selected ? 0 : -1}
onClick={() => onChange(choice.value)}
style={css(`display:grid; grid-template-columns:16px minmax(0,1fr); gap:10px; width:100%; padding:11px 12px; text-align:left; border-radius:10px; border:1px solid ${selected ? C.tealStroke : C.line}; background:${selected ? C.tealFill : C.surface}; color:${C.ink}; cursor:pointer; font:inherit; transition:border-color 150ms ease, background 150ms ease;`)}
>
@@ -694,7 +714,10 @@ export function SetupWizard({
const dialogRef = useRef(null)
useEffect(() => {
- const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
+ // Matches the defaultPrevented-first convention used everywhere else a
+ // shell-level and a dialog-level Escape handler could otherwise both act
+ // on the same keypress (see SettingsView's identical guard).
+ const onKey = (e: KeyboardEvent) => { if (!e.defaultPrevented && e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
diff --git a/apps/console/src/components/useOpenerFocus.ts b/apps/console/src/components/useOpenerFocus.ts
new file mode 100644
index 0000000..df544a8
--- /dev/null
+++ b/apps/console/src/components/useOpenerFocus.ts
@@ -0,0 +1,62 @@
+import { useCallback, useMemo, useRef } from 'react'
+
+const FOCUSABLE_SELECTOR = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+
+/**
+ * Focus-return for an imperatively opened dialog (Settings, the setup
+ * wizard): `capture()` at the moment the dialog is told to open — before the
+ * state flip, while `document.activeElement` is still the trigger — and
+ * `restore()` right after the state flip that closes it.
+ *
+ * The opener is not always still there to focus: the wizard can auto-open
+ * with no button at all (first run), and any trigger can have scrolled out
+ * of the viewport or been unmounted underneath a still-open dialog. `restore`
+ * walks the captured opener first, then `fallbackSelectors` in order, and
+ * focuses the first candidate that is connected, focusable, and actually
+ * visible (a zero-size node is read as "not yet laid out" rather than
+ * hidden, so it is not excluded on that basis alone) — never .
+ */
+export function useOpenerFocus(fallbackSelectors: readonly string[] = []) {
+ const openerRef = useRef(null)
+ // Separate from openerRef because `null` there is itself a valid captured
+ // state (no focusable activeElement, or the wizard's no-trigger first-run
+ // open) that still means "run the fallback search". This flag is what
+ // makes restore() idempotent: a real-Chrome repro found Settings closing
+ // via Escape called restore() TWICE — the shell's own Escape handler closes
+ // it, and SettingsView's internal Escape handler (fixed alongside this to
+ // check event.defaultPrevented) fired too. The first call reads the real
+ // opener and schedules a focus() for it; the second, with openerRef already
+ // nulled, fell through to the generic fallback selectors and — because
+ // rAF preserves scheduling order — its focus() ran *after* the first and
+ // silently overrode the correct target with the wrong one (or, once the
+ // fallback matched nothing at all in the ADD Source case, left focus on
+ // ). Gating on `pendingRef` makes a second, spurious restore() a
+ // no-op instead of a second, competing guess.
+ const pendingRef = useRef(false)
+
+ const capture = useCallback(() => {
+ openerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
+ pendingRef.current = true
+ }, [])
+
+ const restore = useCallback(() => {
+ if (!pendingRef.current) return
+ pendingRef.current = false
+ const opener = openerRef.current
+ openerRef.current = null
+ window.requestAnimationFrame(() => {
+ const candidates = [opener, ...fallbackSelectors.map((selector) => document.querySelector(selector))]
+ candidates.find((candidate) => {
+ if (!candidate?.isConnected) return false
+ if (!candidate.matches(FOCUSABLE_SELECTOR)) return false
+ const rect = candidate.getBoundingClientRect()
+ const hasNoLayout = rect.width === 0 && rect.height === 0
+ const visible = hasNoLayout || (rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.bottom > 0 && rect.left < window.innerWidth && rect.top < window.innerHeight)
+ if (visible) candidate.focus()
+ return visible
+ })
+ })
+ }, [fallbackSelectors])
+
+ return useMemo(() => ({ capture, restore }), [capture, restore])
+}
diff --git a/apps/console/src/data.ts b/apps/console/src/data.ts
index dcac85b..c27737b 100644
--- a/apps/console/src/data.ts
+++ b/apps/console/src/data.ts
@@ -79,6 +79,13 @@ export interface Conflict {
ruleConflict?: boolean
target?: string
affectedLinks?: string[]
+ /**
+ * True when any raw contribution behind this discrepancy is an array-typed
+ * frontmatter value (a list field). The engine 400s a compose against such
+ * a field (service.mjs), so the UI disables the compose disposition rather
+ * than letting the request round-trip into an error.
+ */
+ isList?: boolean
}
/** `sourceLayer` is the source's real name; `layer` is the lane it renders in. */
@@ -100,6 +107,13 @@ export interface ConceptSection {
export interface Concept {
id: string; title: string; type: string; layers: LayerId[]
conflict?: boolean; draft?: boolean; sections: ConceptSection[]
+ /**
+ * The real source names behind this concept, winner first — kept
+ * separately from `layers` (the three-lane buckets) because a concept with
+ * zero sections has no `ConceptSection.sourceLayer` to read a contributor's
+ * real name from, and the "Open file" affordance needs one anyway.
+ */
+ contributorLayers?: string[]
}
export interface Activity {
diff --git a/apps/console/src/shell-navigation.ts b/apps/console/src/shell-navigation.ts
index 0427853..b11e07c 100644
--- a/apps/console/src/shell-navigation.ts
+++ b/apps/console/src/shell-navigation.ts
@@ -97,6 +97,21 @@ export function dispatchNavigationGuard(): boolean {
export const SEARCHABLE_VIEWS = new Set(['concepts', 'files', 'sources', 'triage', 'conflicts'])
+/** Per-view document title, same names the command palette's "Go to …" entries use. */
+export const VIEW_TITLES: Record = {
+ overview: 'Home',
+ canvas: 'Cascade',
+ concepts: 'Knowledge: Concepts',
+ files: 'Knowledge: Files',
+ sources: 'Sources',
+ triage: 'Review: Queue',
+ conflicts: 'Review: Discrepancies',
+}
+
+export function titleForView(view: ViewId): string {
+ return `${VIEW_TITLES[view]} — ContextCake`
+}
+
export function readBrowserGroupedViews(): { knowledgeView: KnowledgeSubview; reviewView: ReviewSubview } {
try {
return {
diff --git a/apps/console/src/store.test.tsx b/apps/console/src/store.test.tsx
index 2fbb79f..c77feb2 100644
--- a/apps/console/src/store.test.tsx
+++ b/apps/console/src/store.test.tsx
@@ -259,6 +259,11 @@ describe('store load state', () => {
// Generation moves every tick (the engine counts documents into it), but
// nothing that decides the payload changes until the snapshot lands.
mocks.status
+ // Consumed by the WP-B bootstrap probe, immediately after readAll()
+ // commits — same generation and shape readAll() itself just saw, so
+ // the probe is a no-op and the three progress-only ticks below still
+ // land on the recurring poll() exactly as before.
+ .mockResolvedValueOnce(statusPayload({ generation: 1, indexing: true, loaded: 0, total: 3000 }))
.mockResolvedValueOnce(statusPayload({ generation: 2, indexing: true, loaded: 400, total: 3000 }))
.mockResolvedValueOnce(statusPayload({ generation: 3, indexing: true, loaded: 900, total: 3000 }))
.mockResolvedValueOnce(statusPayload({ generation: 4, indexing: true, loaded: 1800, total: 3000 }))
@@ -354,19 +359,23 @@ describe('store load state', () => {
expect(probe().dataset.count).toBe('1')
})
- it('stops polling while the window is hidden and resumes when it comes back', async () => {
- mocks.graph.mockResolvedValue(graphPayload(['personal']))
- mocks.resolveAll.mockResolvedValue({ concepts: [], errors: [], indexing: true, indexingSources: ['personal'] })
- mocks.status.mockResolvedValue(statusPayload({ generation: 2, indexing: true, loaded: 10, total: 3000 }))
+ it('stops polling while the window is hidden when nothing is active, and resumes when it comes back', async () => {
+ // The cost optimization this pins: a hidden tab with nothing in flight
+ // must stay silent, exactly as before FIX 3 — only a hidden tab with
+ // real work active gets the new slower-but-still-polling behavior
+ // (see the next test).
+ mocks.graph.mockResolvedValue(graphPayload([]))
+ mocks.resolveAll.mockResolvedValue({ concepts: [conceptPayload('a')], errors: [], indexing: false })
+ mocks.status.mockResolvedValue(statusPayload({ generation: 2, indexing: false, conceptCount: 1 }))
await act(async () => root.render())
- await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
+ await act(async () => { await vi.advanceTimersByTimeAsync(11_000) })
const whileVisible = mocks.status.mock.calls.length
expect(whileVisible).toBeGreaterThan(0)
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
await act(async () => document.dispatchEvent(new Event('visibilitychange')))
- await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
+ await act(async () => { await vi.advanceTimersByTimeAsync(30_000) })
expect(mocks.status.mock.calls.length).toBe(whileVisible)
visibility.mockReturnValue('visible')
@@ -375,6 +384,177 @@ describe('store load state', () => {
expect(mocks.status.mock.calls.length).toBeGreaterThan(whileVisible)
visibility.mockRestore()
})
+
+ // FIX 3(a): schedule() used to be a flat no-op while hidden, so a
+ // backgrounded tab that was still indexing when it went hidden (or that
+ // started out hidden — see the WP-B bootstrap-probe test below) had
+ // nothing left to resume it, possibly forever. Real work in flight now
+ // keeps the loop polling through a hidden window, just at
+ // HIDDEN_ACTIVE_POLL_MS instead of the visible ACTIVE_POLL_MS cadence.
+ it('keeps polling, slower, while hidden when work is active — and recovers once the engine finishes', async () => {
+ mocks.graph.mockResolvedValue(graphPayload(['personal']))
+ mocks.resolveAll.mockResolvedValue({ concepts: [], errors: [], indexing: true, indexingSources: ['personal'] })
+ mocks.status.mockResolvedValue(statusPayload({ generation: 2, indexing: true, loaded: 10, total: 3000 }))
+
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
+ expect(probe().dataset.indexing).toBe('personal')
+
+ const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
+ await act(async () => document.dispatchEvent(new Event('visibilitychange')))
+ const whileHiddenStart = mocks.status.mock.calls.length
+
+ // Well past the visible cadence (900ms) but short of the hidden-active
+ // one: if hiding failed to slow the loop down, a poll would already
+ // have landed here.
+ await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
+ expect(mocks.status.mock.calls.length).toBe(whileHiddenStart)
+
+ // The engine finishes indexing while the tab is still hidden. FIX 3:
+ // the heavy graph+resolve-all refetch this would otherwise trigger is
+ // deferred while hidden — see the dedicated FIX 3 test below for the
+ // call-count proof. Only the cheap status signal lands here.
+ mocks.resolveAll.mockResolvedValue({ concepts: [conceptPayload('a')], errors: [], indexing: false })
+ mocks.status.mockResolvedValue(statusPayload({ generation: 9, indexing: false, conceptCount: 1 }))
+ await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
+
+ expect(mocks.status.mock.calls.length).toBeGreaterThan(whileHiddenStart)
+ // The status route's own indexingSources answers directly, independent
+ // of the deferred heavy refetch — a hidden tab still learns indexing is
+ // done without downloading the corpus to prove it.
+ expect(probe().dataset.indexing).toBe('')
+ // The resolved concept count is the deferred half: still stale.
+ expect(probe().dataset.count).toBe('0')
+
+ // Once idle (nothing active) AND still hidden, the loop goes fully
+ // silent — confirming the hidden polling wound back down rather than
+ // persisting after work finished.
+ const afterRecovery = mocks.status.mock.calls.length
+ await act(async () => { await vi.advanceTimersByTimeAsync(20_000) })
+ expect(mocks.status.mock.calls.length).toBe(afterRecovery)
+
+ // Returning to visible lands the deferred catch-up.
+ visibility.mockReturnValue('visible')
+ await act(async () => document.dispatchEvent(new Event('visibilitychange')))
+ await act(async () => { await vi.advanceTimersByTimeAsync(50) })
+ expect(probe().dataset.count).toBe('1')
+
+ visibility.mockRestore()
+ })
+
+ // FIX 3: /api/graph and /api/resolve-all are the 620ms/150MB-on-a-real-vault
+ // payloads (see CLAUDE.md); a hidden tab has nobody to show them to. This
+ // pins the call counts directly, rather than through a derived dataset
+ // value, so a regression here fails on the exact thing that was expensive.
+ it('issues zero heavy payload calls while hidden, then exactly one catch-up refetch on return to visible (FIX 3)', async () => {
+ mocks.graph.mockResolvedValue(graphPayload(['personal']))
+ mocks.resolveAll.mockResolvedValue({ concepts: [], errors: [], indexing: true, indexingSources: ['personal'] })
+ mocks.status.mockResolvedValue(statusPayload({ generation: 2, indexing: true, loaded: 10, total: 3000 }))
+
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
+
+ const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
+ await act(async () => document.dispatchEvent(new Event('visibilitychange')))
+ const graphAtHidden = mocks.graph.mock.calls.length
+ const resolveAllAtHidden = mocks.resolveAll.mock.calls.length
+
+ // The snapshot lands while hidden: generation moves and indexing flips
+ // to done — exactly the condition that would normally earn a heavy
+ // refetch.
+ mocks.status.mockResolvedValue(statusPayload({ generation: 9, indexing: false, conceptCount: 1 }))
+ await act(async () => { await vi.advanceTimersByTimeAsync(20_000) })
+
+ expect(mocks.graph.mock.calls.length).toBe(graphAtHidden)
+ expect(mocks.resolveAll.mock.calls.length).toBe(resolveAllAtHidden)
+
+ mocks.resolveAll.mockResolvedValue({ concepts: [conceptPayload('a')], errors: [], indexing: false })
+ visibility.mockReturnValue('visible')
+ await act(async () => document.dispatchEvent(new Event('visibilitychange')))
+ await act(async () => { await vi.advanceTimersByTimeAsync(50) })
+
+ expect(mocks.graph.mock.calls.length).toBe(graphAtHidden + 1)
+ expect(mocks.resolveAll.mock.calls.length).toBe(resolveAllAtHidden + 1)
+ expect(probe().dataset.count).toBe('1')
+ expect(probe().dataset.indexing).toBe('')
+
+ visibility.mockRestore()
+ })
+
+ // FIX 2: a failed pass tells us nothing about whether work is active, so
+ // it must not keep a hidden tab polling off a stale `activeState === true`
+ // from the last successful pass. Measured before this fix: 85 failed
+ // fetches in 10 simulated hidden minutes, no termination.
+ it('stops polling in a hidden tab once the engine starts failing, even though it was active a moment ago (FIX 2)', async () => {
+ mocks.graph.mockResolvedValue(graphPayload(['personal']))
+ mocks.resolveAll.mockResolvedValue({ concepts: [], errors: [], indexing: true, indexingSources: ['personal'] })
+ mocks.status.mockResolvedValue(statusPayload({ generation: 2, indexing: true, loaded: 10, total: 3000 }))
+
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
+ expect(probe().dataset.indexing).toBe('personal')
+
+ const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
+ await act(async () => document.dispatchEvent(new Event('visibilitychange')))
+
+ // The engine dies while the tab is hidden and stays dead.
+ mocks.status.mockRejectedValue(new Error('engine gone'))
+ const beforeFailures = mocks.status.mock.calls.length
+
+ // The already-scheduled hidden-active tick fires once, fails, and (per
+ // the fix) must not reschedule itself while hidden.
+ await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
+ const afterOneFailure = mocks.status.mock.calls.length
+ expect(afterOneFailure).toBeGreaterThan(beforeFailures)
+
+ // Simulates the full 10-minutes-hidden repro: nothing more should fire.
+ await act(async () => { await vi.advanceTimersByTimeAsync(600_000) })
+ expect(mocks.status.mock.calls.length).toBe(afterOneFailure)
+
+ visibility.mockRestore()
+ })
+
+ // WP-B: a page that first renders hidden (an embedded webview can
+ // misreport visibility) never gets a recurring poll at all — schedule()
+ // is a no-op while hidden, and nothing resumes the loop until
+ // visibilitychange fires. Without a probe that ignores hidden(), a page
+ // that loaded mid-index would sit on that stuck snapshot forever with no
+ // way to notice the engine actually finished.
+ it('probes /api/status once at bootstrap even when the page starts hidden, and recovers a stuck initial snapshot once visible', async () => {
+ const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
+ try {
+ mocks.graph.mockResolvedValue(graphPayload(['personal']))
+ mocks.resolveAll
+ .mockResolvedValueOnce({ concepts: [], errors: [], indexing: true, indexingSources: ['personal'] })
+ .mockResolvedValue({ concepts: [conceptPayload('a')], errors: [], indexing: false })
+ // The engine actually finished by the time this page loaded — status
+ // disagrees with the graph's still-indexing snapshot from readAll().
+ mocks.status.mockResolvedValue(statusPayload({ generation: 9, indexing: false, conceptCount: 1 }))
+
+ await act(async () => root.render())
+
+ // Exactly one status call: the bootstrap probe. schedule() is a
+ // no-op while hidden, so nothing else could have called it.
+ expect(mocks.status.mock.calls.length).toBe(1)
+ // The status route's own indexingSources answers directly — a hidden
+ // tab learns indexing is done without the heavy refetch.
+ expect(probe().dataset.indexing).toBe('')
+ // FIX 3: the correction this probe would otherwise fetch immediately
+ // is deferred while hidden — count is still the stale readAll()
+ // snapshot from before the mismatch was noticed.
+ expect(probe().dataset.count).toBe('0')
+ expect(mocks.resolveAll.mock.calls.length).toBe(1)
+
+ // Becoming visible lands the deferred catch-up.
+ visibility.mockReturnValue('visible')
+ await act(async () => document.dispatchEvent(new Event('visibilitychange')))
+ await act(async () => { await vi.advanceTimersByTimeAsync(50) })
+ expect(mocks.resolveAll.mock.calls.length).toBe(2)
+ expect(probe().dataset.count).toBe('1')
+ } finally {
+ visibility.mockRestore()
+ }
+ })
})
it('does not treat an empty mid-index pass as a fatal error', async () => {
@@ -444,6 +624,19 @@ describe('store load state', () => {
})
})
+describe('document title', () => {
+ it('names the current view, and updates when the view changes', async () => {
+ mocks.graph.mockResolvedValue(graphPayload([]))
+ mocks.resolveAll.mockResolvedValue({ concepts: [], errors: [], indexing: false })
+
+ await act(async () => root.render())
+ expect(document.title).toBe('Home — ContextCake')
+
+ await click('to sources')
+ expect(document.title).toBe('Sources — ContextCake')
+ })
+})
+
// ---- Back/Forward vs. an unsaved file --------------------------------------
//
// Real session-history traversal, not a synthesized PopStateEvent: what these
diff --git a/apps/console/src/store.tsx b/apps/console/src/store.tsx
index 0b7edb1..754bad6 100644
--- a/apps/console/src/store.tsx
+++ b/apps/console/src/store.tsx
@@ -6,15 +6,15 @@ import {
type Activity, type Concept, type Conflict, type Signal, type Source,
} from './data'
import {
- adaptConcept, adaptConflicts, adaptDiscrepancies, adaptSources, createDataSource, LiveDataError, mergeSourceStatus,
+ adaptConcept, adaptConflicts, adaptDiscrepancies, adaptSources, computeLevelBuckets, createDataSource, LiveDataError, mergeSourceStatus,
type Mode,
} from './api'
import type {
DiscrepancyDecisionRequest, DiscrepancyRule, DiscrepancyRuleSuggestion,
- GraphSummary, SourceStatus,
+ GraphSummary, SearchHit, SourceStatus, StatusSummary,
} from './types'
import type { LayerId, RouteId } from './theme'
-import { dispatchNavigationGuard, filesHash, isViewId, parseHash, type ViewId } from './shell-navigation'
+import { dispatchNavigationGuard, filesHash, isViewId, parseHash, titleForView, type ViewId } from './shell-navigation'
export type { ViewId } from './shell-navigation'
export type TriageTab = 'review' | 'captured' | 'ignored'
@@ -147,6 +147,18 @@ const ACTIVE_POLL_MS = 900
const IDLE_POLL_MS = 5_000
/** Backoff ceiling. There is deliberately no failure count that stops the loop. */
const MAX_BACKOFF_MS = 5_000
+/**
+ * Cadence for a HIDDEN tab while the engine reports work in flight (indexing
+ * or refreshing). A hidden tab still goes fully silent once nothing is
+ * active — that cost optimization stays exactly as before — but a hidden tab
+ * that landed on a still-indexing snapshot used to have nothing left to
+ * resume it until visibilitychange fired, which could be never (a
+ * backgrounded tab the user doesn't return to for minutes). /api/status
+ * answers in 2-4ms, so ~8x the active cadence is cheap enough to run
+ * unattended and still finishes a bounded indexing pass in single-digit
+ * seconds instead of stalling indefinitely.
+ */
+const HIDDEN_ACTIVE_POLL_MS = 7_000
/**
* The part of the engine's status that decides what /api/graph and
@@ -232,6 +244,15 @@ export interface StoreData {
/** Go to Concepts on one concept — the cross-link from the file behind it. */
openConcept: (id: string) => void
setQuery: (q: string) => void
+ /**
+ * Full-text search over section content (GET /api/search), for Knowledge's
+ * search box. Live mode only — callers gate on `mode` themselves; calling
+ * this in demo mode is a caller bug, not handled here. Never throws: a
+ * missing route or any other failure (network, timeout, malformed body)
+ * resolves to `null`, the signal to fall back to the substring filter
+ * silently rather than break the list.
+ */
+ search: (query: string, limit?: number) => Promise
openChat: () => void
closeChat: () => void
setChatInput: (v: string) => void
@@ -410,17 +431,36 @@ export function StoreProvider({ children }: { children: ReactNode }) {
// thing that knows what is in flight — and the shell needs to say so from
// the first paint, not from the first poll a second later.
let statusAnswered = false
+ // The last `active` a poll/bootstrap pass computed. Read by onVisibility
+ // to decide whether hiding the tab should stop the loop or just slow it
+ // down — see schedule() below.
+ let activeState = false
const hidden = () => typeof document !== 'undefined' && document.visibilityState === 'hidden'
const clearTimer = () => { if (timer !== undefined) { clearTimeout(timer); timer = undefined } }
- const schedule = (ms: number) => {
+ /**
+ * `active` says whether the engine reported work in flight on the pass
+ * that's scheduling this tick — not just "not hidden". A hidden window
+ * with nothing active has nobody to tell, and visibilitychange resumes
+ * the loop when that changes — that cost optimization is unchanged. But
+ * a hidden window with work ACTIVE keeps polling anyway, at
+ * HIDDEN_ACTIVE_POLL_MS: without this, a tab that went hidden (or was
+ * hidden from first paint — see bootstrap's probe) while the engine was
+ * still indexing had nothing left to resume it, possibly forever.
+ * Demo mode has no engine behind it and nothing that can change either
+ * way.
+ */
+ const schedule = (ms: number, active = false) => {
clearTimer()
- // A hidden window has nobody to tell; visibilitychange resumes the loop.
- // Demo mode has no engine behind it and nothing that can change.
- if (cancelled || hidden() || source.mode === 'demo') return
- // Re-checked on fire, not only on schedule: a tick queued a moment before
- // the window was hidden would otherwise still land.
- timer = setTimeout(() => { if (!hidden()) void poll() }, ms)
+ if (cancelled || source.mode === 'demo') return
+ if (hidden()) {
+ if (!active) return
+ ms = Math.max(ms, HIDDEN_ACTIVE_POLL_MS)
+ }
+ // Re-checked on fire, not only on schedule: a tick queued a moment
+ // before the window was hidden would otherwise still land — unless
+ // this tick was itself scheduled to keep running while hidden.
+ timer = setTimeout(() => { if (!hidden() || active) void poll() }, ms)
}
const applyIndexing = (next: string[]) => {
@@ -470,10 +510,16 @@ export function StoreProvider({ children }: { children: ReactNode }) {
// can finish between them; use the later answer so a stale graph never
// leaves the banner running after the work has landed.
applyIndexing(indexing ? (resolvingSources ?? g.indexingSources ?? []) : [])
- setConcepts(raw.map(adaptConcept))
+ // Rank-based level→lane buckets for this pass, computed once from every
+ // source in the graph and threaded through every adapter below — see
+ // computeLevelBuckets in api.ts for why a narrower computation (e.g. per
+ // concept) would bucket the same source differently depending on what
+ // happened to touch it.
+ const buckets = computeLevelBuckets(g.sources.map((s) => s.level))
+ setConcepts(raw.map((c) => adaptConcept(c, buckets)))
const derivedConflicts = discrepancyPayload
- ? adaptDiscrepancies(discrepancyPayload.discrepancies, discrepancyPayload.coverageComplete)
- : adaptConflicts(raw, resolutionHistory)
+ ? adaptDiscrepancies(discrepancyPayload.discrepancies, discrepancyPayload.coverageComplete, buckets)
+ : adaptConflicts(raw, resolutionHistory, buckets)
setConflicts(derivedConflicts)
setDiscrepancyRules(rulePayload.rules)
setDiscrepancyRuleSuggestions(rulePayload.suggestions)
@@ -509,6 +555,58 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return Boolean(indexing)
}
+ /**
+ * One /api/status answer → task/indexing/source state, plus a heavy
+ * refetch when the part that decides the payload moved. Shared by the
+ * recurring `poll()` below and the one-off bootstrap probe further down,
+ * so the gate — "generation moved AND (content signature changed OR
+ * nothing in flight)" — is written in exactly one place rather than
+ * re-derived, loosely, wherever a status answer needs applying.
+ */
+ const applyStatus = async (status: StatusSummary): Promise => {
+ statusAnswered = true
+ const rows: SourceStatus[] = status.sources ?? []
+ applyIndexing(status.indexingSources ?? [])
+ applyTasks(trackTasks(rows))
+ // Keep the per-source rows as current as the toolbar. Without this
+ // the Sources list holds whatever the last heavy refetch said —
+ // which, mid-index, is the phase the source started in.
+ setSources((prev) => mergeSourceStatus(prev, rows))
+ let active = Boolean(status.indexing) || rows.some((r) => r.refreshing)
+ const nextSignature = contentSignature(rows)
+ // `generation` also moves for a progress counter. Refetch when the
+ // part that decides the payload moved — a snapshot landing, a
+ // source erroring, a refresh finishing — or, while the engine is
+ // quiet, on any generation change at all (the file-edit case).
+ const moved = status.generation !== generation
+ const worthRefetching = refetchOwed || (moved && (nextSignature !== signature || !active))
+ if (worthRefetching && hidden()) {
+ // Owed, but deferred: a hidden tab has no one to show the corpus to,
+ // and CLAUDE.md clocks /api/resolve-all at 620ms/150MB on a 3,000-note
+ // vault — not a payload to issue on a timer nobody is watching. Mark
+ // it owed and leave `generation`/`signature` where they are so the
+ // gate stays open (worthRefetching stays true) on every status-only
+ // poll while still hidden, then land it in one shot: onVisibility's
+ // immediate poll() re-enters this same gate once visible, hidden()
+ // is now false, and the branch below actually runs readAll().
+ refetchOwed = true
+ } else if (worthRefetching) {
+ // Committing the gate here — before the heavy read — is how a
+ // failed refetch used to hide: the next poll saw nothing moved,
+ // skipped the retry, and then took the success path below,
+ // clearing the banner over pre-edit concepts. Nothing recovers
+ // from that on its own, because contentSignature deliberately
+ // excludes document content, so a pure edit never reopens the
+ // gate. readAll commits it, once it has actually landed.
+ refetchOwed = true
+ active = (await readAll(status.generation)) || active
+ } else {
+ generation = status.generation
+ signature = nextSignature
+ }
+ return active
+ }
+
/** One cheap poll. Refetches the heavy payloads only when they moved. */
const poll = async () => {
if (cancelled || running) return
@@ -519,45 +617,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
const status = await source.status()
if (cancelled) return
if (status === null) hasStatusRoute = false
- else {
- statusAnswered = true
- const rows: SourceStatus[] = status.sources ?? []
- applyIndexing(status.indexingSources ?? [])
- applyTasks(trackTasks(rows))
- // Keep the per-source rows as current as the toolbar. Without this
- // the Sources list holds whatever the last heavy refetch said —
- // which, mid-index, is the phase the source started in.
- setSources((prev) => mergeSourceStatus(prev, rows))
- active = Boolean(status.indexing) || rows.some((r) => r.refreshing)
- const nextSignature = contentSignature(rows)
- // `generation` also moves for a progress counter. Refetch when the
- // part that decides the payload moved — a snapshot landing, a
- // source erroring, a refresh finishing — or, while the engine is
- // quiet, on any generation change at all (the file-edit case).
- const moved = status.generation !== generation
- const worthRefetching = refetchOwed || (moved && (nextSignature !== signature || !active))
- if (worthRefetching) {
- // Committing the gate here — before the heavy read — is how a
- // failed refetch used to hide: the next poll saw nothing moved,
- // skipped the retry, and then took the success path below,
- // clearing the banner over pre-edit concepts. Nothing recovers
- // from that on its own, because contentSignature deliberately
- // excludes document content, so a pure edit never reopens the
- // gate. readAll commits it, once it has actually landed.
- refetchOwed = true
- active = (await readAll(status.generation)) || active
- } else {
- generation = status.generation
- signature = nextSignature
- }
- }
+ else active = await applyStatus(status)
}
if (!hasStatusRoute) active = await readAll()
if (cancelled) return
failures = 0
setRefreshError(null)
setLastRefreshAt(Date.now())
- schedule(active ? ACTIVE_POLL_MS : IDLE_POLL_MS)
+ activeState = active
+ schedule(active ? ACTIVE_POLL_MS : IDLE_POLL_MS, active)
} catch (e) {
if (cancelled) return
// Never give up, and never quietly retract what the page is saying.
@@ -566,7 +634,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
// anyone to know — the exact impression this whole pass exists to fix.
failures += 1
setRefreshError(asLiveDataError(e))
- schedule(Math.min(MAX_BACKOFF_MS, ACTIVE_POLL_MS * failures))
+ // A failure tells us nothing about whether work is still in flight —
+ // it is not evidence of "active", so it must not keep a HIDDEN tab
+ // polling forever off a stale `true` from the last successful pass.
+ // (Measured before this fix: 85 failed fetches in 10 simulated hidden
+ // minutes, no termination.) A VISIBLE tab is unaffected: schedule()
+ // always fires its next tick when the tab isn't hidden, regardless of
+ // `active` — only the hidden branch reads this value at all.
+ activeState = false
+ schedule(Math.min(MAX_BACKOFF_MS, ACTIVE_POLL_MS * failures), activeState)
} finally {
running = false
}
@@ -575,11 +651,37 @@ export function StoreProvider({ children }: { children: ReactNode }) {
const bootstrap = async () => {
running = true
try {
- const active = await readAll()
+ let active = await readAll()
if (cancelled) return
setRefreshError(null)
setLastRefreshAt(Date.now())
- schedule(active ? ACTIVE_POLL_MS : IDLE_POLL_MS)
+ // A page that first renders with document.visibilityState === 'hidden'
+ // (embedded webviews can misreport this) is exactly the case
+ // schedule()'s HIDDEN_ACTIVE_POLL_MS branch exists for: if the engine
+ // is still indexing at this instant — the common case on a large
+ // vault — a plain schedule(active) below would go silent forever
+ // while hidden, because active only reflects readAll()'s snapshot,
+ // taken before this probe. So this one extra probe runs regardless
+ // of hidden(), through the exact gate `poll()` uses (applyStatus),
+ // so it only pays for a heavy refetch when that gate says one is
+ // owed — and its OWN answer (not the earlier readAll()'s) is what
+ // schedule() below acts on, so work that started between the two
+ // calls is scheduled at ACTIVE_POLL_MS instead of IDLE_POLL_MS.
+ // Demo mode never probes — there is no engine behind it to ask.
+ if (source.mode === 'live' && hasStatusRoute) {
+ try {
+ const status = await source.status()
+ if (!cancelled) {
+ if (status === null) hasStatusRoute = false
+ else active = await applyStatus(status)
+ }
+ } catch {
+ // Non-fatal: readAll() above already left a good snapshot up, and
+ // the recurring loop retries on its own once it gets to run.
+ }
+ }
+ activeState = active
+ schedule(active ? ACTIVE_POLL_MS : IDLE_POLL_MS, active)
} catch (e) {
if (cancelled) return
// A failure on a background refresh must not blow away a working page;
@@ -592,15 +694,33 @@ export function StoreProvider({ children }: { children: ReactNode }) {
}
failures += 1
setRefreshError(asLiveDataError(e))
- schedule(Math.min(MAX_BACKOFF_MS, ACTIVE_POLL_MS * failures))
+ // Same reasoning as poll()'s catch: a failure is not evidence of
+ // active work, so it must not keep a hidden tab polling forever.
+ activeState = false
+ schedule(Math.min(MAX_BACKOFF_MS, ACTIVE_POLL_MS * failures), activeState)
} finally {
running = false
}
}
- const onVisibility = () => { if (hidden()) clearTimer(); else schedule(0) }
+ const onVisibility = () => {
+ if (hidden()) {
+ // Nothing active: go fully silent, same as before — the whole point
+ // of the cost optimization. Something active: re-schedule rather
+ // than clear, so the loop drops straight to HIDDEN_ACTIVE_POLL_MS
+ // instead of continuing to fire at the visible cadence until its
+ // already-queued tick happens to land.
+ if (activeState) schedule(ACTIVE_POLL_MS, true)
+ else clearTimer()
+ } else {
+ schedule(0, activeState)
+ }
+ }
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
- pollNowRef.current = () => { failures = 0; schedule(0) }
+ // An explicit user retry (the refresh-error banner) always fires
+ // immediately, hidden tab or not — it's a direct request, not the
+ // passive background loop the hidden-tab cadence rules are about.
+ pollNowRef.current = () => { failures = 0; schedule(0, true) }
// A refresh must not replace an already-usable shell with a full-page
// loader. Besides the visual regression, doing so unmounts the Files editor
@@ -680,6 +800,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
setViewState('concepts')
}, [])
+ useEffect(() => {
+ document.title = titleForView(view)
+ }, [view])
+
useEffect(() => {
window.__CC_DESKTOP?.uiState?.set({
lastView: view,
@@ -926,6 +1050,21 @@ export function StoreProvider({ children }: { children: ReactNode }) {
}
}, [])
+ // `source.search` is on the DataSource interface, but several test harnesses
+ // stub a partial DataSource (see store.test.tsx) with no `search` at all —
+ // same reason `source.discrepancies` above is guarded rather than called
+ // directly. Any rejection (network, timeout, a malformed body) is caught
+ // here too: this is the one place that owns "never break the list",
+ // regardless of which layer the failure came from.
+ const search = useCallback(async (query: string, limit?: number): Promise => {
+ if (!source.search) return null
+ try {
+ return await source.search(query, limit)
+ } catch {
+ return null
+ }
+ }, [source])
+
const reload = useCallback(() => setReloadKey((k) => k + 1), [])
const openChat = useCallback(() => setChatOpen(true), [])
const closeChat = useCallback(() => setChatOpen(false), [])
@@ -945,7 +1084,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
mode, loading, load, error,
concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError,
discrepancyRules, discrepancyRuleSuggestions,
- setView, setTriageTab, setSelSignal, setSelConflict, setSelConcept, setQuery,
+ setView, setTriageTab, setSelSignal, setSelConflict, setSelConcept, setQuery, search,
setFilesScope, setFilesPath, openFilesScope, openConcept,
openChat, closeChat, setChatInput,
retryNow, route, resolveConflict, resolveSafeConflicts, decideDiscrepancy,
@@ -955,7 +1094,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
resolvingConflict, resolutionError, discrepancyRules, discrepancyRuleSuggestions,
retryNow, route, resolveConflict, resolveSafeConflicts, decideDiscrepancy,
approveRuleSuggestion, updateDiscrepancyRule, promoteDiscrepancyRule, setDiscrepancyPriority,
- send, reload, reloadKey, setView, setSelConcept, setQuery, setFilesScope, setFilesPath,
+ send, reload, reloadKey, setView, setSelConcept, setQuery, search, setFilesScope, setFilesPath,
openFilesScope, openConcept, openChat, closeChat])
const nav = useMemo(
diff --git a/apps/console/src/styles.css b/apps/console/src/styles.css
index 072488d..d6e4bda 100644
--- a/apps/console/src/styles.css
+++ b/apps/console/src/styles.css
@@ -1847,13 +1847,14 @@ textarea:focus-visible,
.cc-decision-panel select { min-height: 44px; padding: 0 10px; }
.cc-decision-panel textarea { min-height: 120px; padding: 10px; line-height: 1.5; resize: vertical; }
.cc-compose, .cc-acknowledge { display: grid; gap: 8px; margin: 0 0 6px 28px; }
-.cc-compose > button { justify-self: start; min-height: 36px; }
+.cc-compose-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+.cc-compose > button, .cc-compose-actions > button { justify-self: start; min-height: 36px; }
.cc-compose-preview { padding: 12px; border: 1px solid var(--cc-line); border-radius: 8px; background: var(--cc-surface); }
.cc-callout, .cc-rule-match { padding: 10px 12px; border-radius: 8px; background: var(--cc-neutral-fill); color: var(--cc-body); font-size: 11.5px; line-height: 1.45; }
.cc-priority-assign { display: flex; align-items: center; gap: 10px; margin-top: 12px; color: var(--cc-caption); font-size: 11px; }
.cc-priority-assign select { width: auto; min-width: 130px; }
.cc-decision-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
-.cc-decision-actions button, .cc-rules button, .cc-rule-preview button, .cc-compose > button { min-height: 44px; padding: 0 13px; border: 1px solid var(--cc-line-strong); border-radius: 8px; background: var(--cc-raised); color: var(--cc-body); font: inherit; font-size: 11.5px; font-weight: 650; cursor: pointer; }
+.cc-decision-actions button, .cc-rules button, .cc-rule-preview button, .cc-compose > button, .cc-compose-actions > button { min-height: 44px; padding: 0 13px; border: 1px solid var(--cc-line-strong); border-radius: 8px; background: var(--cc-raised); color: var(--cc-body); font: inherit; font-size: 11.5px; font-weight: 650; cursor: pointer; }
.cc-decision-actions button:disabled, .cc-rules button:disabled { opacity: .5; cursor: not-allowed; }
.cc-decision-actions .cc-button-primary { border-color: var(--cc-teal-stroke-e); background: var(--cc-teal-text); color: var(--cc-raised); }
.cc-discrepancy-history { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; }
@@ -1953,6 +1954,8 @@ textarea:focus-visible,
font-family: var(--cc-mono, ui-monospace, monospace);
}
.cc-settings-number:disabled { opacity: 0.55; cursor: not-allowed; }
+.cc-settings-unit { color: var(--cc-caption); font-size: 11.5px; font-weight: 600; }
+.cc-settings-hint { margin: 6px 0 0; font-size: 11.5px; color: var(--cc-caption); }
.cc-settings-rowerr { margin: 6px 0 0; font-size: 11.5px; color: var(--cc-amber-text); }
.cc-settings-reset {
padding: 6px 12px; border-radius: 8px; cursor: pointer; font: inherit;
@@ -1984,6 +1987,7 @@ textarea:focus-visible,
background: var(--cc-surface);
text-align: center;
}
+.cc-conflict-empty button { margin-top: 10px; min-height: 40px; padding: 0 14px; border: 1px solid var(--cc-line-strong); border-radius: 8px; background: var(--cc-raised); color: var(--cc-body); font: inherit; font-size: 11.5px; font-weight: 650; cursor: pointer; }
.cc-conflict-summary {
min-height: 72px;
display: flex;
diff --git a/apps/console/src/types.ts b/apps/console/src/types.ts
index 5739935..2a59856 100644
--- a/apps/console/src/types.ts
+++ b/apps/console/src/types.ts
@@ -95,6 +95,21 @@ export interface StatusSummary {
sources: SourceStatus[]
}
+/**
+ * One hit from GET /api/search — BM25F over stemmed section content
+ * (`searchConcepts` in packages/core/src/search.mjs). `layers` names every
+ * contributing layer, best-scoring first; `snippet` is pre-extracted around
+ * the matched terms, so the console never has to re-tokenize the body.
+ * Hits arrive pre-sorted by score, highest first.
+ */
+export interface SearchHit {
+ id: string
+ title: string | null
+ score: number
+ layers: string[]
+ snippet: string
+}
+
/** A source (layer) row in the graph summary. */
export interface GraphSource {
name: string
@@ -257,7 +272,7 @@ export interface ConflictResolutionRecord {
export type DiscrepancyKind = 'section_content' | 'frontmatter_value' | 'broken_link' | 'changed_after_decision'
export type DiscrepancyStatus = 'needs_review' | 'recommended' | 'auto_ready' | 'acknowledged' | 'resolved' | 'reopened' | 'blocked'
export type DiscrepancyAction = 'choose_contribution' | 'compose' | 'acknowledge'
-export type AcknowledgementReason = 'different_scopes' | 'temporary_migration' | 'source_specific_authority' | 'other'
+export type AcknowledgementReason = 'different_scopes' | 'temporary_migration' | 'source_specific_authority' | 'target_missing' | 'other'
export interface DiscrepancyContribution {
source: string
diff --git a/apps/console/src/views/Canvas.test.tsx b/apps/console/src/views/Canvas.test.tsx
index 995548d..c267e5c 100644
--- a/apps/console/src/views/Canvas.test.tsx
+++ b/apps/console/src/views/Canvas.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Concept } from '../data'
-import { computeLayout } from './Canvas'
+import { capConceptsPerLane, clampZoom, computeFitScale, computeLayout, countByLane, MAX_NODES_PER_LANE, MIN_SCALE } from './Canvas'
function concept(id: string, layer: Concept['layers'][number], dissent?: Concept['layers'][number]): Concept {
return {
@@ -36,6 +36,207 @@ describe('computeLayout', () => {
})
})
+describe('computeFitScale', () => {
+ // Fit and manual zoom used to have different floors (Fit ~0, manual 0.1):
+ // on a large cascade Fit would land far below what manual zoom could ever
+ // reach, so the first wheel notch after a Fit snapped the view back up —
+ // see "Fit and manual zoom share one floor" below for the exact repro this
+ // replaces. Fit and manual zoom now share MIN_SCALE, so a world too big to
+ // fit at that floor is *cropped*, not shrunk arbitrarily small.
+ it('floors at the shared MIN_SCALE for a world too large to fit otherwise', () => {
+ const result = computeFitScale(2000, 1200, 200_000, 100_000)
+ expect(result).not.toBeNull()
+ expect(result!.scale).toBe(MIN_SCALE)
+ })
+
+ it('still guards a not-yet-laid-out element', () => {
+ expect(computeFitScale(0, 0, 1000, 1000)).toBeNull()
+ })
+
+ // The cap (MAX_NODES_PER_LANE) is sized precisely so this never has to
+ // happen for real content — see the arithmetic in Canvas.tsx — but a
+ // shrunk viewport can still push a fully-saturated lane under the floor,
+ // and computeFitScale must degrade to "cropped" rather than "sub-pixel"
+ // when it does.
+ it('crops rather than shrinking arbitrarily small when even the floor cannot fit', () => {
+ const result = computeFitScale(600, 400, 200_000, 100_000)
+ expect(result).not.toBeNull()
+ expect(result!.scale).toBe(MIN_SCALE)
+ })
+})
+
+describe('clampZoom', () => {
+ it('floors a manual zoom at the shared MIN_SCALE', () => {
+ expect(clampZoom(0.05)).toBe(MIN_SCALE)
+ expect(clampZoom(0.001)).toBe(MIN_SCALE)
+ })
+
+ it('leaves a scale above the floor untouched', () => {
+ expect(clampZoom(0.3)).toBeCloseTo(0.3, 5)
+ })
+
+ it('still clamps at the top end', () => {
+ expect(clampZoom(50)).toBe(2)
+ })
+})
+
+describe('Fit and manual zoom share one floor (regression)', () => {
+ // This is the bug two adversarial reviewers independently confirmed on the
+ // branch: FIT_MIN_SCALE (~0) and MIN_MANUAL_SCALE (0.1) disagreed, so a Fit
+ // on a large cascade (3,000 concepts capped to 750 under the old
+ // MAX_NODES_PER_LANE=250, on a 1440x800 canvas) landed at scale 0.02296 —
+ // sub-pixel cards, a blank-looking canvas — and the very next wheel notch,
+ // in EITHER direction, clamped up to 0.1: zooming OUT magnified the view
+ // 4.4x under the cursor. The commit that introduced the split floor also
+ // deleted the test that had pinned "zoom out must not zoom in"; this
+ // restores that guarantee against the new shared floor instead.
+ it('a Fit that would drop below the shared floor gets floored there, and the next zoom-out does not jump', () => {
+ // Reconstructs the exact regression's inputs: a single lane fully
+ // saturated at the OLD per-lane cap (250), via the real layout code
+ // rather than a hand-derived worldW, so this stays honest if NODE_W/GAP_X
+ // ever change.
+ const saturated = Array.from({ length: 250 }, (_, i) => concept(`personal-${i}`, 'personal'))
+ const { worldW, worldH } = computeLayout(saturated)
+
+ const fit = computeFitScale(1440, 800, worldW, worldH)!
+ expect(fit).not.toBeNull()
+ expect(fit.scale).toBe(MIN_SCALE) // floored, not sub-pixel
+
+ // The zoom() handler's "zoom out" factor (1/1.2) applied to the just-fitted
+ // scale, then run through the same clamp a wheel-out or the − button uses.
+ const zoomedOut = clampZoom(fit.scale * (1 / 1.2))
+ expect(zoomedOut).toBe(fit.scale) // floored again, at the SAME value — no jump
+ })
+
+ // The cap this branch ships with (MAX_NODES_PER_LANE) is chosen so this
+ // scenario above cannot actually occur for content the app renders: a
+ // fully-saturated lane fits comfortably above the floor on a normal
+ // desktop viewport, so Fit never needs flooring and the first zoom action
+ // after it is a plain, un-clamped zoom.
+ it('a fully-saturated lane at the current cap fits above the floor on a normal desktop viewport', () => {
+ const saturated = Array.from({ length: MAX_NODES_PER_LANE }, (_, i) => concept(`personal-${i}`, 'personal'))
+ const { worldW, worldH } = computeLayout(saturated)
+
+ for (const width of [1280, 1440]) {
+ const fit = computeFitScale(width, 800, worldW, worldH)!
+ expect(fit.scale).toBeGreaterThan(MIN_SCALE)
+ }
+ })
+})
+
+describe('capConceptsPerLane', () => {
+ function many(layer: Concept['layers'][number], count: number): Concept[] {
+ return Array.from({ length: count }, (_, i) => concept(`${layer}-${i}`, layer))
+ }
+
+ it('passes a small cascade through unchanged', () => {
+ const input = [...many('personal', 3), ...many('team', 2)]
+ const result = capConceptsPerLane(input, 250)
+ expect(result.shown).toBe(5)
+ expect(result.total).toBe(5)
+ expect(result.concepts).toHaveLength(5)
+ })
+
+ it('slices each lane independently and reports per-lane + total counts', () => {
+ const input = [...many('personal', 400), ...many('team', 10), ...many('company', 5)]
+ const result = capConceptsPerLane(input, 250)
+ expect(result.laneCounts.personal).toEqual({ shown: 250, total: 400 })
+ expect(result.laneCounts.team).toEqual({ shown: 10, total: 10 })
+ expect(result.laneCounts.company).toEqual({ shown: 5, total: 5 })
+ expect(result.shown).toBe(250 + 10 + 5)
+ expect(result.total).toBe(415)
+ expect(result.concepts).toHaveLength(result.shown)
+ })
+
+ it('keeps the first N in incoming order (no cheap per-concept date to sort by)', () => {
+ const input = many('personal', 5)
+ const result = capConceptsPerLane(input, 3)
+ expect(result.concepts.map((c) => c.id)).toEqual(['personal-0', 'personal-1', 'personal-2'])
+ })
+
+ it('defaults to MAX_NODES_PER_LANE when no max is given', () => {
+ const input = many('personal', MAX_NODES_PER_LANE + 5)
+ const result = capConceptsPerLane(input)
+ expect(result.laneCounts.personal).toEqual({ shown: MAX_NODES_PER_LANE, total: MAX_NODES_PER_LANE + 5 })
+ })
+
+ it('does not crash on a concept with an empty layers array', () => {
+ // primaryLayer(c) is undefined for `layers: []` (sort()[0] of an empty
+ // array), so indexing straight into the byLane record and calling .push
+ // on the result used to throw and unmount the whole Cascade view.
+ const orphan: Concept = { ...concept('orphan', 'personal'), layers: [] }
+ let result: ReturnType | undefined
+ expect(() => { result = capConceptsPerLane([orphan]) }).not.toThrow()
+ expect(result!.concepts.map((c) => c.id)).toEqual(['orphan'])
+ expect(result!.laneCounts.company).toEqual({ shown: 1, total: 1 })
+ })
+})
+
+describe('countByLane', () => {
+ it('counts each concept into its primary lane', () => {
+ const input = [
+ concept('p1', 'personal'), concept('p2', 'personal'),
+ concept('t1', 'team'),
+ concept('c1', 'company'),
+ ]
+ expect(countByLane(input)).toEqual({ personal: 2, team: 1, company: 1 })
+ })
+
+ // F7: capConceptsPerLane falls back a layerless concept (empty `layers`
+ // array — primaryLayer(c) is undefined there) into the company lane's
+ // rendered cards. Before this fix, this count used `counts[primaryLayer(c)]
+ // += 1` directly, which wrote a stray "undefined" key and left company's
+ // header total not counting a concept that nonetheless occupied one of its
+ // rendered slots — so the header undercounted what was actually on screen.
+ it('falls back a layerless concept to company, matching capConceptsPerLane\'s rendering fallback', () => {
+ const orphan: Concept = { ...concept('orphan', 'personal'), layers: [] }
+ const counts = countByLane([orphan, concept('c1', 'company')])
+ expect(counts).toEqual({ personal: 0, team: 0, company: 2 })
+ expect(Object.keys(counts)).not.toContain('undefined')
+ })
+})
+
+describe('lane header honesty (F3)', () => {
+ // vi.doMock (not vi.mock) so this stays scoped to a resetModules() import —
+ // the legend test below needs the real StoreProvider, and a file-level mock
+ // of '../store' would break it.
+ afterEach(() => {
+ vi.doUnmock('../store')
+ vi.resetModules()
+ })
+
+ it('names the real source and level behind a lane instead of the static trio', async () => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.resetModules()
+ vi.doMock('../store', () => {
+ const noop = () => {}
+ const state = {
+ mode: 'live', concepts: [], conflicts: [], sources: [{ name: 'messy-vault', layer: 'team', level: 1 }],
+ setSelConcept: noop, setSelConflict: noop, setView: noop,
+ }
+ const useState = () => state
+ return { useStore: useState, useStoreData: useState, useStoreNav: useState, useStoreInput: useState }
+ })
+ const { act } = await import('react')
+ const { createRoot } = await import('react-dom/client')
+ const { Canvas } = await import('./Canvas')
+
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ await act(async () => root.render())
+
+ expect(container.textContent).toContain('messy-vault')
+ // The team lane's round badge shows the real level (1), not the static 2 —
+ // and the lane's static "runbooks, decisions, system docs" blurb is gone,
+ // replaced by the source name.
+ expect(container.textContent).not.toContain('runbooks, decisions, system docs')
+
+ await act(async () => root.unmount())
+ container.remove()
+ })
+})
+
describe('the canvas legend', () => {
it('stays translucent, because the graph moves underneath it', async () => {
// Not a style preference: the legend is absolutely positioned over the
diff --git a/apps/console/src/views/Canvas.tsx b/apps/console/src/views/Canvas.tsx
index df77730..4cdfdb7 100644
--- a/apps/console/src/views/Canvas.tsx
+++ b/apps/console/src/views/Canvas.tsx
@@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { C, css, lc, MONO, type LayerId } from '../theme'
-import { layerLevel, layers, type Concept } from '../data'
+import { layerLevel, layerName, layers, type Concept } from '../data'
import { LayerChip } from '../components/LayerChip'
import { ConceptDetail } from '../components/ConceptDetail'
import { useStoreData } from '../store'
@@ -13,6 +13,56 @@ const LANE_TOP = 60, LANE_H = 196, LANE_GAP = 16
const LANE_INNER = LANE_H - LANE_GAP
const NODE_DY = 46, GHOST_DY = 62
+// A real-DOM canvas with no virtualization: every node is a live element in
+// the pan/zoom transform, so a vault with thousands of concepts in one lane
+// stops being interactive well before it stops being legible. Cap what's
+// rendered rather than let the browser choke on it — Knowledge (unpaginated,
+// list-based) is where the rest is still reachable.
+//
+// The cap is sized so a fully-saturated lane's Fit never needs to drop below
+// MIN_SCALE on a normal desktop viewport (see MIN_SCALE below for both
+// numbers' arithmetic) — a smaller cap than the DOM could technically still
+// render, chosen for legibility rather than raw capacity. The "Showing N of
+// M · Browse everything in Knowledge" banner already tells the user this is
+// a partial view, which is what makes a smaller cap honest rather than a
+// silent loss.
+//
+// Worst case for layout is every concept landing in ONE lane: nodes that
+// share a primary lane always conflict (computeLayout never reuses a column
+// between two nodes with the same occupied-layer set), so N nodes in a
+// single lane cost N columns — there is no sharing to fall back on. That
+// makes worldW a function of N alone:
+// worldW(N) = START_X + N*NODE_W + (N-1)*GAP_X + END_X = 134 + 242*N
+// Solving worldW(N) * MIN_SCALE <= (viewport width - 48) for the narrow end
+// of a normal desktop viewport (1280px, from the "~1280-1440px" range this
+// was measured against) gives N <= 24.9 — MAX_NODES_PER_LANE=25 lands at
+// scale 0.1992 (just under the floor); 24 lands at 0.2073, comfortably
+// above it at 1280px and with more room at 1440px. worldH is fixed at 648px
+// (3 lanes) regardless of N, so height is never the binding constraint here.
+export const MAX_NODES_PER_LANE = 24
+// The ONE floor shared by Fit and manual zoom (wheel / +/- controls). These
+// used to differ (Fit ~0, manual 0.1): a 3,000-concept vault's Fit landed at
+// scale 0.023 — cards rendered sub-pixel, the screenshot was a blank canvas
+// with one faint dashed line — and the first wheel notch in EITHER direction
+// then clamped up to the manual floor, magnifying the view 4x under the
+// cursor. Splitting the floors again would only resurrect that: the fix is
+// one shared number, low enough to still be a legible discrete card and no
+// lower.
+//
+// Derivation: a card narrower than ~40 screen px reads as a sliver, not a
+// rectangle — at MAX_NODES_PER_LANE's fully-saturated worst case, NODE_W
+// (214px) needs to render at >= ~40px for the card to be a visible,
+// color-coded shape (border + lane accent) rather than noise:
+// 40 / 214 ≈ 0.187, rounded up to 0.2 for a clean shared constant.
+// With the cap above, a saturated lane's Fit lands at ~0.207-0.234 (see its
+// derivation) — comfortably above 0.2, so in practice Fit never needs the
+// floor at all; it exists purely as the shared backstop both Fit and manual
+// zoom respect, so neither can ever clamp past the other.
+export const MIN_SCALE = 0.2
+const MAX_SCALE = 2
+
+const NUM = new Intl.NumberFormat()
+
// lanes top→bottom: highest precedence (Personal) on top so "up = wins"
const LANE_ORDER: LayerId[] = ['personal', 'team', 'company']
const laneIndex = (id: LayerId) => LANE_ORDER.indexOf(id)
@@ -20,6 +70,67 @@ const laneY = (i: number) => LANE_TOP + i * LANE_H
const primaryLayer = (c: Concept): LayerId =>
c.layers.slice().sort((a, b) => layerLevel(b) - layerLevel(a))[0]
+/** Fit scale/pan for a `cw`×`ch` viewport around `worldW`×`worldH` content, or
+ * `null` while the element is not yet laid out (see the caller's guard). */
+export function computeFitScale(cw: number, ch: number, worldW: number, worldH: number) {
+ if (cw < 40 || ch < 40) return null
+ const scale = Math.max(MIN_SCALE, Math.min(1, (cw - 48) / worldW, (ch - 48) / worldH))
+ return { scale, tx: (cw - worldW * scale) / 2, ty: Math.max(24, (ch - worldH * scale) / 2) }
+}
+
+/** Clamp a manual zoom (wheel or +/− button) to the app's zoom range. */
+export function clampZoom(scale: number): number {
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
+}
+
+export interface LaneCapResult {
+ concepts: Concept[]
+ shown: number
+ total: number
+ laneCounts: Record
+}
+
+/**
+ * Cap how many concepts land on the canvas per lane. Selection keeps the
+ * first N in resolve-all order: `Concept` carries no single "last updated"
+ * timestamp of its own (only per-section dates), and scanning every section
+ * of every concept just to sort would undercut the point of a cheap cap at
+ * the scale this exists for.
+ */
+export function capConceptsPerLane(concepts: Concept[], max = MAX_NODES_PER_LANE): LaneCapResult {
+ const byLane: Record = { company: [], team: [], personal: [] }
+ // primaryLayer(c) is undefined for a concept with an empty `layers` array
+ // (sort()[0] of []) despite its declared LayerId return type — a chaos
+ // input computeLayout already tolerates (it lays such a concept out off
+ // canvas via laneIndex's -1). Here it indexed straight into `byLane` and
+ // called .push on the resulting undefined, unmounting the whole view.
+ // Falling back to the company lane matches that existing tolerance.
+ for (const c of concepts) (byLane[primaryLayer(c)] ?? byLane.company).push(c)
+ const laneCounts = {} as Record
+ const out: Concept[] = []
+ for (const id of LANE_ORDER) {
+ const all = byLane[id]
+ const shown = all.slice(0, max)
+ laneCounts[id] = { shown: shown.length, total: all.length }
+ out.push(...shown)
+ }
+ return { concepts: out, shown: out.length, total: concepts.length, laneCounts }
+}
+
+/**
+ * Full per-lane concept counts — honest even while the canvas only renders
+ * the capped subset (capConceptsPerLane). Shares capConceptsPerLane's
+ * company fallback for a concept with an empty `layers` array (primaryLayer
+ * returns undefined there): without it, such a concept indexed a stray
+ * "undefined" key here — consuming a company slot in the capped render
+ * while never appearing in company's header total.
+ */
+export function countByLane(concepts: Concept[]): Record {
+ const counts: Record = { company: 0, team: 0, personal: 0 }
+ for (const c of concepts) counts[primaryLayer(c) ?? 'company'] += 1
+ return counts
+}
+
interface NodePos { c: Concept; x: number; y: number; conflict: boolean }
interface GhostPos { key: string; parent: NodePos; layer: LayerId; value: string; x: number; y: number }
@@ -72,14 +183,27 @@ function edgePath(x1: number, y1: number, x2: number, y2: number) {
}
function CanvasInner({ keyboardSuspended = false }: { keyboardSuspended?: boolean }) {
- const { setSelConcept, setSelConflict, setView, conflicts, concepts } = useStoreData()
+ const { setSelConcept, setSelConflict, setView, conflicts, concepts, sources, mode } = useStoreData()
+ // Capped before layout: a real-DOM canvas with no virtualization stops
+ // being usable well before thousands of nodes finish laying out. Ghost
+ // (dissent) cards derive from `nodes` below, so they respect the cap too —
+ // there is no separate ghost list to cap.
+ const capped = useMemo(() => capConceptsPerLane(concepts), [concepts])
// Memoized: pan/zoom re-renders every pointermove — don't re-lay-out for those.
- const { nodes, ghosts, worldW, worldH } = useMemo(() => computeLayout(concepts), [concepts])
- const laneCounts = useMemo(() => {
- const counts: Record = { company: 0, team: 0, personal: 0 }
- for (const c of concepts) counts[primaryLayer(c)] += 1
- return counts
- }, [concepts])
+ const { nodes, ghosts, worldW, worldH } = useMemo(() => computeLayout(capped.concepts), [capped.concepts])
+ // Full counts, not the capped subset — the lane header's "N concepts" stays
+ // an honest total even while the canvas itself only renders some of them.
+ const laneCounts = useMemo(() => countByLane(concepts), [concepts])
+ // Real (source name, level) pairs behind each lane, for honest lane headers
+ // (Fix F3): demo mode's sources are already the canonical company/team/
+ // personal trio, so this reduces to the static labels there — the fallback
+ // below only changes what a live, non-canonical cascade renders.
+ const laneSourceRows = useMemo(() => {
+ const rows: Record = { company: [], team: [], personal: [] }
+ if (mode === 'demo') return rows
+ for (const s of sources) rows[s.layer].push({ name: s.name, level: s.level })
+ return rows
+ }, [sources, mode])
const wrapRef = useRef(null)
const [view, setViewT] = useState({ tx: 40, ty: 20, scale: 1 })
@@ -94,13 +218,12 @@ function CanvasInner({ keyboardSuspended = false }: { keyboardSuspended?: boolea
const fit = useCallback(() => {
const el = wrapRef.current
if (!el) return
- const cw = el.clientWidth, ch = el.clientHeight
- // Guard against a not-yet-laid-out element (async data can populate before
- // layout settles): a zero width would yield a negative scale that never
- // self-corrects, collapsing the whole canvas to a speck.
- if (cw < 40 || ch < 40) return
- const scale = Math.max(0.2, Math.min(1, (cw - 48) / worldW, (ch - 48) / worldH))
- setViewT({ scale, tx: (cw - worldW * scale) / 2, ty: Math.max(24, (ch - worldH * scale) / 2) })
+ // computeFitScale's own guard covers a not-yet-laid-out element (async
+ // data can populate before layout settles): a zero width would otherwise
+ // yield a negative scale that never self-corrects, collapsing the canvas
+ // to a speck.
+ const next = computeFitScale(el.clientWidth, el.clientHeight, worldW, worldH)
+ if (next) setViewT(next)
}, [worldW, worldH])
useLayoutEffect(() => { fit() }, [fit])
@@ -125,7 +248,7 @@ function CanvasInner({ keyboardSuspended = false }: { keyboardSuspended?: boolea
const rect = el.getBoundingClientRect()
const px = e.clientX - rect.left, py = e.clientY - rect.top
setViewT((v) => {
- const next = Math.min(2, Math.max(0.4, v.scale * Math.exp(-e.deltaY * 0.0015)))
+ const next = clampZoom(v.scale * Math.exp(-e.deltaY * 0.0015))
const wx = (px - v.tx) / v.scale, wy = (py - v.ty) / v.scale
return { scale: next, tx: px - wx * next, ty: py - wy * next }
})
@@ -185,7 +308,7 @@ function CanvasInner({ keyboardSuspended = false }: { keyboardSuspended?: boolea
}, [keyboardSuspended, openId, wideInspector])
const zoom = (dir: number) => setViewT((v) => {
const el = wrapRef.current!, px = el.clientWidth / 2, py = el.clientHeight / 2
- const next = Math.min(2, Math.max(0.4, v.scale * (dir > 0 ? 1.2 : 1 / 1.2)))
+ const next = clampZoom(v.scale * (dir > 0 ? 1.2 : 1 / 1.2))
const wx = (px - v.tx) / v.scale, wy = (py - v.ty) / v.scale
return { scale: next, tx: px - wx * next, ty: py - wy * next }
})
@@ -205,18 +328,27 @@ function CanvasInner({ keyboardSuspended = false }: { keyboardSuspended?: boolea
style={{ position: 'absolute', top: 0, left: 0, bottom: 0, right: wideInspector && openConceptObj ? 360 : 0, cursor: dragging ? 'grabbing' : 'grab', touchAction: 'none' }}
>
- {/* lane backgrounds + labels */}
+ {/* lane backgrounds + labels — real levels and source names behind
+ each lane, not the static trio (F3): a level-1 source that ranks
+ into 'team' should say so, and two sources sharing a lane should
+ both be named rather than only the lane's generic blurb. */}
{LANE_ORDER.map((id, i) => {
const L = layers.find((l) => l.id === id)!
const col = lc(id)
+ const rows = laneSourceRows[id]
+ const levels = [...new Set(rows.map((r) => r.level))].sort((a, b) => a - b)
+ const conventional = rows.some((r) => r.name === id)
+ const badgeText = levels.length ? levels.join('/') : String(L.level)
+ const primary = conventional || rows.length === 0 ? L.name : `L${levels.join('/')}`
+ const detail = rows.length ? rows.map((r) => r.name).join(', ') : L.members
return (
+ {/* cap banner — a real-DOM canvas with no virtualization stops being
+ usable well before a large cascade finishes laying out (F7); this
+ says what's hidden and where the rest still is. */}
+ {capped.shown < capped.total && (
+
+ Showing {NUM.format(capped.shown)} of {NUM.format(capped.total)}
+
+
diff --git a/apps/console/src/views/Concepts.test.tsx b/apps/console/src/views/Concepts.test.tsx
new file mode 100644
index 0000000..e2b07de
--- /dev/null
+++ b/apps/console/src/views/Concepts.test.tsx
@@ -0,0 +1,314 @@
+// @vitest-environment jsdom
+// The Knowledge: Concepts list and its detail panel — including the
+// zero-section dead end (F18): a concept with no sections used to render an
+// empty panel with no explanation and no way out.
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Concepts } from './Concepts'
+import type { Concept } from '../data'
+import type { SearchHit } from '../types'
+
+const mocks = vi.hoisted(() => ({ useStore: vi.fn(), useLayerFiles: vi.fn() }))
+vi.mock('../store', () => ({ useStore: mocks.useStore, useStoreData: mocks.useStore, useStoreNav: mocks.useStore, useStoreInput: mocks.useStore }))
+vi.mock('../layer-files', () => ({
+ filesRevalidation: () => 'rev',
+ useLayerFiles: mocks.useLayerFiles,
+}))
+
+let container: HTMLDivElement
+let root: Root
+
+function storeWith(concepts: Concept[], selConcept: string, openFilesScope = vi.fn()) {
+ return {
+ mode: 'demo', sources: [], reloadKey: 0,
+ query: '', concepts, selConcept,
+ setSelConcept: vi.fn(), openFilesScope,
+ }
+}
+
+function populated(): Concept {
+ return {
+ id: 'decisions/primary-db', title: 'Primary database', type: 'decision',
+ layers: ['personal'], contributorLayers: ['personal'],
+ sections: [{ name: 'Choice', winner: 'personal', sourceLayer: 'personal', value: 'SingleStore.', updated: '2026-01-01' }],
+ }
+}
+
+function empty(): Concept {
+ return {
+ id: 'decisions/empty-note', title: 'Empty note', type: 'note',
+ layers: ['personal'], contributorLayers: ['personal'], sections: [],
+ }
+}
+
+function button(label: string): HTMLButtonElement | undefined {
+ return Array.from(container.querySelectorAll('button')).find((item) => item.textContent === label)
+}
+
+beforeEach(() => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ mocks.useLayerFiles.mockReturnValue({ layers: [] })
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+})
+
+describe('a concept with no sections', () => {
+ it('shows a quiet note and an Open file affordance instead of an empty panel', async () => {
+ mocks.useStore.mockReturnValue(storeWith([empty()], 'decisions/empty-note'))
+ mocks.useLayerFiles.mockReturnValue({
+ layers: [{
+ layer: 'personal', kind: 'files', root: '/vault', fileCount: 1, truncated: false,
+ files: [{ path: 'personal/decisions/empty-note.md', name: 'empty-note.md', rel: 'decisions/empty-note.md', ext: '.md', kind: 'text', markdown: true }],
+ }],
+ })
+ await act(async () => root.render())
+
+ expect(container.textContent).toContain('This concept has no sections — the file may be empty.')
+ expect(button('Open file')).toBeTruthy()
+ })
+
+ it('falls back to a Files-tab affordance when no file is listed for the winning contributor', async () => {
+ const openFilesScope = vi.fn()
+ mocks.useStore.mockReturnValue(storeWith([empty()], 'decisions/empty-note', openFilesScope))
+ await act(async () => root.render())
+
+ const browse = button('Browse personal in Files')
+ expect(browse).toBeTruthy()
+ await act(async () => browse?.click())
+ expect(openFilesScope).toHaveBeenCalledWith('personal')
+ })
+
+ it('marks the concept "empty" in the list so it is triageable without opening it', async () => {
+ mocks.useStore.mockReturnValue(storeWith([populated(), empty()], 'decisions/primary-db'))
+ await act(async () => root.render())
+
+ const rows = Array.from(container.querySelectorAll('.cc-navigator-detail > div > button'))
+ const emptyRow = rows.find((row) => row.textContent?.includes('Empty note'))
+ const populatedRow = rows.find((row) => row.textContent?.includes('Primary database'))
+ expect(emptyRow?.textContent).toContain('empty')
+ expect(populatedRow?.textContent).not.toContain('empty')
+ })
+})
+
+// WP-G: Knowledge search calls the engine's full-text /api/search in live
+// mode, debounced, while the instant title/id substring filter (above) keeps
+// serving the result until the engine answers or fails.
+describe('Knowledge search (live mode)', () => {
+ function liveStoreWith(concepts: Concept[], query: string, search = vi.fn()) {
+ return {
+ mode: 'live', sources: [], reloadKey: 0,
+ query, concepts, selConcept: concepts[0]?.id ?? '',
+ setSelConcept: vi.fn(), openFilesScope: vi.fn(), search,
+ }
+ }
+
+ function rows(): HTMLButtonElement[] {
+ return Array.from(container.querySelectorAll('.cc-navigator-detail > div > button'))
+ }
+
+ beforeEach(() => { vi.useFakeTimers() })
+ afterEach(() => { vi.useRealTimers() })
+
+ it('debounces the query before calling the engine search', async () => {
+ const search = vi.fn().mockResolvedValue([])
+ mocks.useStore.mockReturnValue(liveStoreWith([populated()], 'singlestore', search))
+ await act(async () => root.render())
+
+ expect(search).not.toHaveBeenCalled()
+ await act(async () => { await vi.advanceTimersByTimeAsync(249) })
+ expect(search).not.toHaveBeenCalled()
+ await act(async () => { await vi.advanceTimersByTimeAsync(1) })
+ expect(search).toHaveBeenCalledTimes(1)
+ expect(search).toHaveBeenCalledWith('singlestore')
+ })
+
+ it('narrows and reorders the list to the engine hits once they land', async () => {
+ const a = populated()
+ const b: Concept = { ...populated(), id: 'decisions/other', title: 'Other decision' }
+ const search = vi.fn().mockResolvedValue([{ id: b.id, title: b.title, score: 5, layers: ['personal'], snippet: '' }])
+ mocks.useStore.mockReturnValue(liveStoreWith([a, b], 'other', search))
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+
+ const list = rows()
+ expect(list).toHaveLength(1)
+ expect(list[0].textContent).toContain(b.title)
+ })
+
+ it('shows a content-search hint, not the title-only one, when the engine finds nothing', async () => {
+ const search = vi.fn().mockResolvedValue([])
+ mocks.useStore.mockReturnValue(liveStoreWith([populated()], 'nomatch', search))
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+
+ expect(container.textContent).toContain('No matches in titles or content.')
+ })
+
+ it('falls back to the substring filter silently when the engine call fails', async () => {
+ // The store's search() action never throws — a failed engine call
+ // resolves to null, which is exactly what this exercises.
+ const search = vi.fn().mockResolvedValue(null)
+ mocks.useStore.mockReturnValue(liveStoreWith([populated()], 'primary', search))
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+
+ // Substring match on the title still renders; no error, no empty state.
+ expect(rows()).toHaveLength(1)
+ expect(container.textContent).toContain('Primary database')
+ })
+
+ it('never calls the engine search in demo mode', async () => {
+ const search = vi.fn()
+ mocks.useStore.mockReturnValue({ ...liveStoreWith([populated()], 'primary', search), mode: 'demo' })
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+
+ expect(search).not.toHaveBeenCalled()
+ // Demo mode still gets the plain substring result.
+ expect(rows()).toHaveLength(1)
+ })
+
+ // FIX 1: the engine has no prefix matching (BM25F over whole stemmed
+ // tokens), so a mid-word query the engine misses must not blank a list the
+ // substring filter would still populate.
+ it('keeps a substring match visible when the engine answers empty on a partial word', async () => {
+ const search = vi.fn().mockResolvedValue([])
+ mocks.useStore.mockReturnValue(liveStoreWith([populated()], 'prim', search))
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+
+ expect(search).toHaveBeenCalledWith('prim')
+ expect(rows()).toHaveLength(1)
+ expect(container.textContent).toContain('Primary database')
+ expect(container.textContent).not.toContain('No matching concepts')
+ })
+
+ // FIX 2: engineHits from the PREVIOUS query must not survive a query
+ // change — only the substring filter (recomputed synchronously) should
+ // render until the new debounced answer lands.
+ it('clears stale engine hits as soon as the query changes, before the new answer lands', async () => {
+ const a = populated()
+ const b: Concept = { ...populated(), id: 'decisions/other', title: 'Other decision' }
+ let resolveSecond: (hits: SearchHit[]) => void = () => {}
+ const search = vi.fn()
+ .mockResolvedValueOnce([{ id: a.id, title: a.title, score: 5, layers: ['personal'], snippet: '' }])
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
+ const store = liveStoreWith([a, b], 'primary', search)
+ mocks.useStore.mockReturnValue(store)
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining('Primary database')])
+
+ // Point the mocked store at a new query — `Concepts` is a props-less
+ // `memo`, so a second `root.render()` call would bail out without ever
+ // re-invoking it (verified: an external value change alone never
+ // reaches a props-less memoized component here — only the component's
+ // OWN state can force it to read the store hooks again). Dispatching the
+ // close-detail event it already listens for triggers exactly that kind
+ // of internal state update, forcing it to re-render and read the new
+ // query — the same thing a real query keystroke does via context in the
+ // live app.
+ mocks.useStore.mockReturnValue({ ...store, query: 'other' })
+ await act(async () => { window.dispatchEvent(new Event('contextcake:close-detail')) })
+
+ // `a`'s stale hit from the first search must be gone immediately — well
+ // before the new debounced search resolves. (The detail panel on the
+ // right keeps showing whatever is still selected — that's unrelated to
+ // this bug, so the assertion is scoped to the list rows.)
+ const list = rows().map((r) => r.textContent ?? '')
+ expect(list.some((text) => text.includes('Primary database'))).toBe(false)
+ expect(list.some((text) => text.includes('Other decision'))).toBe(true)
+
+ resolveSecond([])
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+ })
+
+ // FIX 5: the effect used to depend on both `q` (trimmed+lowercased) and
+ // `query` (raw), so a trailing space or a capitalization change — neither
+ // of which moves `q` — still re-ran it, and its setEngineHits(null) reset
+ // fired a second, identical search while dropping the list to substring
+ // order in between. Measured before the fix: 2 search calls for one
+ // meaningful query, with the ranked list blanked between them.
+ it('does not re-fire the search, or blank the ranked list, on a keystroke that leaves the normalized query unchanged', async () => {
+ const a = populated()
+ const b: Concept = { ...populated(), id: 'decisions/other', title: 'Other decision' }
+ // Engine ranks b above a — a different order than substring/insertion
+ // order — so a reset back to the substring list would be observable.
+ const search = vi.fn().mockResolvedValue([
+ { id: b.id, title: b.title, score: 5, layers: ['personal'], snippet: '' },
+ { id: a.id, title: a.title, score: 1, layers: ['personal'], snippet: '' },
+ ])
+ const store = liveStoreWith([a, b], 'alpha', search)
+ mocks.useStore.mockReturnValue(store)
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+ expect(search).toHaveBeenCalledTimes(1)
+ expect(rows().map((r) => r.textContent)).toEqual([
+ expect.stringContaining(b.title),
+ expect.stringContaining(a.title),
+ ])
+
+ for (const nextQuery of ['alpha ', 'ALPHA']) {
+ mocks.useStore.mockReturnValue({ ...store, query: nextQuery })
+ await act(async () => { window.dispatchEvent(new Event('contextcake:close-detail')) })
+ // Still the engine's ranked order, immediately — never reset to
+ // substring order (which would be empty here) in between.
+ expect(rows().map((r) => r.textContent)).toEqual([
+ expect.stringContaining(b.title),
+ expect.stringContaining(a.title),
+ ])
+ expect(search).toHaveBeenCalledTimes(1)
+ }
+ })
+
+ // FIX 6: the union of engine hits + substring matches is right (it keeps
+ // partial words alive — see the FIX 1 test above), but when the engine
+ // answers precisely, its one ranked hit rendered visually indistinguishable
+ // from the substring-only rows beneath it.
+ describe('Top matches / Also contains divider', () => {
+ it('shows both labels when the engine half and the substring-only half are both non-empty', async () => {
+ const a = populated()
+ const b: Concept = { ...populated(), id: 'decisions/other-primary', title: 'Other primary' }
+ // The engine answers with only `a`; `b` reaches the list purely via the
+ // substring filter (its id/title also contain "primary").
+ const search = vi.fn().mockResolvedValue([{ id: a.id, title: a.title, score: 5, layers: ['personal'], snippet: '' }])
+ mocks.useStore.mockReturnValue(liveStoreWith([a, b], 'primary', search))
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+
+ expect(rows()).toHaveLength(2)
+ expect(container.textContent).toContain('Top matches')
+ expect(container.textContent).toContain('Also contains')
+ })
+
+ it('hides both labels when the engine half is empty (substring-only)', async () => {
+ const search = vi.fn().mockResolvedValue([])
+ mocks.useStore.mockReturnValue(liveStoreWith([populated()], 'primary', search))
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+
+ expect(rows()).toHaveLength(1)
+ expect(container.textContent).not.toContain('Top matches')
+ expect(container.textContent).not.toContain('Also contains')
+ })
+
+ it('hides both labels when the engine half already covers every row (nothing substring-only left)', async () => {
+ const a = populated()
+ const search = vi.fn().mockResolvedValue([{ id: a.id, title: a.title, score: 5, layers: ['personal'], snippet: '' }])
+ mocks.useStore.mockReturnValue(liveStoreWith([a], 'primary', search))
+ await act(async () => root.render())
+ await act(async () => { await vi.advanceTimersByTimeAsync(250) })
+
+ expect(rows()).toHaveLength(1)
+ expect(container.textContent).not.toContain('Top matches')
+ expect(container.textContent).not.toContain('Also contains')
+ })
+ })
+})
diff --git a/apps/console/src/views/Concepts.tsx b/apps/console/src/views/Concepts.tsx
index a8de76d..f5bfac4 100644
--- a/apps/console/src/views/Concepts.tsx
+++ b/apps/console/src/views/Concepts.tsx
@@ -1,16 +1,84 @@
-import { memo, useEffect, useRef, useState } from 'react'
+import { Fragment, memo, useEffect, useRef, useState } from 'react'
import { C, css, conceptTypeStyle, MONO } from '../theme'
import { LayerChip } from '../components/LayerChip'
import { ConceptDetail } from '../components/ConceptDetail'
import { useDetailSurface } from '../components/useDetailSurface'
import { useStoreData, useStoreInput, useStoreNav } from '../store'
+import type { Concept } from '../data'
+import type { SearchHit } from '../types'
+
+/** How long a keystroke waits before it becomes an /api/search request. */
+const SEARCH_DEBOUNCE_MS = 250
function ConceptsInner() {
- const { setSelConcept, concepts } = useStoreData()
+ const { setSelConcept, concepts, mode, search } = useStoreData()
const { selConcept } = useStoreNav()
const { query } = useStoreInput()
const q = query.trim().toLowerCase()
- const list = concepts.filter((c) => !q || `${c.title} ${c.id}`.toLowerCase().includes(q))
+ const substringList = concepts.filter((c) => !q || `${c.title} ${c.id}`.toLowerCase().includes(q))
+
+ // Engine full-text search (live mode only). `null` means "no answer to show
+ // yet" — either nothing has been typed, the debounced request is still in
+ // flight, or the engine failed/is too old — and the substring filter above
+ // is what renders in every one of those cases. A non-null array (possibly
+ // empty) is the engine's own answer. It is reset to `null` at the top of
+ // every run of this effect — not only when the query empties — so a query
+ // change immediately falls back to the substring list instead of rendering
+ // the PREVIOUS query's hits for the debounce window + round-trip.
+ const [engineHits, setEngineHits] = useState(null)
+ useEffect(() => {
+ setEngineHits(null)
+ if (mode !== 'live' || !q) return
+ let cancelled = false
+ const timer = setTimeout(() => {
+ void search(query.trim()).then((hits) => { if (!cancelled) setEngineHits(hits) })
+ }, SEARCH_DEBOUNCE_MS)
+ return () => { cancelled = true; clearTimeout(timer) }
+ // `query` (raw) is read inside, deliberately not a dependency: depending
+ // on both `q` (trimmed+lowercased) and `query` re-ran this — and its
+ // setEngineHits(null) reset — on a trailing-space or capitalization-only
+ // keystroke that changes `query` but not the normalized search term,
+ // re-firing an identical search and blanking the ranked list in between.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mode, q, search])
+
+ // A hit for a concept not in the loaded list is skipped rather than
+ // rendered as a dead row — resolve-all supplies the full set, so this only
+ // happens for a concept still resolving in the background.
+ const usingEngine = mode === 'live' && q !== '' && engineHits !== null
+ // Union, not replace: the engine is BM25F over whole stemmed tokens with no
+ // prefix matching, so typing "primary" one keystroke at a time returns zero
+ // hits until the word completes ("prim"/"prima"/"primar" all miss; only
+ // "primary" hits) — letting the engine's answer wholesale replace the
+ // substring list blanked the list mid-word. Engine hits render first, in
+ // the engine's own rank order (a relevance signal the substring filter
+ // doesn't have); any concept the substring filter also matches but the
+ // engine didn't return follows, deduped by id. So the "no matches" empty
+ // state below is only reachable when both lists come back empty.
+ // `rankedCount` is how many of `list`'s leading entries came from the
+ // engine half of the union above, vs. the substring-only tail — the split
+ // point the "Top matches" / "Also contains" divider renders at below.
+ let rankedCount = 0
+ const list = usingEngine
+ ? (() => {
+ const seen = new Set()
+ const merged: Concept[] = []
+ for (const hit of engineHits) {
+ const match = concepts.find((c) => c.id === hit.id)
+ if (match && !seen.has(match.id)) { seen.add(match.id); merged.push(match); rankedCount += 1 }
+ }
+ for (const c of substringList) {
+ if (!seen.has(c.id)) { seen.add(c.id); merged.push(c) }
+ }
+ return merged
+ })()
+ : substringList
+ // Only when the engine answered precisely AND the substring filter still
+ // has something extra to say — a 1-hit engine answer next to 400
+ // substring-only ids otherwise renders as 401 undifferentiated rows, with
+ // the engine's precision visually indistinguishable from the noise below
+ // it.
+ const showMatchDivider = rankedCount > 0 && rankedCount < list.length
const selCpt = concepts.find((c) => c.id === selConcept) || null
const [detailOpen, setDetailOpen] = useState(Boolean(selConcept))
const selectedButton = useRef(null)
@@ -22,14 +90,21 @@ function ConceptsInner() {
}, [])
if (concepts.length === 0) return
No concepts yet
Add or index a source to build the resolved cascade.
- if (list.length === 0) return
No matching concepts
Try a title, concept ID, or type.
+ if (list.length === 0) {
+ return (
+
+ No matching concepts
+
{usingEngine ? 'No matches in titles or content.' : 'Try a title, concept ID, or type.'}
{c.id}
@@ -49,6 +127,28 @@ function ConceptsInner() {
)
+ // Divider between the engine's ranked half and the substring-only
+ // tail (F6) — same small-caption idiom as Triage's "Why it routed
+ // here" / "Where it lands" pair. Only rendered when both halves are
+ // non-empty (showMatchDivider), so a plain substring list or an
+ // all-engine answer never grows an empty-handed label.
+ if (showMatchDivider && i === 0) {
+ return (
+
+
Top matches
+ {row}
+
+ )
+ }
+ if (showMatchDivider && i === rankedCount) {
+ return (
+
+
Also contains
+ {row}
+
+ )
+ }
+ return row
})}
diff --git a/apps/console/src/views/Conflicts.test.tsx b/apps/console/src/views/Conflicts.test.tsx
index dd5cbc2..a32913d 100644
--- a/apps/console/src/views/Conflicts.test.tsx
+++ b/apps/console/src/views/Conflicts.test.tsx
@@ -17,7 +17,7 @@ let root: Root
function storeWith(conflicts: Conflict[], selConflict: string) {
return {
- mode: 'demo', query: '',
+ mode: 'demo', query: '', setQuery: vi.fn(),
conflicts,
selConflict,
setSelConflict: vi.fn(),
@@ -90,6 +90,58 @@ const codeConflict: Conflict = {
],
}
+const listConflict: Conflict = {
+ ...freshConflict,
+ id: 'decisions/primary-db::tags',
+ sectionKey: 'tags',
+ section: 'Tags',
+ title: 'Tags — Primary database',
+ kind: 'frontmatter_value',
+ isList: true,
+ contributions: [
+ { layer: 'personal', sourceLayer: 'personal', value: '["postgres","oltp"]', updated: '2026-05-12' },
+ { layer: 'team', sourceLayer: 'team', value: '["mysql"]', updated: '2026-06-01' },
+ ],
+}
+
+const brokenLinkConflict: Conflict = {
+ ...freshConflict,
+ id: 'decisions/primary-db::choice::missing-target',
+ kind: 'broken_link',
+ target: 'decisions/missing',
+ contributions: [
+ { layer: 'personal', sourceLayer: 'personal', value: 'decisions/missing', updated: '2026-05-12' },
+ ],
+}
+
+const companyContributorConflict: Conflict = {
+ ...freshConflict,
+ id: 'other/concept::field',
+ concept: 'other/concept',
+ sectionKey: 'field',
+ section: 'Field',
+ title: 'Field — Other concept',
+ contributions: [
+ { layer: 'company', sourceLayer: 'company', value: 'Company answer.', updated: '2026-05-12' },
+ ],
+}
+
+const resolvedViaEffectiveSource: Conflict = {
+ ...freshConflict,
+ id: 'decisions/primary-db::resolved-effective',
+ sectionKey: 'resolved-effective',
+ section: 'Resolved effective',
+ title: 'Resolved effective — Primary database',
+ status: 'resolved',
+ discrepancyStatus: 'resolved',
+ effectiveSource: 'company',
+ // Deliberately no 'company' contribution in the snapshot — the filter must
+ // still match on effectiveSource, not only the contributions array (F13).
+ contributions: [
+ { layer: 'team', sourceLayer: 'team', value: 'Postgres.', updated: '2026-01-01' },
+ ],
+}
+
beforeEach(() => {
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
@@ -156,4 +208,190 @@ describe('Discrepancy Center', () => {
await act(async () => { reason.value = 'different_scopes'; reason.dispatchEvent(new Event('change', { bubbles: true })) })
expect(submit.disabled).toBe(false)
})
+
+ it('gives every disposition radio the same name so they behave as one group', async () => {
+ mocks.useStore.mockReturnValue(storeWith([safeConflict], safeConflict.id))
+ await act(async () => root.render())
+ const radios = Array.from(container.querySelectorAll('input[type="radio"]'))
+ expect(radios.length).toBeGreaterThan(1)
+ expect(new Set(radios.map((input) => input.name)).size).toBe(1)
+ expect(radios[0].name).not.toBe('')
+ })
+
+ // F22a: does ArrowDown move focus/selection between the disposition radios?
+ //
+ // jsdom cannot answer this directly — same-name radio-group arrow
+ // navigation is a browser default action implemented well below the DOM
+ // event layer (Blink's RadioInputType::handleKeydownEvent), not something
+ // triggered by dispatching a keydown event, trusted or not. A jsdom probe
+ // (`input.dispatchEvent(new KeyboardEvent('keydown', {key:'ArrowDown'}))`
+ // on a bare same-name radio pair, no framework involved) confirmed jsdom
+ // does not implement it — focus and `checked` were unchanged after the
+ // dispatch, so a "does ArrowDown move focus" assertion here would only
+ // test jsdom's fidelity, not this component.
+ //
+ // Manually verified instead, in a real Chromium tab (CDP-level keyboard
+ // input, not a synthetic DOM event) against the running app: focusing the
+ // first `cc-disposition` radio and pressing the real ArrowDown key moved
+ // both focus and `checked` to the second radio. No extra keydown handling
+ // was added — native same-name-radio-group behavior already covers this,
+ // which is exactly what the structural preconditions below exist to keep
+ // true: same `name`, no `