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 (
@@ -93,7 +129,7 @@ export function ConceptDetail({ concept }: { concept: Concept }) { {s.suppressed ? (
- suppressed by {layerName(s.winner)} + suppressed by {s.sourceLayer}
) : (
{s.value}
@@ -107,7 +143,7 @@ export function ConceptDetail({ concept }: { concept: Concept }) {
- {layerName(d.layer)} says "{d.value}" — overridden here. + {d.sourceLayer} says "{d.value}" — overridden here.
{d.updated && {d.updated}} diff --git a/apps/console/src/components/FileTree.keyboard.test.tsx b/apps/console/src/components/FileTree.keyboard.test.tsx new file mode 100644 index 0000000..db43ff5 --- /dev/null +++ b/apps/console/src/components/FileTree.keyboard.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +// +// FileTree's own keyboard handling (APG tree pattern) is implemented in JS — +// not relying on any browser default the way a native `` +// does — so unlike radio-group arrow navigation (see Conflicts.test.tsx), +// jsdom CAN verify it: `onKeyDown` is a plain React handler reacting to a +// dispatched `keydown` DOM event, trusted or not. +// +// A DOM-level accessibility audit flagged this tree as having no working +// ArrowDown navigation (F22c). Manual verification in a real browser (Chrome, +// via CDP-level keyboard input) found ArrowDown/ArrowUp/ArrowRight/ +// ArrowLeft/Home/End all already move focus correctly — ArrowDown from the +// root row landed on its first child ("company" → "company/assets"), and End +// reached the last visible row. These tests lock that in as a regression +// guard rather than changing behavior that already works. +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { buildTree, FileTree, flattenTree } from './FileTree' +import type { LayerFile, LayerFiles } from '../types' + +function file(layer: string, rel: string): LayerFile { + const name = rel.slice(rel.lastIndexOf('/') + 1) + const dot = name.lastIndexOf('.') + return { + path: `${layer}/${rel}`, name, rel, + ext: dot > 0 ? name.slice(dot) : '', + kind: 'text', markdown: rel.endsWith('.md'), + } +} + +function layer(name: string, rels: string[]): LayerFiles { + return { + layer: name, kind: 'files', root: `/${name}`, fileCount: rels.length, truncated: false, + files: rels.map((rel) => file(name, rel)), + } +} + +let container: HTMLDivElement +let root: Root + +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) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() +}) + +function activeRow(): HTMLElement | null { + return container.querySelector('[role="treeitem"][tabindex="0"]') +} + +async function press(key: string) { + await act(async () => { + activeRow()?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + }) +} + +describe('FileTree keyboard navigation', () => { + it('moves the roving tab stop with ArrowDown/ArrowUp and jumps with Home/End', async () => { + const entries = buildTree([layer('vault', ['assets/a.md', 'notes/b.md', 'notes/c.md'])]) + await act(async () => root.render( + {}} layerIds={new Map()} label="Sources" />, + )) + + expect(activeRow()?.getAttribute('title')).toBe('vault') + + await press('ArrowDown') + expect(activeRow()?.getAttribute('title')).toBe('vault/assets') + + await press('ArrowDown') + expect(activeRow()?.getAttribute('title')).toBe('vault/assets/a.md') + + await press('ArrowUp') + expect(activeRow()?.getAttribute('title')).toBe('vault/assets') + + await press('End') + const visible = flattenTree(entries, () => true) + expect(activeRow()?.getAttribute('title')).toBe(visible[visible.length - 1].path) + + await press('Home') + expect(activeRow()?.getAttribute('title')).toBe('vault') + }) + + it('expands/collapses with ArrowRight/ArrowLeft and walks into and back out of a folder', async () => { + const entries = buildTree([layer('vault', ['notes/a.md'])]) + await act(async () => root.render( + // Not expandAll: the folder starts collapsed except the layer root itself. + {}} layerIds={new Map()} label="Sources" />, + )) + + await press('ArrowDown') + expect(activeRow()?.getAttribute('title')).toBe('vault/notes') + expect(activeRow()?.getAttribute('aria-expanded')).toBe('false') + + // Collapsed dir: ArrowRight expands it in place, without moving focus. + await press('ArrowRight') + expect(activeRow()?.getAttribute('title')).toBe('vault/notes') + expect(activeRow()?.getAttribute('aria-expanded')).toBe('true') + + // Expanded dir: ArrowRight now moves into the first child. + await press('ArrowRight') + expect(activeRow()?.getAttribute('title')).toBe('vault/notes/a.md') + + // ArrowLeft from a file row walks up to its parent directory. + await press('ArrowLeft') + expect(activeRow()?.getAttribute('title')).toBe('vault/notes') + + // ArrowLeft on the (still expanded) directory collapses it in place. + await press('ArrowLeft') + expect(activeRow()?.getAttribute('title')).toBe('vault/notes') + expect(activeRow()?.getAttribute('aria-expanded')).toBe('false') + }) + + it('activates a file with Enter and Space', async () => { + const entries = buildTree([layer('vault', ['a.md', 'b.md'])]) + const selected: string[] = [] + await act(async () => root.render( + selected.push(path)} layerIds={new Map()} label="Sources" />, + )) + + await press('ArrowDown') + expect(activeRow()?.getAttribute('title')).toBe('vault/a.md') + await press('Enter') + expect(selected).toEqual(['vault/a.md']) + + await press('ArrowDown') + await press(' ') + expect(selected).toEqual(['vault/a.md', 'vault/b.md']) + }) +}) diff --git a/apps/console/src/components/IndexingSettings.test.tsx b/apps/console/src/components/IndexingSettings.test.tsx index f23f05a..fabc2a2 100644 --- a/apps/console/src/components/IndexingSettings.test.tsx +++ b/apps/console/src/components/IndexingSettings.test.tsx @@ -121,4 +121,23 @@ describe('IndexingSettings', () => { expect(container.textContent).toContain('running ContextCake engine') }) + + it('labels a millisecond setting with its unit and a human-scale reading, and leaves a count setting alone', async () => { + await act(async () => root.render()) + + // sourceBudgetMs (30000 in the default payload): unitless before this fix. + expect(field('sourceBudgetMs').nextElementSibling?.textContent).toBe('ms') + expect(container.textContent).toContain('30000 ms = 30 sec') + + // maxDocFiles is a count, not a duration — no unit, no invented reading. + expect(field('maxDocFiles').nextElementSibling).toBeNull() + expect(container.textContent).not.toContain('10000 ms') + }) + + it('updates the human-scale reading as the field is edited', async () => { + await act(async () => root.render()) + + await type(field('sourceBudgetMs'), '120000') + expect(container.textContent).toContain('120000 ms = 2 min') + }) }) diff --git a/apps/console/src/components/IndexingSettings.tsx b/apps/console/src/components/IndexingSettings.tsx index b1d3738..2969f14 100644 --- a/apps/console/src/components/IndexingSettings.tsx +++ b/apps/console/src/components/IndexingSettings.tsx @@ -18,6 +18,26 @@ function format(n: number): string { return String(n) } +/** Whether a setting's stored unit is milliseconds — its key names that, e.g. `sourceBudgetMs`. */ +function isMillisecondSetting(key: string): boolean { + return key.endsWith('Ms') +} + +/** + * "120000 ms = 2 min" — the human-scale reading beside the raw number. The + * engine's catalog (settings.mjs) supplies `label`/`help` and never a unit or + * a friendlier scale, so that reading is computed here rather than asking the + * engine to speak console-specific UI copy. + */ +function humanizeMs(ms: number): string | null { + if (!Number.isFinite(ms) || ms < 0) return null + const round = (n: number) => Math.round(n * 10) / 10 + if (ms < 1000) return `${ms} ms` + if (ms < 60_000) return `${round(ms / 1000)} sec` + if (ms < 3_600_000) return `${round(ms / 60_000)} min` + return `${round(ms / 3_600_000)} hr` +} + export function IndexingSettings({ onChanged }: { onChanged?: () => void }) { const [payload, setPayload] = useState(null) const [loadError, setLoadError] = useState(null) @@ -101,6 +121,9 @@ export function IndexingSettings({ onChanged }: { onChanged?: () => void }) { {payload.catalog.map((def) => { const row = rows[def.key] const isDefault = payload.stored[def.key] === undefined + const isMs = isMillisecondSetting(def.key) + const rowValue = row ? Number(row.value) : NaN + const humanized = isMs && Number.isFinite(rowValue) ? humanizeMs(rowValue) : null return (
@@ -108,6 +131,7 @@ export function IndexingSettings({ onChanged }: { onChanged?: () => void }) { {def.help} + {humanized &&

{row?.value} ms = {humanized}

} {row?.error &&

{row.error}

}
@@ -120,19 +144,23 @@ export function IndexingSettings({ onChanged }: { onChanged?: () => void }) { title={`Reset to the default (${def.default.toLocaleString()})`} >Reset )} - setRow(def.key, { value: e.target.value, error: null })} - onBlur={() => save(def)} - onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} - /> +
+ setRow(def.key, { value: e.target.value, error: null })} + onBlur={() => save(def)} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} + /> + {isMs && ms} +
) 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 (
- {L.level} + {badgeText}
-
{L.name}
-
{L.members} · {laneCounts[id]} concept{laneCounts[id] === 1 ? '' : 's'}
+
{primary}
+
{detail} · {laneCounts[id]} concept{laneCounts[id] === 1 ? '' : 's'}
@@ -252,6 +384,7 @@ function CanvasInner({ keyboardSuspended = false }: { keyboardSuspended?: boolea onMouseEnter={() => setHoverId(g.parent.c.id)} onMouseLeave={() => setHoverId(null)} title="Layers disagree — open the conflict" + aria-label={`${g.parent.c.title} — ${layerName(g.layer)} dissents, has conflict`} style={{ position: 'absolute', left: g.x, top: g.y, width: GHOST_W, height: GHOST_H, ...css(`display:flex; flex-direction:column; justify-content:center; gap:4px; text-align:left; padding:10px 12px; background:${C.surface}; border:1px dashed var(--cc-edge-conflict); border-radius:11px; cursor:pointer; font:inherit;`) }} >
@@ -275,6 +408,7 @@ function CanvasInner({ keyboardSuspended = false }: { keyboardSuspended?: boolea onClick={(event) => openConcept(n.c, event.currentTarget)} onMouseEnter={() => setHoverId(n.c.id)} onMouseLeave={() => setHoverId(null)} + aria-label={`${n.c.title} — ${layerName(primaryLayer(n.c))}${n.conflict ? ', has conflict' : n.c.draft ? ', draft' : ''}`} style={{ position: 'absolute', left: n.x, top: n.y, width: NODE_W, height: NODE_H, boxShadow: glow, ...css(`display:flex; flex-direction:column; gap:0; text-align:left; padding:12px 14px; background:${C.raised}; border:1px solid ${selected ? col.strokeE : C.line}; border-left:3px solid ${col.strokeE}; border-radius:12px; cursor:pointer; font:inherit;`) }} >
@@ -314,17 +448,33 @@ function CanvasInner({ keyboardSuspended = false }: { keyboardSuspended?: boolea {/* zoom controls */}
- {[['+', () => zoom(1)], ['−', () => zoom(-1)], ['⤢', fit]].map(([label, fn]) => ( + {([['+', 'Zoom in', () => zoom(1)], ['−', 'Zoom out', () => zoom(-1)], ['⤢', 'Fit to view', fit]] as const).map(([label, name, fn]) => ( + >{label} ))}
+ {/* 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)} + +
+ )} + {/* node detail slide-over */} {openConceptObj && (
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.'}

+
+ ) + } return (
- {list.map((c) => { + {list.map((c, i) => { const selected = c.id === selConcept - return ( + const row = (
{c.title}
{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 `
` boundary between them (an explicit form + // owner would scope the group to elements sharing THAT owner), and no + // radio hidden in a way (`display:none`, `disabled`) that would pull it out + // of the group's focus order. + it('keeps the disposition radios in one native focus-navigable group (no form owner, none display:none or disabled)', async () => { + mocks.useStore.mockReturnValue(storeWith([safeConflict], safeConflict.id)) + await act(async () => root.render()) + const radios = Array.from(container.querySelectorAll('input[type="radio"][name="cc-disposition"]')) + expect(radios.length).toBeGreaterThan(1) + for (const radio of radios) { + expect(radio.form).toBeNull() + expect(radio.disabled).toBe(false) + expect(getComputedStyle(radio).display).not.toBe('none') + } + }) + + it('starts the compose field empty and submits exactly what was typed, never the old value plus new text', async () => { + const store = storeWith([freshConflict], freshConflict.id) + mocks.useStore.mockReturnValue(store) + await act(async () => root.render()) + + const composeRadio = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Write a reconciled answer'))! + await act(async () => composeRadio.click()) + + const textarea = container.querySelector('textarea[aria-label="Reconciled Markdown"]')! + expect(textarea.value).toBe('') + expect(textarea.placeholder).toContain('Write the reconciled answer') + + const submit = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('Simulate reconciled answer'))! + expect(submit.disabled).toBe(true) + + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(textarea, 'Only the freshly typed reconciliation.') + textarea.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(submit.disabled).toBe(false) + + await act(async () => submit.click()) + expect(store.decideDiscrepancy).toHaveBeenCalledWith(expect.objectContaining({ content: 'Only the freshly typed reconciliation.' })) + }) + + it('resets the compose field to empty when the selected conflict changes', async () => { + // Conflicts is a props-less memo (see the note at the bottom of this + // file's subject) and the store hooks are mocked as plain functions, not + // reactive context — so a second root.render() with the same (empty) + // props bails out via memo and never re-invokes the component. Force a + // genuine remount, the same way a real navigation to a different + // discrepancy would, to exercise the conflict.id-keyed reset effect. + mocks.useStore.mockReturnValue(storeWith([freshConflict, staleConflict], freshConflict.id)) + await act(async () => root.render()) + const composeRadio = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Write a reconciled answer'))! + await act(async () => composeRadio.click()) + const textarea = container.querySelector('textarea[aria-label="Reconciled Markdown"]')! + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set + setter?.call(textarea, 'Draft for the first conflict.') + textarea.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(textarea.value).toBe('Draft for the first conflict.') + + await act(async () => root.unmount()) + root = createRoot(container) + mocks.useStore.mockReturnValue(storeWith([freshConflict, staleConflict], staleConflict.id)) + await act(async () => root.render()) + const composeRadioAfter = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Write a reconciled answer'))! + await act(async () => composeRadioAfter.click()) + const textareaAfter = container.querySelector('textarea[aria-label="Reconciled Markdown"]')! + expect(textareaAfter.value).toBe('') + }) + + it('offers to start the compose field from the winning contributor without prefilling it automatically', async () => { + mocks.useStore.mockReturnValue(storeWith([freshConflict], freshConflict.id)) + await act(async () => root.render()) + const composeRadio = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Write a reconciled answer'))! + await act(async () => composeRadio.click()) + + const textarea = container.querySelector('textarea[aria-label="Reconciled Markdown"]')! + expect(textarea.value).toBe('') + const startButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.startsWith('Start from'))! + expect(startButton).toBeTruthy() + + await act(async () => startButton.click()) + expect(textarea.value).toBe('SingleStore.') + }) + + it('disables compose for an array-typed frontmatter discrepancy and explains why', async () => { + mocks.useStore.mockReturnValue(storeWith([listConflict], listConflict.id)) + await act(async () => root.render()) + + const composeRadio = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Write a reconciled answer'))! + expect(composeRadio.disabled).toBe(true) + expect(container.textContent).toContain('This field is a list — pick an existing answer or edit the file directly.') + }) + + it('labels a frontmatter compose field "Reconciled value" and hides the Markdown preview affordance', async () => { + const listConflictComposable: Conflict = { ...listConflict, isList: false } + mocks.useStore.mockReturnValue(storeWith([listConflictComposable], listConflictComposable.id)) + await act(async () => root.render()) + + const composeRadio = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Write a reconciled answer'))! + await act(async () => composeRadio.click()) + + expect(container.querySelector('[aria-label="Reconciled value"]')).toBeTruthy() + expect(container.querySelector('[aria-label="Reconciled Markdown"]')).toBeFalsy() + expect(container.textContent).not.toContain('Preview Markdown') + }) + + it('offers "Target not created yet" for a broken-link discrepancy', async () => { + mocks.useStore.mockReturnValue(storeWith([brokenLinkConflict], brokenLinkConflict.id)) + await act(async () => root.render()) + const acknowledgeRadio = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Keep the scoped difference'))! + await act(async () => acknowledgeRadio.click()) + const options = Array.from(container.querySelectorAll('[aria-label="Acknowledgement reason"] option')).map((option) => option.textContent) + expect(options).toContain('Target not created yet') + }) + + it('never offers "Target not created yet" for a non-broken-link discrepancy', async () => { + mocks.useStore.mockReturnValue(storeWith([freshConflict], freshConflict.id)) + await act(async () => root.render()) + const otherRadio = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Keep the scoped difference'))! + await act(async () => otherRadio.click()) + const otherOptions = Array.from(container.querySelectorAll('[aria-label="Acknowledgement reason"] option')).map((option) => option.textContent) + expect(otherOptions).not.toContain('Target not created yet') + }) + + it('names the active search in the empty state and clears it on request', async () => { + const store = storeWith([freshConflict], freshConflict.id) + store.query = 'nothing will match this' + mocks.useStore.mockReturnValue(store) + await act(async () => root.render()) + + expect(container.textContent).toContain('No matches for "nothing will match this" in this status.') + const clear = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Clear search')! + await act(async () => clear.click()) + expect(store.setQuery).toHaveBeenCalledWith('') + }) + + it('still shows the generic empty state when no search is active', async () => { + mocks.useStore.mockReturnValue(storeWith([], '')) + await act(async () => root.render()) + expect(container.textContent).toContain('No discrepancies in this view') + expect(container.textContent).not.toContain('No matches for') + }) + + it('matches a resolved discrepancy on effectiveSource even when its contribution snapshot lacks that source (F13)', async () => { + mocks.useStore.mockReturnValue(storeWith([companyContributorConflict, resolvedViaEffectiveSource], resolvedViaEffectiveSource.id)) + await act(async () => root.render()) + + const resolvedTab = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Resolved')! + await act(async () => resolvedTab.click()) + expect(container.textContent).toContain('Resolved effective') + + const sourceSelect = container.querySelector('[aria-label="Source"]')! + await act(async () => { sourceSelect.value = 'company'; sourceSelect.dispatchEvent(new Event('change', { bubbles: true })) }) + + expect(container.textContent).toContain('Resolved effective') + }) }) diff --git a/apps/console/src/views/Conflicts.tsx b/apps/console/src/views/Conflicts.tsx index 587fde5..47daff2 100644 --- a/apps/console/src/views/Conflicts.tsx +++ b/apps/console/src/views/Conflicts.tsx @@ -19,6 +19,11 @@ const REASONS: { value: AcknowledgementReason; label: string }[] = [ { value: 'source_specific_authority', label: 'Source-specific authority' }, { value: 'other', label: 'Other' }, ] +// Broken-link-only: acknowledging why a link target doesn't exist yet is a +// distinct reason from the general four above. The engine's allowedReasons +// set (service.mjs) already accepts this value — verified before adding it +// here, since the UI must never offer a reason the API would 400. +const TARGET_MISSING_REASON: { value: AcknowledgementReason; label: string } = { value: 'target_missing', label: 'Target not created yet' } function formatDate(value?: string | null) { if (!value) return 'Date not recorded' @@ -140,17 +145,32 @@ function DecisionPanel({ conflict, onClose }: { conflict: Conflict; onClose: () const { mode, decideDiscrepancy, setDiscrepancyPriority, resolvingConflict, resolutionError, openFilesScope } = useStoreData() const [action, setAction] = useState<'choose_contribution' | 'compose' | 'acknowledge'>('choose_contribution') const [selectedSource, setSelectedSource] = useState(conflict.effectiveSource ?? conflict.contributions[0]?.sourceLayer ?? '') - const [content, setContent] = useState(conflict.contributions[0]?.value ?? '') + // Starts EMPTY, never pre-filled with an existing contributor's value. A + // compose field seeded with the old answer let a caret-position edit submit + // old+new concatenated as the "reconciled" content — real on-disk + // corruption in QA. "Start from " below is the only way old content + // enters this field, and it is an explicit click, never automatic. + const [content, setContent] = useState('') const [reasonCode, setReasonCode] = useState('') const [note, setNote] = useState('') const [preview, setPreview] = useState(false) const busy = resolvingConflict === conflict.id const cannotWrite = conflict.kind === 'broken_link' + // The engine 400s a compose against an array-typed frontmatter value (a + // list field) — service.mjs rejects it outright. Disable the disposition + // here instead of round-tripping into that error. + const composeDisabled = conflict.kind === 'frontmatter_value' && conflict.isList === true + const isFrontmatterValue = conflict.kind === 'frontmatter_value' + const winningSource = conflict.effectiveSource ?? conflict.contributions[0]?.sourceLayer ?? null + const winningValue = conflict.contributions.find((item) => item.sourceLayer === winningSource)?.value + const reasonOptions = conflict.kind === 'broken_link' + ? [...REASONS.slice(0, -1), TARGET_MISSING_REASON, REASONS[REASONS.length - 1]] + : REASONS useEffect(() => { setAction('choose_contribution') setSelectedSource(conflict.effectiveSource ?? conflict.contributions[0]?.sourceLayer ?? '') - setContent(conflict.contributions[0]?.value ?? '') + setContent('') setReasonCode('') setNote('') setPreview(false) @@ -171,12 +191,29 @@ function DecisionPanel({ conflict, onClose }: { conflict: Conflict; onClose: () {resolutionError &&
Decision not applied. {resolutionError.message}
}
Choose a safe disposition - + {action === 'choose_contribution' && } - - {action === 'compose' &&