diff --git a/README.md b/README.md index 5491c1c2..894243e1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

One sourced answer from the context your team already has.
- ContextCake resolves company policy, team practice, and personal judgment into an effective knowledge graph for people and AI agents. + ContextCake links company policy, team practice, and personal judgment through shared OKF identities, then resolves them into an effective knowledge graph for people and AI agents.

@@ -43,13 +43,13 @@ The app is Apple silicon (arm64) only. On an Intel Mac or Linux, run the engine Teams do not have one source of truth. They have an org policy, a service runbook, a team decision, and the local note that explains the exception. Flattening those into another wiki loses both the useful detail and the disagreement. -ContextCake keeps each scope separate, then resolves them at read time. The result is an answer an agent can use **with its source, date, and contradictions intact**. +ContextCake keeps each scope separate, then resolves them at read time. The result is an answer an agent can use **with its source, date, and structural discrepancies intact**. | What you need | What ContextCake does | | --- | --- | | Local nuance without copying every policy | Higher-priority layers override only the sections they address. Everything else inherits. | | An AI agent that can explain its answer | Returns provenance for frontmatter and every resolved section. | -| A safe way through disagreement | Clears formatting-only conflicts automatically; asks a direct question when meaning changes; keeps the original answers and every decision in append-only local history. | +| A safe way through disagreement | Surfaces structural discrepancies with complete evidence; applies explicit decisions transactionally; suggests governed rules only after repeated, consistent choices. | | Knowledge from more than one system | Layers any folder of Markdown, any GitHub repository, local [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundles, and trusted foreign MCP graphs in one cascade. | ## The layer cake diff --git a/apps/console/src/App.test.tsx b/apps/console/src/App.test.tsx index fb4b3667..5598568c 100644 --- a/apps/console/src/App.test.tsx +++ b/apps/console/src/App.test.tsx @@ -45,7 +45,9 @@ describe('Mac-first application shell', () => { await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: '5', metaKey: true, bubbles: true }))) expect(container.querySelector('[data-destination="review"]')?.getAttribute('aria-current')).toBe('page') expect(button('Queue 3')).toBeTruthy() - expect(button('Conflicts 3')).toBeTruthy() + expect(button('Discrepancies 3')).toBeTruthy() + expect(container.textContent).toContain('Simulation—no files will change.') + expect(container.textContent).toContain('Automatic rules never run.') expect(container.querySelector('[aria-live="polite"]')?.textContent).toBe('') }) diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 57e81299..1e3e4c23 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -182,7 +182,7 @@ export function App() { { id: 'files', label: 'Go to Knowledge: Files', keywords: 'markdown documents', shortcut: '⇧⌘F', run: () => setView('files') }, { id: 'sources', label: 'Go to Sources', shortcut: '⌘4', run: () => setView('sources') }, { id: 'queue', label: 'Go to Review: Queue', keywords: 'triage', run: () => setView('triage') }, - { id: 'conflicts', label: 'Go to Review: Conflicts', keywords: 'resolve', run: () => setView('conflicts') }, + { id: 'conflicts', label: 'Go to Review: Discrepancies', keywords: 'resolve align', run: () => setView('conflicts') }, // One per source: the palette is the keyboard route into the navigator, // matching the Sources panel's "Browse files" button — including in the // demo, where that button is offered too. Browsing is a read. @@ -378,6 +378,12 @@ export function App() { onAddSource={mode === 'live' ? reopenWizard : undefined} onConnectAgent={isDesktop && !needsSetup ? openConnect : undefined} /> + {mode === 'demo' && ( +

+ Simulation—no files will change. + Actions and history reset on reload. Automatic rules never run. +
+ )}
{backgroundAnnouncement}
diff --git a/apps/console/src/api.ts b/apps/console/src/api.ts index bd7d67b8..51bfc2c1 100644 --- a/apps/console/src/api.ts +++ b/apps/console/src/api.ts @@ -13,7 +13,8 @@ import demoBundleRaw from './generated/demo-cascade.json' import type { - ConflictResolutionRecord, DemoBundle, GraphSummary, GraphSource, ResolveConflictRequest, + ConflictResolutionRecord, DemoBundle, DiscrepanciesResponse, DiscrepancyDecisionRequest, DiscrepancyRecord, + DiscrepancyRule, DiscrepancyRuleSuggestion, GraphSummary, GraphSource, ResolveConflictRequest, ResolvedConcept, ResolvedSection, SourceStatus, StatusSummary, } from './types' import type { Concept, ConceptSection, Conflict, Dissent, Source } from './data' @@ -60,6 +61,13 @@ export interface DataSource { status(): Promise conflictResolutions(): Promise resolveConflict(request: ResolveConflictRequest): Promise + discrepancies(): Promise + decideDiscrepancy(request: DiscrepancyDecisionRequest): Promise + discrepancyRules(): Promise<{ rules: DiscrepancyRule[]; suggestions: DiscrepancyRuleSuggestion[] }> + createDiscrepancyRule(suggestionId: string): Promise + patchDiscrepancyRule(id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }): Promise + promoteDiscrepancyRule(id: string, confirm: boolean): Promise> + setDiscrepancyPriority(id: string, priority: string): Promise } // ---- Transport -------------------------------------------------------------- @@ -188,6 +196,42 @@ class DemoSource implements DataSource { } } async conflictResolutions(): Promise { return this.resolutions } + async discrepancies(): Promise { + const conflicts = adaptConflicts(this.bundle.concepts, this.resolutions) + return { + discrepancies: conflicts.map((conflict) => legacyConflictRecord(conflict)), + coverageComplete: true, indexing: false, indexingSources: [], errors: [], generation: 1, + } + } + async decideDiscrepancy(request: DiscrepancyDecisionRequest): Promise { + if (request.action !== 'choose_contribution' || !request.selectedSource) { + const current = (await this.discrepancies()).discrepancies.find((item) => item.id === request.discrepancyId) + if (!current) throw new LiveDataError('bad-status', 'This discrepancy is no longer open.', 409) + const chosen = request.action === 'compose' + ? { layer: current.effectiveSource ?? current.contributions[0].source, content: request.content ?? '', updated: new Date().toISOString() } + : null + const record: ConflictResolutionRecord = { + schemaVersion: 2, id: `demo-${Date.now()}-${this.resolutions.length + 1}`, + conflictId: current.legacyId ?? current.id, discrepancyId: current.id, + conceptId: current.conceptId, title: current.conceptTitle, sectionKey: current.key, + sectionHeading: current.label, + contributions: current.contributions.map((item) => ({ layer: item.source, level: item.level, content: String(item.value), updated: item.updated })), + chosen, method: 'manual', actor: 'local-user', decidedAt: new Date().toISOString(), + action: request.action, reason: request.action === 'acknowledge' ? 'You kept this scoped difference.' : 'You wrote a reconciled answer.', + reasonCode: request.reasonCode, note: request.note, + transactionState: request.action === 'acknowledge' ? 'not_required' : 'committed', writtenTargets: [], + } + this.resolutions.push(record) + return record + } + const [, conceptId, sectionKey] = request.discrepancyId.split('::') + return this.resolveConflict({ conceptId, sectionKey, selectedLayer: request.selectedSource, method: 'manual' }) + } + async discrepancyRules() { return { rules: [], suggestions: [] } } + async createDiscrepancyRule(): Promise { throw new LiveDataError('bad-status', 'Simulation rules reset on reload.', 405) } + async patchDiscrepancyRule(): Promise { throw new LiveDataError('bad-status', 'Automatic rules never run in simulation.', 405) } + async promoteDiscrepancyRule(): Promise> { throw new LiveDataError('bad-status', 'Simulation cannot promote team rules.', 405) } + async setDiscrepancyPriority(): Promise { /* simulation-only local state is owned by the store */ } async resolveConflict(request: ResolveConflictRequest): Promise { const prior = request.resolutionId ? this.resolutions.find((item) => item.id === request.resolutionId) @@ -301,6 +345,41 @@ class LiveSource implements DataSource { }) return response.resolution } + async discrepancies(): Promise { + try { return await this.get('/api/discrepancies') } + catch (error) { + if (error instanceof LiveDataError && error.kind === 'bad-status' && error.status === 404) return null + throw error + } + } + async decideDiscrepancy(request: DiscrepancyDecisionRequest): Promise { + return (await this.request<{ decision: ConflictResolutionRecord }>('/api/discrepancy-decisions', { + method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(request), + })).decision + } + async discrepancyRules(): Promise<{ rules: DiscrepancyRule[]; suggestions: DiscrepancyRuleSuggestion[] }> { + return this.get('/api/discrepancy-rules') + } + async createDiscrepancyRule(suggestionId: string): Promise { + return (await this.request<{ rule: DiscrepancyRule }>('/api/discrepancy-rules', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ suggestionId }), + })).rule + } + async patchDiscrepancyRule(id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }): Promise { + return (await this.request<{ rule: DiscrepancyRule }>(`/api/discrepancy-rules?id=${encodeURIComponent(id)}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changes), + })).rule + } + async promoteDiscrepancyRule(id: string, confirm: boolean): Promise> { + return this.request('/api/discrepancy-rules/promote', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm }), + }) + } + async setDiscrepancyPriority(id: string, priority: string): Promise { + await this.request(`/api/discrepancies?id=${encodeURIComponent(id)}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority }), + }) + } private async get(path: string): Promise { return this.request(path, { headers: { accept: 'application/json' } }) } @@ -697,7 +776,7 @@ 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.chosen.level ?? (latest.chosen.layer === 'personal' ? 3 : latest.chosen.layer === 'team' ? 2 : 0)), + winner: layerOf(latest.chosen?.layer ?? latest.contributions[0]?.layer ?? '', latest.chosen?.level ?? latest.contributions[0]?.level ?? 0), contributions, safe: false, history, @@ -705,3 +784,52 @@ export function adaptConflicts(concepts: ResolvedConcept[], resolutions: Conflic } return out } + +function legacyConflictRecord(conflict: Conflict): DiscrepancyRecord { + return { + id: `section_content::${conflict.concept}::${conflict.sectionKey}`, + legacyId: conflict.id, + kind: 'section_content', originalKind: 'section_content', + conceptId: conflict.concept, conceptTitle: conflict.title, conceptType: 'concept', + key: conflict.sectionKey, label: conflict.section, + revision: `${conflict.id}:${conflict.history.length}`, + status: conflict.status === 'resolved' ? 'resolved' : 'needs_review', + contributions: conflict.contributions.map((item, index) => ({ + source: item.sourceLayer, level: item.layer === 'personal' ? 3 : item.layer === 'team' ? 2 : 0, + updated: item.updated || null, value: item.value, fingerprint: `${conflict.id}:${index}`, effective: index === 0, + })), + effectiveSource: conflict.contributions[0]?.sourceLayer ?? null, + effectiveValue: conflict.contributions[0]?.value ?? '', + winnerReason: `${conflict.contributions[0]?.sourceLayer ?? 'The selected source'} wins by configured layer precedence.`, + owner: 'Unassigned', priority: 'unassigned', fresherDissent: conflict.contributions.some((item) => item.fresherDissent), + freshness: { effectiveUpdated: conflict.contributions[0]?.updated ?? null, newestUpdated: conflict.contributions[0]?.updated ?? null, hasNewerDissent: conflict.contributions.some((item) => item.fresherDissent) }, + affectedLinks: [], + sourceHealth: conflict.contributions.map((item) => ({ source: item.sourceLayer, status: 'ok', error: null })), + history: conflict.history, matchingRules: [], + } +} + +/** Raw professional discrepancy records → the existing navigator view model. */ +export function adaptDiscrepancies(records: DiscrepancyRecord[], coverageComplete = true): Conflict[] { + return records.map((record) => { + const contributions = record.contributions.map((item) => ({ + layer: layerOf(item.source, item.level), 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 } : {}), + })) + const effective = record.contributions.find((item) => item.effective) ?? record.contributions[0] + return { + 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), + 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, + } + }) +} diff --git a/apps/console/src/components/Header.tsx b/apps/console/src/components/Header.tsx index 8900a17a..5f3e2adc 100644 --- a/apps/console/src/components/Header.tsx +++ b/apps/console/src/components/Header.tsx @@ -25,7 +25,7 @@ function HeaderInner({ const destination = destinationForView(view) const searchable = SEARCHABLE_VIEWS.has(view) const queueCount = signals.filter((signal) => signal.route === 'review_required').length - const conflictCount = conflicts.filter((conflict) => conflict.status === 'open').length + const conflictCount = conflicts.filter((conflict) => ['needs_review', 'reopened', 'recommended', 'auto_ready', 'blocked'].includes(conflict.discrepancyStatus ?? (conflict.status === 'open' ? 'needs_review' : 'resolved'))).length useEffect(() => { const focus = () => search.current?.focus() @@ -44,7 +44,7 @@ function HeaderInner({ { value: 'concepts', label: 'Concepts' }, { value: 'files', label: 'Files' }, ]} />} {destination === 'review' && }
@@ -57,8 +57,8 @@ function HeaderInner({ setQuery('') } }} - label={`Search ${view === 'triage' ? 'queue' : view}`} - placeholder={`Search ${view === 'triage' ? 'queue' : view}`} + label={`Search ${view === 'triage' ? 'queue' : view === 'conflicts' ? 'discrepancies' : view}`} + placeholder={`Search ${view === 'triage' ? 'queue' : view === 'conflicts' ? 'discrepancies' : view}`} />} {/* Background work and its health, from every destination — a count with no progress and no detail was the badge this replaces. */} diff --git a/apps/console/src/components/Sidebar.tsx b/apps/console/src/components/Sidebar.tsx index 6b005d41..3cc7773e 100644 --- a/apps/console/src/components/Sidebar.tsx +++ b/apps/console/src/components/Sidebar.tsx @@ -51,7 +51,7 @@ function SidebarInner({ onOpenSettings, onNavigate }: { onOpenSettings?: () => v if (view === 'triage' || view === 'conflicts') reviewView.current = view const reviewCount = signals.filter((signal) => signal.route === 'review_required').length - + conflicts.filter((conflict) => conflict.status === 'open').length + + conflicts.filter((conflict) => ['needs_review', 'reopened', 'recommended', 'auto_ready', 'blocked'].includes(conflict.discrepancyStatus ?? (conflict.status === 'open' ? 'needs_review' : 'resolved'))).length const sourceErrors = sources.filter((source) => source.status === 'degraded' || source.status === 'error').length const go = (destination: ShellDestination) => { diff --git a/apps/console/src/data.ts b/apps/console/src/data.ts index c38293bf..dcac85b6 100644 --- a/apps/console/src/data.ts +++ b/apps/console/src/data.ts @@ -1,4 +1,4 @@ -import type { ConflictResolutionRecord } from './types' +import type { ConflictResolutionRecord, DiscrepancyKind, DiscrepancyStatus, DiscrepancyRule } from './types' import type { LayerId, RouteId } from './theme' export interface Layer { @@ -66,6 +66,19 @@ export interface Conflict { status: 'open' | 'resolved'; contributions: Contribution[]; winner: LayerId safe: boolean history: ConflictResolutionRecord[] + kind?: DiscrepancyKind + discrepancyStatus?: DiscrepancyStatus + revision?: string + owner?: string + priority?: string + winnerReason?: string + effectiveSource?: string | null + coverageComplete?: boolean + sourceHealth?: ({ source: string; status: string; error: string | null } | null)[] + matchingRules?: Pick[] + ruleConflict?: boolean + target?: string + affectedLinks?: string[] } /** `sourceLayer` is the source's real name; `layer` is the lane it renders in. */ diff --git a/apps/console/src/store.tsx b/apps/console/src/store.tsx index f8cf0955..0b7edb18 100644 --- a/apps/console/src/store.tsx +++ b/apps/console/src/store.tsx @@ -6,10 +6,13 @@ import { type Activity, type Concept, type Conflict, type Signal, type Source, } from './data' import { - adaptConcept, adaptConflicts, adaptSources, createDataSource, LiveDataError, mergeSourceStatus, + adaptConcept, adaptConflicts, adaptDiscrepancies, adaptSources, createDataSource, LiveDataError, mergeSourceStatus, type Mode, } from './api' -import type { GraphSummary, SourceStatus } from './types' +import type { + DiscrepancyDecisionRequest, DiscrepancyRule, DiscrepancyRuleSuggestion, + GraphSummary, SourceStatus, +} from './types' import type { LayerId, RouteId } from './theme' import { dispatchNavigationGuard, filesHash, isViewId, parseHash, type ViewId } from './shell-navigation' @@ -209,6 +212,8 @@ export interface StoreData { loadErrors: { concept: string; error: string }[] resolvingConflict: string | null resolutionError: { message: string; partial: boolean } | null + discrepancyRules: DiscrepancyRule[] + discrepancyRuleSuggestions: DiscrepancyRuleSuggestion[] setView: (v: ViewId) => void setTriageTab: (t: TriageTab) => void @@ -236,6 +241,11 @@ export interface StoreData { route: (target: RouteId) => void resolveConflict: (conflictId: string, sourceLayer: string, method: 'automatic' | 'manual') => Promise resolveSafeConflicts: () => Promise + decideDiscrepancy: (request: DiscrepancyDecisionRequest) => Promise + approveRuleSuggestion: (id: string) => Promise + updateDiscrepancyRule: (id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }) => Promise + promoteDiscrepancyRule: (id: string, confirm: boolean) => Promise> + setDiscrepancyPriority: (id: string, priority: string) => Promise send: (text?: string) => void reload: () => void /** @@ -306,6 +316,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [loadErrors, setLoadErrors] = useState<{ concept: string; error: string }[]>([]) const [resolvingConflict, setResolvingConflict] = useState(null) const [resolutionError, setResolutionError] = useState<{ message: string; partial: boolean } | null>(null) + const [discrepancyRules, setDiscrepancyRules] = useState([]) + const [discrepancyRuleSuggestions, setDiscrepancyRuleSuggestions] = useState([]) // Triage signals and the activity feed have no resolver equivalent — demo-only // fixtures (D6: live-mode triage is read-only, and there is no signal API). const [signals, setSignals] = useState(mode === 'demo' ? initialSignals : []) @@ -441,9 +453,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { shellReadyRef.current = true setLoading(false) // the shell can render now — everything else streams in - const [{ concepts: raw, errors, indexing, indexingSources: resolvingSources }, resolutionHistory] = await Promise.all([ + const [{ concepts: raw, errors, indexing, indexingSources: resolvingSources }, resolutionHistory, discrepancyPayload, rulePayload] = await Promise.all([ source.resolveAll(), source.conflictResolutions(), + source.discrepancies ? source.discrepancies() : Promise.resolve(null), + source.discrepancyRules ? source.discrepancyRules().catch(() => ({ rules: [], suggestions: [] })) : Promise.resolve({ rules: [], suggestions: [] }), ]) if (cancelled) return false // Only fail the whole page when nothing resolved AND nothing is still @@ -457,8 +471,12 @@ export function StoreProvider({ children }: { children: ReactNode }) { // leaves the banner running after the work has landed. applyIndexing(indexing ? (resolvingSources ?? g.indexingSources ?? []) : []) setConcepts(raw.map(adaptConcept)) - const derivedConflicts = adaptConflicts(raw, resolutionHistory) + const derivedConflicts = discrepancyPayload + ? adaptDiscrepancies(discrepancyPayload.discrepancies, discrepancyPayload.coverageComplete) + : adaptConflicts(raw, resolutionHistory) setConflicts(derivedConflicts) + setDiscrepancyRules(rulePayload.rules) + setDiscrepancyRuleSuggestions(rulePayload.suggestions) // Honor a deep-linked concept from the URL hash; else default to the // first. Only claim the deep link once it actually resolved. const pendingId = pendingConceptRef.current @@ -841,6 +859,45 @@ export function StoreProvider({ children }: { children: ReactNode }) { } }, [applyResolution]) + const decideDiscrepancy = useCallback(async (request: DiscrepancyDecisionRequest) => { + if (resolvingConflictRef.current) return + resolvingConflictRef.current = request.discrepancyId + setResolvingConflict(request.discrepancyId) + setResolutionError(null) + try { + const record = await source.decideDiscrepancy(request) + setConflicts((previous) => previous.map((item) => item.id === request.discrepancyId + ? { ...item, status: request.action === 'acknowledge' ? 'open' : 'resolved', discrepancyStatus: request.action === 'acknowledge' ? 'acknowledged' : 'resolved', history: [...item.history, record] } + : item)) + window.setTimeout(() => setReloadKey((key) => key + 1), 300) + } catch (error) { + setResolutionError({ message: error instanceof Error ? error.message : String(error), partial: false }) + throw error + } finally { + resolvingConflictRef.current = null + setResolvingConflict(null) + } + }, [source]) + + const approveRuleSuggestion = useCallback(async (id: string) => { + const rule = await source.createDiscrepancyRule(id) + setDiscrepancyRules((items) => [...items, rule]) + setDiscrepancyRuleSuggestions((items) => items.filter((item) => item.id !== id)) + setReloadKey((key) => key + 1) + }, [source]) + + const updateDiscrepancyRule = useCallback(async (id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }) => { + const rule = await source.patchDiscrepancyRule(id, changes) + setDiscrepancyRules((items) => items.map((item) => item.id === id ? rule : item)) + setReloadKey((key) => key + 1) + }, [source]) + + const promoteDiscrepancyRule = useCallback((id: string, confirm: boolean) => source.promoteDiscrepancyRule(id, confirm), [source]) + const setDiscrepancyPriority = useCallback(async (id: string, priority: string) => { + await source.setDiscrepancyPriority(id, priority) + setConflicts((items) => items.map((item) => item.id === id ? { ...item, priority } : item)) + }, [source]) + const send = useCallback((text?: string) => { const q = (text != null ? text : chatInputRef.current).trim() if (!q || chatBusyRef.current) return @@ -887,11 +944,19 @@ export function StoreProvider({ children }: { children: ReactNode }) { const data = useMemo(() => ({ mode, loading, load, error, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, + discrepancyRules, discrepancyRuleSuggestions, setView, setTriageTab, setSelSignal, setSelConflict, setSelConcept, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat, setChatInput, - retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, - }), [mode, loading, load, error, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, setView, setSelConcept, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat]) + retryNow, route, resolveConflict, resolveSafeConflicts, decideDiscrepancy, + approveRuleSuggestion, updateDiscrepancyRule, promoteDiscrepancyRule, setDiscrepancyPriority, + send, reload, reloadKey, + }), [mode, loading, load, error, concepts, sources, signals, conflicts, activity, loadErrors, + resolvingConflict, resolutionError, discrepancyRules, discrepancyRuleSuggestions, + retryNow, route, resolveConflict, resolveSafeConflicts, decideDiscrepancy, + approveRuleSuggestion, updateDiscrepancyRule, promoteDiscrepancyRule, setDiscrepancyPriority, + send, reload, reloadKey, setView, setSelConcept, setQuery, setFilesScope, setFilesPath, + openFilesScope, openConcept, openChat, closeChat]) const nav = useMemo( () => ({ view, triageTab, selSignal, selConflict, selConcept, filesScope, filesPath, chatOpen }), diff --git a/apps/console/src/styles.css b/apps/console/src/styles.css index ac52d2d5..072488d1 100644 --- a/apps/console/src/styles.css +++ b/apps/console/src/styles.css @@ -1775,6 +1775,123 @@ textarea:focus-visible, .cc-src-cov { display: none; } /* the coverage bar drops on small screens */ } +/* Professional Discrepancy Center ----------------------------------------- */ +.cc-discrepancy-center { gap: 16px; padding-bottom: 42px; } +.cc-simulation-notice, .cc-coverage-warning { + padding: 11px 14px; border: 1px solid var(--cc-blue-stroke); border-radius: 10px; + background: var(--cc-blue-fill); color: var(--cc-blue-text); font-size: 12px; line-height: 1.45; +} +.cc-global-simulation { min-height: 34px; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 6px 14px; border-bottom: 1px solid var(--cc-blue-stroke); background: var(--cc-blue-fill); color: var(--cc-blue-text); font-size: 11px; } +.cc-global-simulation span { color: var(--cc-body); } +.cc-simulation-notice { position: sticky; top: 0; z-index: 5; box-shadow: 0 4px 16px var(--cc-shadow); } +.cc-discrepancy-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 4px 2px 0; } +.cc-eyebrow { color: var(--cc-teal-text); font-size: 10px; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; } +.cc-discrepancy-header h2 { margin: 5px 0 4px; color: var(--cc-ink); font-size: clamp(22px, 2.2vw, 30px); letter-spacing: -.035em; } +.cc-discrepancy-header p { margin: 0; color: var(--cc-caption); font-size: 12.5px; } +.cc-actionable-count { min-width: 48px; min-height: 48px; display: grid; place-items: center; border: 1px solid var(--cc-amber-stroke); border-radius: 14px; background: var(--cc-amber-fill); color: var(--cc-amber-text); font-size: 19px; font-weight: 750; } +.cc-status-tabs { display: flex; gap: 4px; overflow-x: auto; padding: 4px; border: 1px solid var(--cc-line); border-radius: 11px; background: var(--cc-neutral-fill); } +.cc-status-tabs button { min-height: 44px; flex: 1 0 auto; padding: 0 13px; border: 0; border-radius: 8px; background: transparent; color: var(--cc-caption); font: inherit; font-size: 12px; font-weight: 650; cursor: pointer; } +.cc-status-tabs button[data-active="true"] { background: var(--cc-raised); color: var(--cc-ink); box-shadow: 0 1px 4px var(--cc-shadow); } +.cc-discrepancy-filters { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; gap: 8px; } +.cc-discrepancy-filters select, .cc-filter-check { min-height: 44px; border: 1px solid var(--cc-line); border-radius: 9px; background: var(--cc-surface); color: var(--cc-body); font: inherit; font-size: 12px; } +.cc-discrepancy-filters select { padding: 0 30px 0 11px; } +.cc-filter-check { display: flex; align-items: center; gap: 7px; padding: 0 12px; white-space: nowrap; } +.cc-filter-check input { width: 16px; height: 16px; accent-color: var(--cc-teal-stroke-e); } +.cc-discrepancy-center .cc-conflict-layout { min-height: 560px; grid-template-columns: minmax(250px, .72fr) minmax(0, 1.7fr); } +.cc-discrepancy-center .cc-conflict-list { max-height: 760px; overflow-y: auto; padding-right: 3px; } +.cc-discrepancy-center .cc-conflict-row { min-height: 126px; } +.cc-kind-pill { display: inline-flex; width: max-content; align-items: center; min-height: 23px; padding: 0 8px; border-radius: 999px; background: var(--cc-neutral-fill); color: var(--cc-caption); font-size: 9.5px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; } +.cc-discrepancy-center .cc-conflict-row > code { color: var(--cc-caption); font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; } +.cc-discrepancy-center .cc-conflict-row-foot { color: var(--cc-caption); font-size: 10.5px; } +.cc-discrepancy-center .cc-conflict-detail { max-height: 760px; overflow-y: auto; padding: 24px; } +.cc-discrepancy-center .cc-detail-close { min-width: 44px; min-height: 44px; } +.cc-discrepancy-path { display: flex; gap: 8px; align-items: center; color: var(--cc-caption); font-size: 11px; } +.cc-discrepancy-path code { padding: 4px 8px; border-radius: 6px; background: var(--cc-blue-fill); color: var(--cc-blue-text); } +.cc-discrepancy-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-top: 18px; } +.cc-discrepancy-title h2 { margin: 7px 0 0; color: var(--cc-ink); font-size: 22px; letter-spacing: -.025em; } +.cc-status-large { padding: 6px 9px; border-radius: 7px; background: var(--cc-amber-fill); color: var(--cc-amber-text); font-size: 10.5px; font-weight: 700; } +.cc-discrepancy-explanation { max-width: 76ch; margin: 10px 0 18px; color: var(--cc-body); font-size: 13px; line-height: 1.55; } +.cc-evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; margin-bottom: 20px; border: 1px solid var(--cc-line); border-radius: 10px; background: var(--cc-line); } +.cc-evidence-grid > div { min-height: 66px; padding: 11px 12px; background: var(--cc-surface); } +.cc-evidence-grid span, .cc-evidence-grid strong { display: block; } +.cc-evidence-grid span { color: var(--cc-caption); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; } +.cc-evidence-grid strong { margin-top: 5px; color: var(--cc-body); font-size: 12px; line-height: 1.4; } +.cc-conflict-detail section > h3, .cc-rules h3 { margin: 22px 0 10px; color: var(--cc-ink); font-size: 13px; } +.cc-answer-stack { display: grid; gap: 9px; } +.cc-discrepancy-answer { padding: 13px; border: 1px solid var(--cc-line); border-radius: 10px; background: var(--cc-surface); } +.cc-discrepancy-answer[data-effective="true"] { border-color: var(--cc-teal-stroke-e); background: var(--cc-teal-fill); } +.cc-discrepancy-answer header { display: flex; justify-content: space-between; gap: 12px; } +.cc-discrepancy-answer header strong { color: var(--cc-ink); font-size: 12.5px; } +.cc-discrepancy-answer header span { color: var(--cc-caption); font-size: 10.5px; } +.cc-discrepancy-meta { margin-top: 3px; color: var(--cc-caption); font-size: 10px; } +.cc-discrepancy-answer details, .cc-discrepancy-history details { margin-top: 10px; } +.cc-discrepancy-answer summary, .cc-discrepancy-history summary { color: var(--cc-teal-text); font-size: 11px; cursor: pointer; } +.cc-discrepancy-original { margin-top: 10px; padding: 12px; border-radius: 8px; background: var(--cc-raised); color: var(--cc-body); font-size: 12px; overflow-wrap: anywhere; } +.cc-word-diff { margin: 11px 0 0; color: var(--cc-body); font-size: 12px; line-height: 1.55; } +.cc-word-diff mark { padding: 1px 2px; border-radius: 3px; background: var(--cc-teal-fill); color: var(--cc-teal-text); } +.cc-word-diff del { padding: 1px 2px; border-radius: 3px; background: var(--cc-amber-fill2); color: var(--cc-amber-text); } +.cc-line-diff { margin: 11px 0 0; overflow-x: auto; padding: 10px; border-radius: 8px; background: var(--cc-code-bg); color: var(--cc-code-fg); font-size: 11px; line-height: 1.55; } +.cc-line-diff [data-change="added"] { background: var(--cc-teal-fill); color: var(--cc-teal-text); } +.cc-line-diff [data-change="removed"] { background: var(--cc-amber-fill2); color: var(--cc-amber-text); } +.cc-diff-same, .cc-muted { color: var(--cc-caption); font-size: 11.5px; } +.cc-decision-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--cc-line); } +.cc-decision-panel fieldset { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; } +.cc-decision-panel legend { margin-bottom: 10px; color: var(--cc-ink); font-size: 13px; font-weight: 700; } +.cc-decision-panel fieldset > label { min-height: 54px; display: flex; gap: 10px; align-items: flex-start; padding: 10px 11px; border: 1px solid var(--cc-line); border-radius: 9px; cursor: pointer; } +.cc-decision-panel fieldset > label:has(input:checked) { border-color: var(--cc-teal-stroke-e); background: var(--cc-teal-fill); } +.cc-decision-panel fieldset > label input { width: 17px; height: 17px; margin-top: 2px; accent-color: var(--cc-teal-stroke-e); } +.cc-decision-panel label span strong, .cc-decision-panel label span small { display: block; } +.cc-decision-panel label span strong { color: var(--cc-body); font-size: 12px; } +.cc-decision-panel label span small { margin-top: 2px; color: var(--cc-caption); font-size: 10.5px; line-height: 1.4; } +.cc-decision-panel select, .cc-decision-panel textarea { width: 100%; border: 1px solid var(--cc-line); border-radius: 8px; background: var(--cc-raised); color: var(--cc-ink); font: inherit; font-size: 12px; } +.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-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: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; } +.cc-discrepancy-history > li { padding: 12px; border: 1px solid var(--cc-line); border-radius: 9px; background: var(--cc-surface); } +.cc-history-head { display: flex; justify-content: space-between; gap: 10px; } +.cc-history-head strong { color: var(--cc-body); font-size: 12px; } +.cc-history-head span, .cc-history-facts { color: var(--cc-caption); font-size: 10.5px; } +.cc-discrepancy-history p { margin: 7px 0; color: var(--cc-body); font-size: 11.5px; } +.cc-history-facts { display: flex; flex-wrap: wrap; gap: 6px 12px; } +.cc-history-original pre { overflow-x: auto; padding: 8px; border-radius: 6px; background: var(--cc-code-bg); color: var(--cc-code-fg); white-space: pre-wrap; } +.cc-rules { padding: 18px; border: 1px solid var(--cc-line); border-radius: 12px; background: var(--cc-surface); } +.cc-rules > div:first-child { display: flex; justify-content: space-between; gap: 20px; } +.cc-rules h3 { margin: 0; } +.cc-rules > div:first-child p { margin: 0; color: var(--cc-caption); font-size: 11.5px; } +.cc-rules > article { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 16px; margin-top: 11px; padding: 12px; border: 1px solid var(--cc-line); border-radius: 9px; } +.cc-rules article > strong { color: var(--cc-body); font-size: 12px; } +.cc-rules article > span { color: var(--cc-caption); font-size: 11px; } +.cc-rules article > details, .cc-rules article > div { grid-column: 1 / -1; } +.cc-rules article > button { grid-column: 2; grid-row: 1 / span 2; } +.cc-rule-preview { margin-top: 12px; padding: 14px; border: 1px solid var(--cc-blue-stroke); border-radius: 9px; background: var(--cc-blue-fill); } +.cc-rule-preview pre { max-height: 240px; overflow: auto; font-size: 10px; } + +.cc-discrepancy-center button:focus-visible, .cc-discrepancy-center select:focus-visible, .cc-discrepancy-center textarea:focus-visible, .cc-discrepancy-center input:focus-visible, .cc-discrepancy-center summary:focus-visible { outline: 3px solid var(--cc-blue-soft); outline-offset: 2px; } + +@media (max-width: 980px) { + .cc-discrepancy-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .cc-discrepancy-center .cc-conflict-layout { grid-template-columns: 1fr; } + .cc-discrepancy-center .cc-conflict-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); max-height: none; } +} +@media (max-width: 700px) { + .cc-discrepancy-filters, .cc-evidence-grid { grid-template-columns: 1fr; } + .cc-discrepancy-center .cc-conflict-list { display: flex; } + .cc-discrepancy-center .cc-conflict-detail { padding: 17px; } + .cc-discrepancy-title, .cc-rules > div:first-child { flex-direction: column; } + .cc-decision-actions { flex-direction: column; } + .cc-decision-actions button { width: 100%; } +} + /* Container behavior must win over the legacy window-width triage fallback. */ @container (max-width: 839px) { .cc-triage-grid { position: relative; display: block; min-height: 100%; } diff --git a/apps/console/src/types.ts b/apps/console/src/types.ts index 12c3d91e..57399359 100644 --- a/apps/console/src/types.ts +++ b/apps/console/src/types.ts @@ -226,7 +226,7 @@ export interface ConflictResolutionContribution { /** One append-only decision returned by GET /api/conflict-resolutions. */ export interface ConflictResolutionRecord { - schemaVersion: 1 + schemaVersion: 1 | 2 id: string conflictId: string conceptId: string @@ -234,12 +234,103 @@ export interface ConflictResolutionRecord { sectionKey: string sectionHeading: string contributions: ConflictResolutionContribution[] - chosen: ConflictResolutionContribution + chosen: ConflictResolutionContribution | null method: 'automatic' | 'manual' reason: string actor: 'local-user' decidedAt: string supersedes?: string + discrepancyId?: string + discrepancyKind?: DiscrepancyKind + revision?: string + action?: DiscrepancyAction + reasonCode?: AcknowledgementReason + note?: string + ruleId?: string + transactionId?: string + transactionState?: 'committed' | 'rolled_back' | 'recovery_required' | 'not_required' | 'blocked' + writtenTargets?: { layer: string; path: string }[] + contributorFingerprints?: { source: string; fingerprint: string }[] + supersededDecisionId?: string +} + +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 interface DiscrepancyContribution { + source: string + level: number + updated: string | null + value: unknown + fingerprint: string + effective: boolean +} + +export interface DiscrepancyRule { + id: string + scope: 'local' | 'team' + mode: 'recommend' | 'automatic' + enabled: boolean + match: { kind: DiscrepancyKind; conceptType: string; key: string; sources: string[] } + action: { type: 'prefer_source'; source: string } | { type: 'acknowledge'; reasonCode: AcknowledgementReason } + evidenceDecisionIds: string[] +} + +export interface DiscrepancyRuleSuggestion { + id: string + match: DiscrepancyRule['match'] + action: DiscrepancyRule['action'] + evidenceDecisionIds: string[] + evidenceCount: number +} + +export interface DiscrepancyRecord { + id: string + legacyId?: string + kind: DiscrepancyKind + originalKind: DiscrepancyKind + conceptId: string + conceptTitle: string + conceptType: string + key: string + label: string + target?: string + revision: string + status: DiscrepancyStatus + contributions: DiscrepancyContribution[] + effectiveSource: string | null + effectiveValue: unknown + winnerReason: string + owner: string + priority: string + fresherDissent: boolean + freshness: { effectiveUpdated: string | null; newestUpdated: string | null; hasNewerDissent: boolean } + affectedLinks: string[] + sourceHealth: ({ source: string; status: string; error: string | null } | null)[] + history: ConflictResolutionRecord[] + matchingRules: Pick[] + ruleConflict?: boolean +} + +export interface DiscrepanciesResponse { + discrepancies: DiscrepancyRecord[] + coverageComplete: boolean + indexing: boolean + indexingSources: string[] + errors: { concept: string; error: string }[] + generation: number +} + +export interface DiscrepancyDecisionRequest { + discrepancyId: string + revision: string + action: DiscrepancyAction + selectedSource?: string + content?: string + reasonCode?: AcknowledgementReason + note?: string } export interface ResolveConflictRequest { diff --git a/apps/console/src/views/Conflicts.test.tsx b/apps/console/src/views/Conflicts.test.tsx index c91ba59e..dd5cbc21 100644 --- a/apps/console/src/views/Conflicts.test.tsx +++ b/apps/console/src/views/Conflicts.test.tsx @@ -1,7 +1,5 @@ // @vitest-environment jsdom -// The "dissent is newer" badge (contract C-b) renders in the Conflicts view -// only, on the dissenting card whose date beats the effective value. Fixture -// driven — no engine required. +// Professional discrepancy presentation and governed decision affordances. import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,6 +17,7 @@ let root: Root function storeWith(conflicts: Conflict[], selConflict: string) { return { + mode: 'demo', query: '', conflicts, selConflict, setSelConflict: vi.fn(), @@ -26,6 +25,10 @@ function storeWith(conflicts: Conflict[], selConflict: string) { resolveSafeConflicts: vi.fn(), resolvingConflict: null, resolutionError: null, + discrepancyRules: [], discrepancyRuleSuggestions: [], + decideDiscrepancy: vi.fn(), setDiscrepancyPriority: vi.fn(), + approveRuleSuggestion: vi.fn(), updateDiscrepancyRule: vi.fn(), promoteDiscrepancyRule: vi.fn(), + openFilesScope: vi.fn(), } } @@ -39,6 +42,10 @@ const freshConflict: Conflict = { winner: 'personal', safe: false, history: [], + kind: 'section_content', discrepancyStatus: 'needs_review', revision: 'rev-1', + effectiveSource: 'personal', winnerReason: 'personal wins by configured layer precedence.', + owner: 'Platform', priority: 'unassigned', coverageComplete: true, + sourceHealth: [{ source: 'personal', status: 'ok', error: null }, { source: 'team', status: 'ok', error: null }], contributions: [ { layer: 'personal', sourceLayer: 'personal', value: 'SingleStore.', updated: '2026-05-12' }, { layer: 'team', sourceLayer: 'team', value: 'Postgres.', updated: '2026-06-01', fresherDissent: true }, @@ -71,6 +78,18 @@ const safeConflict: Conflict = { ], } +const codeConflict: Conflict = { + ...freshConflict, + id: 'interfaces/client::example', + concept: 'interfaces/client', + sectionKey: 'example', + section: 'Example', + contributions: [ + { layer: 'personal', sourceLayer: 'personal', value: 'const port = 3000;\nstart(port);', updated: '2026-05-12' }, + { layer: 'team', sourceLayer: 'team', value: 'const port = 8080;\nstart(port);', updated: '2026-06-01' }, + ], +} + beforeEach(() => { ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') @@ -83,41 +102,58 @@ afterEach(async () => { container.remove() }) -describe('Conflicts fresherDissent badge', () => { +describe('Discrepancy Center', () => { it('badges the flagged dissent card as newer than the effective value', async () => { mocks.useStore.mockReturnValue(storeWith([freshConflict, staleConflict], freshConflict.id)) await act(async () => root.render()) - expect(container.textContent).toContain('Newer') - expect(container.textContent).toContain('Used now') - expect(container.textContent).toContain('Which answer should ContextCake use?') + expect(container.textContent).toContain('Newer dissent') + expect(container.textContent).toContain('Effective now') + expect(container.textContent).toContain('Choose a safe disposition') }) it('shows no freshness badge when no dissent is flagged', async () => { mocks.useStore.mockReturnValue(storeWith([staleConflict], staleConflict.id)) await act(async () => root.render()) - expect(container.textContent).not.toContain('Newer') + expect(Array.from(container.querySelectorAll('.cc-discrepancy-answer')).some((answer) => answer.textContent?.includes('Newer dissent'))).toBe(false) }) - it('offers one batch wand action for safe conflicts', async () => { + it('shows both removed and added prose instead of hiding reordered or deleted words', async () => { + mocks.useStore.mockReturnValue(storeWith([freshConflict], freshConflict.id)) + await act(async () => root.render()) + + expect(container.querySelector('.cc-word-diff del')?.textContent).toContain('SingleStore') + expect(container.querySelector('.cc-word-diff mark')?.textContent).toContain('Postgres') + }) + + it('shows removed and added lines for structured content', async () => { + mocks.useStore.mockReturnValue(storeWith([codeConflict], codeConflict.id)) + await act(async () => root.render()) + + expect(container.querySelector('.cc-line-diff [data-change="removed"]')?.textContent).toContain('3000') + expect(container.querySelector('.cc-line-diff [data-change="added"]')?.textContent).toContain('8080') + }) + + it('labels every demo action as a simulation and never offers automatic execution', async () => { const store = storeWith([safeConflict], safeConflict.id) mocks.useStore.mockReturnValue(store) await act(async () => root.render()) - const wand = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('Resolve 1 safe conflict')) - expect(wand).toBeTruthy() - await act(async () => wand?.click()) - expect(store.resolveSafeConflicts).toHaveBeenCalledOnce() - expect(container.textContent).toContain('Which answer should ContextCake use?') + expect(container.textContent).toContain('Simulate using') + expect(container.textContent).toContain('Simulation history resets on reload.') }) - it('does not claim nothing changed when a batch stopped after earlier resolutions', async () => { - const store = { ...storeWith([safeConflict], safeConflict.id), resolutionError: { message: 'The last source changed.', partial: true } } + it('requires a reason before an acknowledgement can be submitted', async () => { + const store = storeWith([safeConflict], safeConflict.id) mocks.useStore.mockReturnValue(store) await act(async () => root.render()) - - expect(container.textContent).toContain('Some safe conflicts were resolved.') - expect(container.textContent).not.toContain('Nothing was changed.') + const radio = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Keep the scoped difference'))! + await act(async () => radio.click()) + const submit = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('Simulate acknowledgement'))! + expect(submit.disabled).toBe(true) + const reason = container.querySelector('[aria-label="Acknowledgement reason"]')! + await act(async () => { reason.value = 'different_scopes'; reason.dispatchEvent(new Event('change', { bubbles: true })) }) + expect(submit.disabled).toBe(false) }) }) diff --git a/apps/console/src/views/Conflicts.tsx b/apps/console/src/views/Conflicts.tsx index abe177b2..587fde5f 100644 --- a/apps/console/src/views/Conflicts.tsx +++ b/apps/console/src/views/Conflicts.tsx @@ -1,341 +1,284 @@ -import { memo, useEffect, useMemo, useRef, useState } from 'react' -import { C, css, lc, MONO } from '../theme' -import { layerLevel, layerName } from '../data' +import { memo, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import type { Conflict, Contribution } from '../data' -import { LayerChip } from '../components/LayerChip' +import type { AcknowledgementReason, DiscrepancyStatus } from '../types' import { Markdown } from '../components/Markdown' import { useStoreData, useStoreInput, useStoreNav } from '../store' import { useDetailSurface } from '../components/useDetailSurface' -function WandIcon() { - return ( - - ) +const STATUS_LABEL: Record = { + needs_review: 'Needs review', reopened: 'Needs review', recommended: 'Recommendations', + auto_ready: 'Automated', acknowledged: 'Acknowledged', resolved: 'Resolved', blocked: 'Automated', +} +const KIND_LABEL: Record = { + section_content: 'Section content', frontmatter_value: 'Frontmatter value', + broken_link: 'Broken link', changed_after_decision: 'Changed after decision', } +const REASONS: { value: AcknowledgementReason; label: string }[] = [ + { value: 'different_scopes', label: 'Different scopes' }, + { value: 'temporary_migration', label: 'Temporary migration' }, + { value: 'source_specific_authority', label: 'Source-specific authority' }, + { value: 'other', label: 'Other' }, +] -function formatDate(value: string) { - if (!value) return 'date not recorded' +function formatDate(value?: string | null) { + if (!value) return 'Date not recorded' const parsed = new Date(value.includes('T') ? value : `${value}T12:00:00`) - if (Number.isNaN(parsed.getTime())) return value - return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: value.includes('T') ? 'short' : undefined }).format(parsed) + return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: value.includes('T') ? 'short' : undefined }).format(parsed) +} + +function valueKind(value: string) { + return /```|^\s*[{[]|\n\s*[-+]?\s*["'][^\n]+:|\n.*[;{}]$/m.test(value) ? 'structured' : 'prose' +} + +type DiffOperation = { type: 'same' | 'removed' | 'added'; value: string } + +function sequenceDiff(left: string[], right: string[]): DiffOperation[] { + // Bound quadratic work for unusually large answers. The fallback remains + // honest and complete—it shows both originals as removed/added—without + // letting a pathological document freeze the review surface. + if (left.length * right.length > 120_000) { + return [ + ...left.map((value) => ({ type: 'removed' as const, value })), + ...right.map((value) => ({ type: 'added' as const, value })), + ] + } + const width = right.length + 1 + const table = new Uint32Array((left.length + 1) * width) + for (let i = 1; i <= left.length; i += 1) { + for (let j = 1; j <= right.length; j += 1) { + table[i * width + j] = left[i - 1] === right[j - 1] + ? table[(i - 1) * width + j - 1] + 1 + : Math.max(table[(i - 1) * width + j], table[i * width + j - 1]) + } + } + const operations: DiffOperation[] = [] + let i = left.length + let j = right.length + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && left[i - 1] === right[j - 1]) { + operations.push({ type: 'same', value: left[i - 1] }); i -= 1; j -= 1 + } else if (j > 0 && (i === 0 || table[i * width + j - 1] >= table[(i - 1) * width + j])) { + operations.push({ type: 'added', value: right[j - 1] }); j -= 1 + } else { + operations.push({ type: 'removed', value: left[i - 1] }); i -= 1 + } + } + return operations.reverse() +} + +function wordDiff(base: string, alternative: string) { + return sequenceDiff(base.split(/(\s+)/), alternative.split(/(\s+)/)).map((operation, index) => ( + operation.type === 'removed' ? {operation.value} + : operation.type === 'added' ? {operation.value} + : {operation.value} + )) } -function choiceName(choice: Contribution) { - const familiar = layerName(choice.layer) - return choice.sourceLayer === choice.layer ? familiar : `${familiar} · ${choice.sourceLayer}` +function lineDiff(base: string, alternative: string) { + return sequenceDiff(base.split('\n'), alternative.split('\n')).map((operation, index) => ( +
+ {operation.value || ' '} +
+ )) +} + +function Diff({ effective, value }: { effective: string; value: string }) { + if (effective === value) return

Matches the effective answer.

+ return valueKind(value) === 'structured' + ?
{lineDiff(effective, value)}
+ :

{wordDiff(effective, value)}

} -function Choice({ - choice, - conflict, - checked, - disabled, - onChange, -}: { - choice: Contribution - conflict: Conflict - checked: boolean - disabled: boolean - onChange: () => void -}) { - const isEffective = choice.sourceLayer === conflict.contributions[0]?.sourceLayer && conflict.status === 'open' - const col = lc(choice.layer) +function SourceAnswer({ choice, effective, isEffective }: { choice: Contribution; effective: string; isEffective: boolean }) { return ( -