Kairo-Jinkendo/frontend/src/composition/resolveSteeringComposition.js
Lars b7d88ce24a
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 4m54s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
feat(A1): Minimum Usability Slice — CadenceInstance, Today-Übung, Gate-Fortschritt
PO No-Go A1 adressieren: Tages-Tracking mit Messnotiz, Work-UI für Übung, Next-Action-Link, Gate-Fortschritt in Kontrolle, Eingang aus A1-Nav entfernt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:32:23 +02:00

311 lines
9.8 KiB
JavaScript

/**
* AP-UX-1 — UX Composition Resolver (Frontend-Spiegel des Steering Kernels).
*/
import { hasSteeringElement, steeringElementUi } from '../registry/steeringElementRegistry.js'
import { getWidgetByKey } from '../registry/widgetRegistry.js'
import { filterSprintProposals } from '../utils/steeringProposals.js'
import { COMPOSITION_PROVIDERS, getProvidersForSlot } from './compositionProviders.js'
import { COMPOSITION_SURFACES, getCompositionSurface, surfaceKeyFor } from './uiSlotRegistry.js'
/** @typedef {import('./compositionProviders.js').CompositionProviderDefinition} CompositionProviderDefinition */
/**
* @typedef {Object} CompositionInput
* @property {string} [surfaceKey]
* @property {string} [mode]
* @property {string | null} [routeKey]
* @property {'portfolio' | 'initiative'} [scope]
* @property {string[] | null} [steeringElements]
* @property {object | null} [operatingContext]
* @property {object | null} [steeringSnapshot]
* @property {Set<string> | string[]} [capabilities]
* @property {Record<string, unknown>} [filterContext]
* @property {object | null} [opsContext]
*/
/**
* @typedef {CompositionProviderDefinition & { instanceKey: string, props: Record<string, unknown> }} ResolvedProvider
*/
const CONDITIONAL_PREDICATES = {
hasRoadblockers(ctx) {
const counts = ctx.steeringSnapshot?.counts || {}
return (counts.actions_blocked || 0) > 0 || (counts.blockers_open || 0) > 0
},
hasActiveWorkCycle(ctx) {
const cycle =
ctx.steeringSnapshot?.active_work_cycle
|| ctx.opsContext?.activeWorkCycle
|| null
return Boolean(cycle?.id)
},
isProductWithoutActiveSprint(ctx) {
if (CONDITIONAL_PREDICATES.hasActiveWorkCycle(ctx)) return false
const features =
ctx.operatingContext?.ui_features
|| ctx.opsContext?.uiFeatures
|| {}
const slices =
ctx.operatingContext?.data_slices
|| ctx.opsContext?.dataSlices
|| []
return Boolean(features.continuousProductWorkMode && slices.includes('work_cycles'))
},
}
/**
* @param {Set<string> | string[] | undefined} capabilities
* @param {string | undefined} required
*/
function hasCapability(capabilities, required) {
if (!required) return true
const capSet = capabilities instanceof Set ? capabilities : new Set(capabilities || [])
return capSet.has(required)
}
/**
* @param {CompositionInput} input
*/
function resolveSteeringElements(input) {
if (Array.isArray(input.steeringElements)) return input.steeringElements
return input.operatingContext?.steering_elements || []
}
/**
* @param {CompositionInput} input
*/
function resolveKernelProposals(input) {
return input.steeringSnapshot?.steering_kernel?.proposals || {}
}
/**
* @param {CompositionInput} input
*/
function resolveAgentSlots(input) {
return (
input.steeringSnapshot?.agent_slots
|| input.steeringSnapshot?.steering_kernel?.agent_slots
|| []
)
}
/**
* Next-Action-Überschriften: kritischster passender steering_element (Priorität).
* @param {string[]} elements
* @param {object | null | undefined} steeringSnapshot
*/
export function resolveNextActionUi(elements, steeringSnapshot) {
const priority = [
'critical_path',
'recurring_rhythm',
'work_cycle_scope',
'maturity_stage',
'gate_fulfillment',
'queue_inbox',
]
const hasActiveSprint = Boolean(steeringSnapshot?.active_work_cycle)
for (const key of priority) {
if (key === 'work_cycle_scope' && !hasActiveSprint) continue
const ui = steeringElementUi(elements, key)
if (ui?.nextActionTitle) return ui
}
return null
}
/**
* @param {CompositionProviderDefinition} provider
* @param {CompositionInput} input
*/
function isProviderActive(provider, input) {
const scope = input.scope || 'portfolio'
const scopeTypes = provider.scopeTypes || ['portfolio', 'initiative']
if (!scopeTypes.includes(scope)) return false
if (!hasCapability(input.capabilities, provider.requiresCapability)) return false
const elements = resolveSteeringElements(input)
const proposals = resolveKernelProposals(input)
const agentSlots = resolveAgentSlots(input)
switch (provider.kind) {
case 'steering_element':
return hasSteeringElement(elements, provider.steeringElement)
case 'proposal': {
const key = provider.proposalKey
if (!key) return false
let items = proposals[key] || []
if (key === 'sprint_commit' && input.filterContext?.workCycleId) {
items = filterSprintProposals(items, input.filterContext.workCycleId)
}
return items.length > 0
}
case 'agent_slots':
return agentSlots.length > 0
case 'widget': {
const widget = getWidgetByKey(provider.widgetKey)
if (!widget) return false
return hasCapability(input.capabilities, widget.requiredCapability)
}
case 'conditional': {
const fn = CONDITIONAL_PREDICATES[provider.predicateKey]
return typeof fn === 'function' ? fn(input) : false
}
case 'core':
default:
return true
}
}
/**
* @param {CompositionProviderDefinition} provider
* @param {CompositionInput} input
*/
export function buildProviderProps(provider, input) {
const ops = input.opsContext || {}
const proposals = resolveKernelProposals(input)
const agentSlots = resolveAgentSlots(input)
const elements = resolveSteeringElements(input)
const capabilities = input.capabilities instanceof Set
? input.capabilities
: new Set(input.capabilities || [])
switch (provider.componentKey) {
case 'SteeringSnapshotPanel':
return {
snapshot: input.steeringSnapshot,
loading: ops.steeringSnapshotLoading,
error: ops.steeringSnapshotError,
methods: ops.steeringMethods,
canManageMethod: capabilities.has('kairo.initiative.manage'),
onMethodChange: ops.handleMethodChange,
methodBusy: ops.methodBusy,
showGateHorizon: hasSteeringElement(elements, 'gate_fulfillment'),
}
case 'CriticalPathPanel': {
const kernel = input.steeringSnapshot?.steering_kernel
return {
initiativeId: ops.initiativeId,
actions: ops.actions || [],
graphState: kernel?.read_models?.execution_graph ?? null,
graphLoading: ops.steeringSnapshotLoading,
scopeRoadmapItemId: kernel?.horizon?.gate_roadmap_item_id ?? null,
}
}
case 'MaturityStagePanel':
return {
initiativeId: ops.initiativeId,
roadmapItems: ops.roadmapItems || [],
}
case 'RecurringRhythmPanel':
return {
initiativeId: ops.initiativeId,
recurringItems: ops.recurringItems || [],
}
case 'WorkTodayPracticePanel':
return {
initiativeId: ops.initiativeId,
recurringItems: ops.recurringItems || [],
canManage: capabilities.has('kairo.recurring.manage'),
busy: ops.formBusy,
onCompleted: async () => {
if (typeof ops.refreshOm === 'function') {
await ops.refreshOm(['recurring', 'steering_snapshot'])
}
},
}
case 'NextActionWidget': {
const nextActionUi = resolveNextActionUi(elements, input.steeringSnapshot)
return {
scope: 'initiative',
initiativeId: ops.initiativeId,
items: input.steeringSnapshot?.next_actions,
loading: ops.steeringSnapshotLoading,
embedded: true,
title: nextActionUi?.nextActionTitle,
subtitle: nextActionUi?.nextActionSubtitle,
}
}
case 'AgentSlotsPanel':
return {
slots: agentSlots,
loading: ops.steeringSnapshotLoading,
}
case 'RoadblockersStrip': {
const counts = input.steeringSnapshot?.counts || {}
return {
initiativeId: ops.initiativeId,
actionsBlocked: counts.actions_blocked || 0,
blockersOpen: counts.blockers_open || 0,
}
}
case 'SteeringProposalsPanel':
return {
proposalsByKey: proposals,
proposalKeys: provider.proposalKey ? [provider.proposalKey] : undefined,
filterContext: input.filterContext || {},
backlogVocabulary: input.operatingContext?.backlog_vocabulary,
canManage: capabilities.has('kairo.backlog.manage'),
onAcceptBacklogProposal: ops.onAcceptBacklogProposal,
busy: ops.formBusy,
}
case 'WidgetHost': {
const widget = getWidgetByKey(provider.widgetKey)
return { widget }
}
case 'WorkActionsPanel':
return {
variant: provider.workActionsVariant || 'today',
defaultCollapsed: true,
}
default:
return {}
}
}
/**
* @param {CompositionProviderDefinition} provider
* @param {CompositionInput} input
* @returns {ResolvedProvider | null}
*/
export function resolveProviderInstance(provider, input) {
if (!isProviderActive(provider, input)) return null
return {
...provider,
instanceKey: `${provider.key}:${provider.slotKeys[0]}`,
props: buildProviderProps(provider, input),
}
}
/**
* @param {string} slotKey
* @param {CompositionInput} input
* @returns {ResolvedProvider[]}
*/
export function resolveSlotComposition(slotKey, input) {
return getProvidersForSlot(slotKey)
.map((provider) => resolveProviderInstance(provider, input))
.filter(Boolean)
}
/**
* @param {CompositionInput} input
*/
export function resolveSteeringComposition(input) {
const surfaceKey = input.surfaceKey
|| surfaceKeyFor(/** @type {import('./uiSlotRegistry.js').CompositionMode} */ (input.mode), input.routeKey ?? null)
const surface = getCompositionSurface(surfaceKey)
if (!surface) {
return { surfaceKey, slots: {} }
}
/** @type {Record<string, ResolvedProvider[]>} */
const slots = {}
for (const slotKey of surface.slotKeys) {
slots[slotKey] = resolveSlotComposition(slotKey, input)
}
return { surfaceKey, slots }
}
/** Exported for tests */
export { COMPOSITION_PROVIDERS, isProviderActive, CONDITIONAL_PREDICATES }