diff --git a/backend/tests/test_ap22b_linear_e2e.py b/backend/tests/test_ap22b_linear_e2e.py
new file mode 100644
index 0000000..c3c32ab
--- /dev/null
+++ b/backend/tests/test_ap22b_linear_e2e.py
@@ -0,0 +1,238 @@
+"""AP2.2b — A2 linear project End-to-End (Neue Küche Happy Path)."""
+
+from __future__ import annotations
+
+from tests.factories import provision_user_in_tenant
+from tests.test_initiatives_actions import _auth, _create_initiative, _login
+
+_STARTER_ACTION = "Erster Schritt — Planung konkretisieren"
+_GATE_TITLES = (
+ "G1 — Planung",
+ "G2 — Vorbereitung",
+ "G3 — Umsetzung",
+ "G4 — Abschluss",
+)
+
+
+def _gate_by_title(items: list[dict], prefix: str) -> dict:
+ return next(i for i in items if i.get("title", "").startswith(prefix))
+
+
+def _create_kitchen(client, token):
+ created = _create_initiative(
+ client,
+ token,
+ title="Neue Küche",
+ archetype_key="initiative.linear_project",
+ )
+ assert created.status_code == 201
+ body = created.json()
+ assert body["starter_kit"]["applied"] is True
+ assert body["archetype_key"] == "initiative.linear_project"
+ return body["id"]
+
+
+def test_a2_starter_kit_kitchen_structure(client):
+ """Starter-Kit: Gates in Kette, Projects, Guidance, Operating Context."""
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+ initiative_id = _create_kitchen(client, token)
+
+ ctx = client.get(
+ f"/api/initiatives/{initiative_id}/operating-context",
+ headers=_auth(token),
+ )
+ assert ctx.status_code == 200
+ body = ctx.json()
+ assert body["method_key"] == "sequential_dependency"
+ assert "critical_path" in body["steering_elements"]
+ assert "gate_fulfillment" in body["steering_elements"]
+ assert body["ui_profile"]["planDefaultRoute"] == "/plan/gates"
+ assert body["ui_profile"]["controlDefaultRoute"] == "/control/status"
+
+ roadmap = client.get(
+ f"/api/initiatives/{initiative_id}/roadmap/items",
+ headers=_auth(token),
+ )
+ assert roadmap.status_code == 200
+ items = roadmap.json()
+ assert len(items) == 4
+ assert {i["title"] for i in items} == set(_GATE_TITLES)
+ assert sum(1 for i in items if i["status"] == "active") == 1
+ assert _gate_by_title(items, "G1")["status"] == "active"
+
+ deps = client.get(
+ f"/api/initiatives/{initiative_id}/roadmap/dependencies",
+ headers=_auth(token),
+ )
+ assert deps.status_code == 200
+ gate_ids = {i["title"]: i["id"] for i in items}
+ dep_pairs = {(d["from_item_id"], d["to_item_id"]) for d in deps.json()}
+ assert (gate_ids["G2 — Vorbereitung"], gate_ids["G1 — Planung"]) in dep_pairs
+ assert (gate_ids["G3 — Umsetzung"], gate_ids["G2 — Vorbereitung"]) in dep_pairs
+ assert (gate_ids["G4 — Abschluss"], gate_ids["G3 — Umsetzung"]) in dep_pairs
+
+ projects = client.get(
+ f"/api/initiatives/{initiative_id}/projects",
+ headers=_auth(token),
+ )
+ assert projects.status_code == 200
+ assert {"Hauptpfad", "Begleitung"} <= {p["title"] for p in projects.json()}
+
+ actions = client.get(
+ f"/api/initiatives/{initiative_id}/actions",
+ headers=_auth(token),
+ )
+ assert actions.status_code == 200
+ assert any(a["title"] == _STARTER_ACTION for a in actions.json())
+
+ snap = client.get(
+ f"/api/initiatives/{initiative_id}/steering-snapshot",
+ headers=_auth(token),
+ )
+ assert snap.status_code == 200
+ snap_body = snap.json()
+ assert snap_body["steering_guidance"]
+ assert snap_body["steering_kernel"]["primary_method_key"] == "sequential_dependency"
+ assert snap_body["steering_kernel"]["horizon"]["kind"] == "gate"
+ next_actions = snap_body.get("next_actions") or []
+ assert next_actions
+ starter = next(a for a in actions.json() if a["title"] == _STARTER_ACTION)
+ assert next_actions[0]["action_id"] == starter["id"]
+ assert next_actions[0]["reason_code"] in (
+ "execution_ready",
+ "execution_critical_path",
+ )
+
+
+def test_a2_kitchen_critical_path_complete_advances_next(client):
+ """Montage-Kette am aktiven Gate: kritischer Pfad, Next mit Begründung, Fortschritt."""
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+ initiative_id = _create_kitchen(client, token)
+
+ actions = client.get(
+ f"/api/initiatives/{initiative_id}/actions",
+ headers=_auth(token),
+ )
+ starter = next(a for a in actions.json() if a["title"] == _STARTER_ACTION)
+ deleted = client.delete(
+ f"/api/actions/{starter['id']}",
+ headers=_auth(token),
+ )
+ assert deleted.status_code == 204
+
+ roadmap = client.get(
+ f"/api/initiatives/{initiative_id}/roadmap/items",
+ headers=_auth(token),
+ )
+ g1_id = _gate_by_title(roadmap.json(), "G1")["id"]
+
+ titles = ["Geräte geliefert", "Einbau Montage", "Endabnahme Küche"]
+ action_ids: list[str] = []
+ for index, title in enumerate(titles):
+ res = client.post(
+ f"/api/initiatives/{initiative_id}/actions",
+ json={
+ "title": title,
+ "status": "open",
+ "roadmap_item_id": g1_id,
+ "sort_order": index,
+ },
+ headers=_auth(token),
+ )
+ assert res.status_code == 201
+ action_ids.append(res.json()["id"])
+
+ for pred, succ in zip(action_ids[:-1], action_ids[1:]):
+ dep = client.post(
+ f"/api/initiatives/{initiative_id}/execution/dependencies",
+ json={
+ "predecessor_action_id": pred,
+ "successor_action_id": succ,
+ "dependency_kind": "requires",
+ },
+ headers=_auth(token),
+ )
+ assert dep.status_code == 201
+
+ graph = client.get(
+ f"/api/initiatives/{initiative_id}/execution/graph-state",
+ headers=_auth(token),
+ )
+ assert graph.status_code == 200
+ graph_body = graph.json()
+ assert graph_body["critical_path"] == action_ids
+ assert action_ids[0] in graph_body["ready_actions"]
+ assert action_ids[1] in graph_body["blocked_actions"]
+
+ snap = client.get(
+ f"/api/initiatives/{initiative_id}/steering-snapshot",
+ headers=_auth(token),
+ )
+ assert snap.status_code == 200
+ snap_body = snap.json()
+ kernel = snap_body["steering_kernel"]
+ exec_graph = kernel["read_models"].get("execution_graph") or {}
+ assert exec_graph.get("critical_path") == action_ids
+
+ next_actions = snap_body.get("next_actions") or []
+ assert next_actions
+ top = next_actions[0]
+ assert top["action_id"] == action_ids[0]
+ assert top["reason_code"] in ("execution_ready", "execution_critical_path")
+
+ done = client.patch(
+ f"/api/actions/{action_ids[0]}",
+ json={"status": "done"},
+ headers=_auth(token),
+ )
+ assert done.status_code == 200
+
+ snap_after = client.get(
+ f"/api/initiatives/{initiative_id}/steering-snapshot",
+ headers=_auth(token),
+ )
+ assert snap_after.status_code == 200
+ next_after = snap_after.json().get("next_actions") or []
+ assert next_after
+ assert next_after[0]["action_id"] == action_ids[1]
+ assert next_after[0]["reason_code"] in (
+ "execution_ready",
+ "execution_critical_path",
+ )
+
+
+def test_a2_kitchen_planning_debt_surfaces_gate_proposals(client):
+ """Aktives Gate ohne Actions → Planning Debt + gate_next_actions Proposal."""
+ user = provision_user_in_tenant(tenant_role="admin")
+ token = _login(client, user)
+ initiative_id = _create_kitchen(client, token)
+
+ actions = client.get(
+ f"/api/initiatives/{initiative_id}/actions",
+ headers=_auth(token),
+ )
+ starter = next(a for a in actions.json() if a["title"] == _STARTER_ACTION)
+ deleted = client.delete(
+ f"/api/actions/{starter['id']}",
+ headers=_auth(token),
+ )
+ assert deleted.status_code == 204
+
+ snap = client.get(
+ f"/api/initiatives/{initiative_id}/steering-snapshot",
+ headers=_auth(token),
+ )
+ assert snap.status_code == 200
+ snap_body = snap.json()
+ planning_debt = snap_body["steering_kernel"]["read_models"].get("planning_debt") or []
+ assert planning_debt
+ assert any(d.get("kind") == "missing_execution_plan" for d in planning_debt)
+
+ gate_proposals = snap_body.get("gate_next_actions_proposals") or []
+ assert gate_proposals
+ assert any(p.get("reason_code") == "planning_debt" for p in gate_proposals)
+
+ attention_codes = {a.get("code") for a in snap_body.get("attention_items") or []}
+ assert "planning_debt" in attention_codes
diff --git a/docs/architecture/ADP_UX_Composition_Kernel_v0.1.md b/docs/architecture/ADP_UX_Composition_Kernel_v0.1.md
new file mode 100644
index 0000000..4285b2c
--- /dev/null
+++ b/docs/architecture/ADP_UX_Composition_Kernel_v0.1.md
@@ -0,0 +1,182 @@
+# ADP — UX Composition Kernel v0.1 (AP-UX-0 / AP-UX-1)
+
+**Status:** PO-Arbeitsentwurf — **MVP geliefert** (2026-07-27)
+**Stand:** 2026-07-27
+**Bezug:** `ADP_Steering_Kernel_Extension_Model_v0.1.md`, `ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, `Kairo_PM_Frontend_UI_Concept_v0.1.md`, `ARCHETYPE_UI_NAVIGATION_v0.1.md`
+
+---
+
+## 1. Problem
+
+Das Backend hat mit dem **Steering Kernel v0.4** einen zentralen Steuerungs-Einstieg (`evaluate_steering()`). Provider für Read Models, Proposals und Agent-Slots hängen an **registrierten Keys** und `steering_elements` — nicht an Archetyp-Ifs.
+
+Das Frontend spiegelt das **noch nicht zentral**:
+
+| Baustein | Heute | Soll |
+|----------|-------|------|
+| Control-Panels | `InitiativeOverviewPage` mit manuellen Ifs | UI-Slots + Provider |
+| Proposals | `SteeringProposalsPanel` direkt in Plan-Pages | Slot `plan.*.proposals` |
+| Agent-Slots | Direktimport in Overview | Slot `control.status.agent` |
+| Cockpit-Widgets | `widgetRegistry` + manuelle Grid-Schleife | Slot `cockpit.main` |
+| Steuerungselement-Registry | Labels/Hints only | Aktiviert Composition-Provider |
+
+**Leitfrage:** Wie composen Cockpit, Control und Plan konsistent aus dem gleichen Modell — analog zum Steering Kernel?
+
+---
+
+## 2. Entscheidung — Vier Schichten (Frontend-Spiegel)
+
+```text
+Operating Context + Steering Snapshot
+ ↓
+resolveSteeringComposition(mode, routeKey, scope, context)
+ ↓
+UI-Slots (z.B. control.status.steering, plan.inbox.proposals)
+ ↓
+registrierte Provider (steering_element | proposal | agent_slots | widget | core)
+ ↓
+React-Komponenten (generisch, keine Archetyp-Ifs)
+```
+
+| Schicht | Registry | Liefert |
+|---------|----------|---------|
+| **UI-Slot** | `composition/uiSlotRegistry.js` | Benannte Flächen pro Modus/Route |
+| **Provider** | `composition/compositionProviders.js` | Match-Regeln + Slot-Zuordnung |
+| **Resolver** | `composition/resolveSteeringComposition.js` | Aktive Provider pro Slot zur Laufzeit |
+| **Renderer** | `CompositionSlot.jsx`, `providerComponents.jsx` | Generisches Rendering |
+
+**Parallele zum Backend:**
+
+| Backend (Steering Kernel) | Frontend (UX Composition) |
+|---------------------------|---------------------------|
+| `steering_elements` | Provider `kind: steering_element` |
+| `proposals[key]` | Provider `kind: proposal` |
+| `agent_slots[]` | Provider `kind: agent_slots` |
+| Portfolio-Aggregation | Provider `kind: widget` (Cockpit) |
+| Snapshot-Kern | Provider `kind: core` |
+
+---
+
+## 3. UI-Slots (MVP)
+
+Slots sind **stabil benannte Flächen** — unabhängig von React-Page-Struktur.
+
+| Slot-Key | Modus | Route | Inhalt (MVP) |
+|----------|-------|-------|--------------|
+| `cockpit.main` | cockpit | — | Portfolio-Widgets |
+| `control.status.core` | control | status | Steering-Snapshot |
+| `control.status.steering` | control | status | Kritischer Pfad, Next Action |
+| `control.status.agent` | control | status | Agent-Slots |
+| `control.status.alerts` | control | status | Roadblocker-Strip |
+| `plan.inbox.proposals` | plan | inbox | Triage-Vorschläge |
+| `plan.sprint.proposals` | plan | sprint | Sprint-Commit-Vorschläge |
+| `plan.gates.proposals` | plan | gates | Gate-Vorschläge |
+
+**Erweiterung (post-MVP):** `control.plan-ist.read_models`, `plan.inbox.read_models`, Work-Modus-Slots.
+
+---
+
+## 4. Provider-Vertrag
+
+```javascript
+{
+ key: 'steering.critical_path',
+ kind: 'steering_element', // steering_element | proposal | agent_slots | widget | core | conditional
+ steeringElement: 'critical_path',
+ slotKeys: ['control.status.steering'],
+ componentKey: 'CriticalPathPanel',
+ requiresCapability: 'kairo.action.read',
+ scopeTypes: ['initiative'], // portfolio | initiative
+ order: 10,
+}
+```
+
+### Match-Regeln
+
+| kind | Aktiv wenn |
+|------|------------|
+| `steering_element` | `steering_elements` enthält Key |
+| `proposal` | `steering_kernel.proposals[key]` nicht leer (+ optional `filterContext`) |
+| `agent_slots` | `agent_slots` oder `steering_kernel.agent_slots` nicht leer |
+| `widget` | Capability erfüllt; Widget aus `widgetRegistry` |
+| `core` | Immer auf Surface (Scope + Capability) |
+| `conditional` | Custom predicate (z.B. Roadblocker counts > 0) |
+
+**Verboten:** `archetype_key === '…'` in Pages — nur Provider-Match über Operating Context.
+
+---
+
+## 5. Auflösung zur Laufzeit
+
+```javascript
+resolveSteeringComposition({
+ mode: 'control',
+ routeKey: 'status',
+ scope: 'initiative',
+ operatingContext,
+ steeringSnapshot,
+ capabilities,
+ filterContext: {},
+ // Initiative-Ops für Props:
+ opsContext: { initiativeId, actions, ... },
+})
+// → { surfaceKey: 'control.status', slots: { 'control.status.core': [ProviderInstance, ...], ... } }
+```
+
+Surfaces sind deklarativ in `COMPOSITION_SURFACES` — eine Page rendert `` statt ad-hoc Imports.
+
+---
+
+## 6. Frontend-Dateien (MVP)
+
+```text
+frontend/src/composition/
+ uiSlotRegistry.js — Slot- + Surface-Definitionen
+ compositionProviders.js — Provider-Registry
+ resolveSteeringComposition.js — Resolver + buildProviderProps
+ resolveSteeringComposition.test.js
+ providerComponents.jsx — Component-Map + Prop-Adapter
+ CompositionSlot.jsx — Slot-Renderer
+ CompositionSurface.jsx — Surface-Renderer (mehrere Slots)
+ useSteeringComposition.js — Hook (InitiativeOperationsContext)
+```
+
+---
+
+## 7. Migration (AP-UX-1)
+
+| Page | Vorher | Nachher |
+|------|--------|---------|
+| `InitiativeOverviewPage` | Manuelle Panel-Ifs | `CompositionSurface surfaceKey="control.status"` |
+| `InitiativeInboxPage` | Direkt `SteeringProposalsPanel` | Slot `plan.inbox.proposals` |
+| `PlanSprintPage` | Direkt `SteeringProposalsPanel` | Slot `plan.sprint.proposals` |
+| `InitiativePlanPage` | Direkt `SteeringProposalsPanel` | Slot `plan.gates.proposals` |
+| `CockpitPage` | `getWidgetsForArea` Schleife | Slot `cockpit.main` via Composition |
+
+**Nicht migriert (post-MVP):** `PlanIstPanel`, `BacklogSection`, `GatesPlanPanel` — Domain-CRUD bleibt außerhalb Composition (P4: Übersicht entscheidet, Detail pflegt).
+
+---
+
+## 8. Abnahme (AP-UX-1)
+
+- [ ] `resolveSteeringComposition` Unit-Tests für alle Provider-Kinds
+- [ ] Control/Plan-Pages ohne direkte `SteeringProposalsPanel`/`AgentSlotsPanel`-Imports
+- [ ] Keine neuen Archetyp-Ifs in migrierten Pages
+- [ ] Vitest grün (`npm test`)
+- [ ] Dev-Deploy: Cockpit, Control/status, Plan/inbox/sprint/gates funktional
+
+---
+
+## 9. Referenzen
+
+| Artefakt | Pfad |
+|----------|------|
+| Steering Element Registry | `frontend/src/registry/steeringElementRegistry.js` |
+| Proposal UI Config | `frontend/src/utils/steeringProposals.js` |
+| Widget Registry | `frontend/src/registry/widgetRegistry.js` |
+| Operating Profile | `frontend/src/registry/resolveOperatingProfile.js` |
+| Steering Kernel Coding Rules | `docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md` |
+
+---
+
+*AP-UX-0 = dieses ADP. AP-UX-1 = MVP-Implementierung unter `frontend/src/composition/`.*
diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
index 40de883..4cc3b2c 100644
--- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
+++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
@@ -157,7 +157,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Archetyp | Anlage+Kit | Struktur pflegen | Kontrolle | Op-API | Paket |
|----------|------------|------------------|-----------|--------|-------|
| A1 Reifegrad | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2c + AP2.0e |
-| A2 Linear | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2b |
+| A2 Linear | ✓ AP2.2a | ✓ | ✓ | ◐ | AP2.2b ✓ |
| B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d ✓ |
| B3 Sprint-Profil | ◐ | ◐ | ◐ | ◐ | AP2.2d ✓ |
| B2a Programm | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2e |
@@ -230,7 +230,8 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1
| AP2.2a | Starter-Kits ◐→✓ |
| ADP Backlog/Epic | P1–P8 ✓ · K-Ext-1/2/3/4/5 ✓ |
| Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) |
-| AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ |
+| AP2.2b | A2 Linear E2E ✓ (2026-07-27) |
+| AP2.2c–e | Referenz-Archetypen End-to-End ◐→✓ |
| AP2.1 | MVP-Abnahfe ✗→✓ |
| AP1.7b | Op-API Parität |
diff --git a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md
index f020d56..c3680c4 100644
--- a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md
+++ b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md
@@ -79,7 +79,7 @@ Phase 0b AP2.3/AP2.4 Plugin-Architektur (Archetyp ↔ Methode) ✓
Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓
Phase 1b Specs Welle 1 finalisieren (A1,A2,B2a,B2b,B3) ◐
Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring)
-Phase 3 AP2.2b A2 Linear End-to-End ◐
+Phase 3 AP2.2b A2 Linear End-to-End ✓
AP2.2c A1 Reifegrad + AP2.0e ◐ Code
AP2.2d B2b Product + B3 Sprint ✓
AP2.2e B2a Programm (optional vor AP2.1)
diff --git a/frontend/src/components/CriticalPathPanel.jsx b/frontend/src/components/CriticalPathPanel.jsx
index bd06c93..b03404d 100644
--- a/frontend/src/components/CriticalPathPanel.jsx
+++ b/frontend/src/components/CriticalPathPanel.jsx
@@ -8,7 +8,6 @@ import { StatusBadge } from './StatusBadge.jsx'
import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx'
import {
buildCriticalPathSteps,
- findNextReadyOnCriticalPath,
summarizeCriticalPath,
} from '../utils/executionGraph.js'
import { actionPath, scopedPath } from '../utils/routes.js'
@@ -16,39 +15,47 @@ import { actionPath, scopedPath } from '../utils/routes.js'
export function CriticalPathPanel({
initiativeId,
actions = [],
+ graphState: kernelGraphState = null,
+ graphLoading = false,
+ scopeRoadmapItemId = null,
embedded = true,
}) {
- const [graphState, setGraphState] = useState(null)
- const [loading, setLoading] = useState(true)
+ const useKernelGraph = kernelGraphState != null || graphLoading
+ const [fallbackGraphState, setFallbackGraphState] = useState(null)
+ const [fallbackLoading, setFallbackLoading] = useState(!useKernelGraph)
const [error, setError] = useState(null)
useEffect(() => {
- if (!initiativeId) {
- setGraphState(null)
- setLoading(false)
+ if (!initiativeId || useKernelGraph) {
+ setFallbackGraphState(null)
+ setFallbackLoading(false)
return undefined
}
let cancelled = false
- setLoading(true)
+ setFallbackLoading(true)
setError(null)
- getInitiativeExecutionGraphState(initiativeId)
+ getInitiativeExecutionGraphState(initiativeId, {
+ scopeRoadmapItemId: scopeRoadmapItemId || undefined,
+ })
.then((data) => {
- if (!cancelled) setGraphState(data)
+ if (!cancelled) setFallbackGraphState(data)
})
.catch((err) => {
if (!cancelled) setError(err.message)
})
.finally(() => {
- if (!cancelled) setLoading(false)
+ if (!cancelled) setFallbackLoading(false)
})
return () => {
cancelled = true
}
- }, [initiativeId])
+ }, [initiativeId, scopeRoadmapItemId, useKernelGraph])
+
+ const graphState = useKernelGraph ? kernelGraphState : fallbackGraphState
+ const loading = useKernelGraph ? graphLoading : fallbackLoading
const steps = buildCriticalPathSteps(graphState, actions)
const summary = summarizeCriticalPath(graphState, actions)
- const nextReady = findNextReadyOnCriticalPath(graphState, actions)
const body = (
<>
@@ -57,26 +64,11 @@ export function CriticalPathPanel({
window.location.reload()} />
)}
{!loading && !error && steps.length === 0 && (
-
+
)}
{!loading && !error && steps.length > 0 && (
<>
{summary.message}
- {nextReady && (
-
-
Nächster Schritt
-
{nextReady.action.title}
-
- Kritischer Pfad — ausführungsbereit, keine blockierenden Vorgänger.
-
-
- Arbeitspaket öffnen
-
-
- )}
{steps.map((step) => (
-
Kritischer Pfad
- Längster Abhängigkeitspfad aus dem Execution Graph — Steuerungsantwort für
- lineare Vorhaben (A2).
+ Abhängigkeitspfad im aktiven Gate-Horizont — Steuerungsantwort für lineare
+ Vorhaben (A2).
diff --git a/frontend/src/components/SteeringSnapshotPanel.jsx b/frontend/src/components/SteeringSnapshotPanel.jsx
index 4bf88e3..74e85fb 100644
--- a/frontend/src/components/SteeringSnapshotPanel.jsx
+++ b/frontend/src/components/SteeringSnapshotPanel.jsx
@@ -1,13 +1,10 @@
import {
LIFECYCLE_LABELS,
SIGNAL_LABELS,
- NEXT_ACTION_KIND_LABELS,
} from '../constants/operating.js'
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
import { methodDescriptionForKey } from '../utils/archetypes.js'
import { ArchetypeSteeringHints } from './ArchetypeSteeringHints.jsx'
-import { Link } from 'react-router-dom'
-import { scopedPath } from '../utils/routes.js'
function formatDate(iso) {
if (!iso) return '—'
@@ -26,6 +23,7 @@ export function SteeringSnapshotPanel({
canManageMethod = false,
onMethodChange,
methodBusy = false,
+ showGateHorizon = false,
}) {
if (loading) {
return (
@@ -60,7 +58,6 @@ export function SteeringSnapshotPanel({
signals = [],
counts,
upcoming_milestones = [],
- next_actions = [],
active_work_cycle = null,
} = snapshot
const displayLabel =
@@ -77,25 +74,6 @@ export function SteeringSnapshotPanel({
const methodDescription = methodDescriptionForKey(method_key, methods)
- function nextActionLink(item) {
- if (item.action_id) {
- return { to: `/actions/${item.action_id}`, label: 'Arbeitspaket öffnen' }
- }
- if (item.backlog_item_id && item.initiative_id) {
- return {
- to: scopedPath('/plan/inbox', { initiativeId: item.initiative_id }),
- label: 'Zum Eingang',
- }
- }
- if (item.kind === 'create_action' && item.initiative_id) {
- return {
- to: scopedPath('/plan/work', { initiativeId: item.initiative_id }),
- label: 'Arbeitspaket anlegen',
- }
- }
- return null
- }
-
return (
@@ -182,35 +160,8 @@ export function SteeringSnapshotPanel({
)}
-
- {next_actions.length > 0 && (
-
-
Nächste Schritte
-
- {next_actions.map((item, i) => {
- const link = nextActionLink(item)
- return (
- -
-
- {i + 1}
- {item.title}
-
- {NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
- {item.recommended_action ? ` — ${item.recommended_action}` : ''}
-
-
- {link && (
-
- {link.label}
-
- )}
-
- )})}
-
-
- )}
-
- {upcoming_milestones.length > 0 && (
+ {(showGateHorizon && upcoming_milestones.length > 0) && (
+
Meilenstein-Horizont
@@ -227,8 +178,8 @@ export function SteeringSnapshotPanel({
))}
- )}
-
+
+ )}
{(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && (
diff --git a/frontend/src/composition/CompositionSlot.jsx b/frontend/src/composition/CompositionSlot.jsx
new file mode 100644
index 0000000..3afcd21
--- /dev/null
+++ b/frontend/src/composition/CompositionSlot.jsx
@@ -0,0 +1,19 @@
+import { getProviderComponent } from './providerComponents.jsx'
+
+/**
+ * Rendert alle Provider-Instanzen eines UI-Slots.
+ * @param {{ slotKey: string, providers?: import('./resolveSteeringComposition.js').ResolvedProvider[], className?: string }} props
+ */
+export function CompositionSlot({ slotKey, providers = [], className }) {
+ if (!providers.length) return null
+
+ return (
+
+ {providers.map((provider) => {
+ const Component = getProviderComponent(provider.componentKey)
+ if (!Component) return null
+ return
+ })}
+
+ )
+}
diff --git a/frontend/src/composition/CompositionSurface.jsx b/frontend/src/composition/CompositionSurface.jsx
new file mode 100644
index 0000000..09f6477
--- /dev/null
+++ b/frontend/src/composition/CompositionSurface.jsx
@@ -0,0 +1,60 @@
+import { useMemo } from 'react'
+import { CompositionSlot } from './CompositionSlot.jsx'
+import { resolveSteeringComposition } from './resolveSteeringComposition.js'
+import { getCompositionSurface } from './uiSlotRegistry.js'
+
+/**
+ * Rendert alle Slots einer Composition-Surface.
+ * @param {{
+ * surfaceKey: string,
+ * scope?: 'portfolio' | 'initiative',
+ * operatingContext?: object | null,
+ * steeringSnapshot?: object | null,
+ * capabilities?: Set | string[],
+ * filterContext?: Record,
+ * opsContext?: object | null,
+ * slotClassName?: string,
+ * wrapperClassName?: string,
+ * }} props
+ */
+export function CompositionSurface({
+ surfaceKey,
+ scope = 'initiative',
+ operatingContext = null,
+ steeringSnapshot = null,
+ capabilities = new Set(),
+ filterContext = {},
+ opsContext = null,
+ slotClassName,
+ wrapperClassName,
+}) {
+ const composition = useMemo(
+ () =>
+ resolveSteeringComposition({
+ surfaceKey,
+ scope,
+ operatingContext,
+ steeringSnapshot,
+ capabilities,
+ filterContext,
+ opsContext,
+ }),
+ [surfaceKey, scope, operatingContext, steeringSnapshot, capabilities, filterContext, opsContext],
+ )
+
+ const surface = getCompositionSurface(surfaceKey)
+ if (!surface) return null
+
+ return (
+
+ {surface.slotKeys.map((slotKey) => (
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/composition/InitiativeCompositionSurface.jsx b/frontend/src/composition/InitiativeCompositionSurface.jsx
new file mode 100644
index 0000000..43bb295
--- /dev/null
+++ b/frontend/src/composition/InitiativeCompositionSurface.jsx
@@ -0,0 +1,62 @@
+import { useMemo } from 'react'
+import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
+import { CompositionSurface } from './CompositionSurface.jsx'
+
+/**
+ * Composition-Surface mit InitiativeOperationsContext — für scoped Plan/Control-Pages.
+ * @param {{
+ * surfaceKey: string,
+ * filterContext?: Record,
+ * onAcceptBacklogProposal?: (itemId: string, options?: object) => Promise | void,
+ * slotClassName?: string,
+ * wrapperClassName?: string,
+ * }} props
+ */
+export function InitiativeCompositionSurface({
+ surfaceKey,
+ filterContext = {},
+ onAcceptBacklogProposal,
+ slotClassName,
+ wrapperClassName,
+}) {
+ const ops = useInitiativeOperations()
+
+ const opsContext = useMemo(
+ () => ({
+ initiativeId: ops.initiativeId,
+ actions: ops.actions,
+ steeringSnapshotLoading: ops.steeringSnapshotLoading,
+ steeringSnapshotError: ops.steeringSnapshotError,
+ steeringMethods: ops.steeringMethods,
+ handleMethodChange: ops.handleMethodChange,
+ methodBusy: ops.methodBusy,
+ formBusy: ops.formBusy,
+ onAcceptBacklogProposal,
+ }),
+ [
+ ops.initiativeId,
+ ops.actions,
+ ops.steeringSnapshotLoading,
+ ops.steeringSnapshotError,
+ ops.steeringMethods,
+ ops.handleMethodChange,
+ ops.methodBusy,
+ ops.formBusy,
+ onAcceptBacklogProposal,
+ ],
+ )
+
+ return (
+
+ )
+}
diff --git a/frontend/src/composition/compositionProviders.js b/frontend/src/composition/compositionProviders.js
new file mode 100644
index 0000000..98b5244
--- /dev/null
+++ b/frontend/src/composition/compositionProviders.js
@@ -0,0 +1,135 @@
+/**
+ * AP-UX-1 — Composition-Provider-Registry.
+ * Provider hängen an UI-Slots; Aktivierung über steering_elements, proposals, widgets, …
+ */
+
+import { WIDGETS } from '../registry/widgetRegistry.js'
+
+/**
+ * @typedef {'steering_element' | 'proposal' | 'agent_slots' | 'widget' | 'core' | 'conditional'} ProviderKind
+ */
+
+/**
+ * @typedef {Object} CompositionProviderDefinition
+ * @property {string} key
+ * @property {ProviderKind} kind
+ * @property {string[]} slotKeys
+ * @property {string} componentKey
+ * @property {number} [order]
+ * @property {string} [requiresCapability]
+ * @property {('portfolio' | 'initiative')[]} [scopeTypes]
+ * @property {string} [steeringElement]
+ * @property {string} [proposalKey]
+ * @property {string} [widgetKey]
+ * @property {string} [predicateKey]
+ */
+
+const WIDGET_PROVIDERS = WIDGETS.filter((w) => w.area === 'workspace').map((widget) => ({
+ key: `widget.${widget.key}`,
+ kind: /** @type {ProviderKind} */ ('widget'),
+ widgetKey: widget.key,
+ slotKeys: ['cockpit.main'],
+ componentKey: 'WidgetHost',
+ order: widget.defaultOrder,
+}))
+
+/** @type {CompositionProviderDefinition[]} */
+export const COMPOSITION_PROVIDERS = [
+ ...WIDGET_PROVIDERS,
+
+ // —— Control / Status ——
+ {
+ key: 'core.steering_snapshot',
+ kind: 'core',
+ slotKeys: ['control.status.core'],
+ componentKey: 'SteeringSnapshotPanel',
+ requiresCapability: 'kairo.initiative.read',
+ scopeTypes: ['initiative'],
+ order: 0,
+ },
+ {
+ key: 'steering.critical_path',
+ kind: 'steering_element',
+ steeringElement: 'critical_path',
+ slotKeys: ['control.status.steering'],
+ componentKey: 'CriticalPathPanel',
+ requiresCapability: 'kairo.action.read',
+ scopeTypes: ['initiative'],
+ order: 10,
+ },
+ {
+ key: 'steering.next_action',
+ kind: 'steering_element',
+ steeringElement: 'next_action_primary',
+ slotKeys: ['control.status.steering'],
+ componentKey: 'NextActionWidget',
+ requiresCapability: 'kairo.workspace.read',
+ scopeTypes: ['initiative'],
+ order: 20,
+ },
+ {
+ key: 'agent.slots',
+ kind: 'agent_slots',
+ slotKeys: ['control.status.agent'],
+ componentKey: 'AgentSlotsPanel',
+ requiresCapability: 'kairo.initiative.read',
+ scopeTypes: ['initiative'],
+ order: 0,
+ },
+ {
+ key: 'conditional.roadblockers',
+ kind: 'conditional',
+ predicateKey: 'hasRoadblockers',
+ slotKeys: ['control.status.alerts'],
+ componentKey: 'RoadblockersStrip',
+ scopeTypes: ['initiative'],
+ order: 0,
+ },
+
+ // —— Plan / Proposals ——
+ {
+ key: 'proposal.intake_triage',
+ kind: 'proposal',
+ proposalKey: 'intake_triage',
+ slotKeys: ['plan.inbox.proposals'],
+ componentKey: 'SteeringProposalsPanel',
+ requiresCapability: 'kairo.initiative.read',
+ scopeTypes: ['initiative'],
+ order: 0,
+ },
+ {
+ key: 'proposal.sprint_commit',
+ kind: 'proposal',
+ proposalKey: 'sprint_commit',
+ slotKeys: ['plan.sprint.proposals'],
+ componentKey: 'SteeringProposalsPanel',
+ requiresCapability: 'kairo.initiative.read',
+ scopeTypes: ['initiative'],
+ order: 0,
+ },
+ {
+ key: 'proposal.gate_next_actions',
+ kind: 'proposal',
+ proposalKey: 'gate_next_actions',
+ slotKeys: ['plan.gates.proposals'],
+ componentKey: 'SteeringProposalsPanel',
+ requiresCapability: 'kairo.initiative.read',
+ scopeTypes: ['initiative'],
+ order: 0,
+ },
+]
+
+/**
+ * @param {string} slotKey
+ */
+export function getProvidersForSlot(slotKey) {
+ return COMPOSITION_PROVIDERS.filter((p) => p.slotKeys.includes(slotKey))
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+}
+
+/**
+ * @param {string} key
+ */
+export function getProviderByKey(key) {
+ return COMPOSITION_PROVIDERS.find((p) => p.key === key) || null
+}
diff --git a/frontend/src/composition/providerComponents.jsx b/frontend/src/composition/providerComponents.jsx
new file mode 100644
index 0000000..b827c8a
--- /dev/null
+++ b/frontend/src/composition/providerComponents.jsx
@@ -0,0 +1,49 @@
+import { Link } from 'react-router-dom'
+import { scopedPath } from '../utils/routes.js'
+import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
+import { CriticalPathPanel } from '../components/CriticalPathPanel.jsx'
+import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
+import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx'
+import { NextActionWidget } from '../widgets/NextActionWidget.jsx'
+
+/** Roadblocker-Strip — extrahiert aus InitiativeOverviewPage für Composition. */
+export function RoadblockersStrip({ initiativeId, actionsBlocked = 0, blockersOpen = 0 }) {
+ if (actionsBlocked <= 0 && blockersOpen <= 0) return null
+ return (
+
+ Roadblocker
+
+ {actionsBlocked > 0 && {actionsBlocked} blockierte Arbeitspakete}
+ {actionsBlocked > 0 && blockersOpen > 0 && ' · '}
+ {blockersOpen > 0 && {blockersOpen} offene Blocker}
+
+
+ Zur Ausführung
+
+
+ )
+}
+
+export function WidgetHost({ widget }) {
+ if (!widget?.component) return null
+ const Component = widget.component
+ return
+}
+
+/** @type {Record>} */
+export const PROVIDER_COMPONENTS = {
+ SteeringSnapshotPanel,
+ CriticalPathPanel,
+ NextActionWidget,
+ AgentSlotsPanel,
+ SteeringProposalsPanel,
+ RoadblockersStrip,
+ WidgetHost,
+}
+
+/**
+ * @param {string} componentKey
+ */
+export function getProviderComponent(componentKey) {
+ return PROVIDER_COMPONENTS[componentKey] || null
+}
diff --git a/frontend/src/composition/resolveSteeringComposition.js b/frontend/src/composition/resolveSteeringComposition.js
new file mode 100644
index 0000000..52aeec8
--- /dev/null
+++ b/frontend/src/composition/resolveSteeringComposition.js
@@ -0,0 +1,257 @@
+/**
+ * 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[]} [capabilities]
+ * @property {Record} [filterContext]
+ * @property {object | null} [opsContext]
+ */
+
+/**
+ * @typedef {CompositionProviderDefinition & { instanceKey: string, props: Record }} ResolvedProvider
+ */
+
+const CONDITIONAL_PREDICATES = {
+ hasRoadblockers(ctx) {
+ const counts = ctx.steeringSnapshot?.counts || {}
+ return (counts.actions_blocked || 0) > 0 || (counts.blockers_open || 0) > 0
+ },
+}
+
+/**
+ * @param {Set | 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', 'work_cycle_scope', '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 '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 }
+ }
+ 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} */
+ 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 }
diff --git a/frontend/src/composition/resolveSteeringComposition.test.js b/frontend/src/composition/resolveSteeringComposition.test.js
new file mode 100644
index 0000000..1a832da
--- /dev/null
+++ b/frontend/src/composition/resolveSteeringComposition.test.js
@@ -0,0 +1,235 @@
+import { describe, expect, it } from 'vitest'
+import { COMPOSITION_PROVIDERS } from './compositionProviders.js'
+import {
+ resolveSteeringComposition,
+ resolveSlotComposition,
+ buildProviderProps,
+} from './resolveSteeringComposition.js'
+import { COMPOSITION_SURFACES, UI_SLOTS } from './uiSlotRegistry.js'
+
+const CAPS = new Set([
+ 'kairo.initiative.read',
+ 'kairo.action.read',
+ 'kairo.workspace.read',
+ 'kairo.backlog.manage',
+])
+
+describe('uiSlotRegistry', () => {
+ it('defines MVP surfaces', () => {
+ expect(COMPOSITION_SURFACES['control.status']).toBeTruthy()
+ expect(COMPOSITION_SURFACES['plan.inbox']).toBeTruthy()
+ expect(COMPOSITION_SURFACES.cockpit).toBeTruthy()
+ })
+
+ it('has unique slot keys', () => {
+ const keys = UI_SLOTS.map((s) => s.key)
+ expect(new Set(keys).size).toBe(keys.length)
+ })
+})
+
+describe('compositionProviders', () => {
+ it('registers widget providers from widgetRegistry', () => {
+ const widgets = COMPOSITION_PROVIDERS.filter((p) => p.kind === 'widget')
+ expect(widgets.length).toBeGreaterThanOrEqual(8)
+ expect(widgets.some((p) => p.widgetKey === 'kairo.initiative_portfolio')).toBe(true)
+ })
+
+ it('registers proposal providers for plan surfaces', () => {
+ expect(COMPOSITION_PROVIDERS.some((p) => p.proposalKey === 'intake_triage')).toBe(true)
+ expect(COMPOSITION_PROVIDERS.some((p) => p.proposalKey === 'sprint_commit')).toBe(true)
+ expect(COMPOSITION_PROVIDERS.some((p) => p.proposalKey === 'gate_next_actions')).toBe(true)
+ })
+})
+
+describe('resolveSteeringComposition', () => {
+ it('resolves cockpit widgets by capability', () => {
+ const result = resolveSteeringComposition({
+ surfaceKey: 'cockpit',
+ scope: 'portfolio',
+ capabilities: CAPS,
+ })
+ const main = result.slots['cockpit.main'] || []
+ expect(main.length).toBeGreaterThan(0)
+ expect(main.some((p) => p.widgetKey === 'kairo.initiative_portfolio')).toBe(true)
+ })
+
+ it('filters cockpit widgets without capability', () => {
+ const result = resolveSteeringComposition({
+ surfaceKey: 'cockpit',
+ scope: 'portfolio',
+ capabilities: ['kairo.initiative.read'],
+ })
+ const main = result.slots['cockpit.main'] || []
+ expect(main.some((p) => p.widgetKey === 'kairo.my_open_actions')).toBe(false)
+ expect(main.some((p) => p.widgetKey === 'kairo.initiative_portfolio')).toBe(true)
+ })
+
+ it('activates critical_path and next_action_primary on control.status', () => {
+ const result = resolveSteeringComposition({
+ surfaceKey: 'control.status',
+ scope: 'initiative',
+ capabilities: CAPS,
+ steeringElements: ['critical_path', 'next_action_primary'],
+ steeringSnapshot: { counts: {}, next_actions: [] },
+ opsContext: { initiativeId: 'init-1', actions: [] },
+ })
+ const steering = result.slots['control.status.steering'] || []
+ expect(steering.some((p) => p.key === 'steering.critical_path')).toBe(true)
+ expect(steering.some((p) => p.key === 'steering.next_action')).toBe(true)
+ })
+
+ it('skips next_action when next_action_primary inactive', () => {
+ const result = resolveSteeringComposition({
+ surfaceKey: 'control.status',
+ scope: 'initiative',
+ capabilities: CAPS,
+ steeringElements: ['critical_path'],
+ steeringSnapshot: { counts: {}, next_actions: [] },
+ opsContext: { initiativeId: 'init-1', actions: [] },
+ })
+ const steering = result.slots['control.status.steering'] || []
+ expect(steering.some((p) => p.key === 'steering.next_action')).toBe(false)
+ })
+
+ it('skips critical_path when element inactive', () => {
+ const result = resolveSteeringComposition({
+ surfaceKey: 'control.status',
+ scope: 'initiative',
+ capabilities: CAPS,
+ steeringElements: ['next_action_primary'],
+ steeringSnapshot: { counts: {}, next_actions: [] },
+ opsContext: { initiativeId: 'init-1', actions: [] },
+ })
+ const steering = result.slots['control.status.steering'] || []
+ expect(steering.some((p) => p.key === 'steering.critical_path')).toBe(false)
+ expect(steering.some((p) => p.key === 'steering.next_action')).toBe(true)
+ })
+
+ it('activates proposal provider when kernel has items', () => {
+ const result = resolveSteeringComposition({
+ surfaceKey: 'plan.inbox',
+ scope: 'initiative',
+ capabilities: CAPS,
+ steeringSnapshot: {
+ steering_kernel: {
+ proposals: {
+ intake_triage: [{ scope_type: 'backlog_item', scope_id: 'b1', title: 'Bug', rank: 1 }],
+ },
+ },
+ },
+ opsContext: { formBusy: false },
+ })
+ const proposals = result.slots['plan.inbox.proposals'] || []
+ expect(proposals).toHaveLength(1)
+ expect(proposals[0].proposalKey).toBe('intake_triage')
+ })
+
+ it('filters sprint proposals by workCycleId', () => {
+ const slot = resolveSlotComposition('plan.sprint.proposals', {
+ scope: 'initiative',
+ capabilities: CAPS,
+ filterContext: { workCycleId: 'wc-1' },
+ steeringSnapshot: {
+ steering_kernel: {
+ proposals: {
+ sprint_commit: [
+ { scope_type: 'backlog_item', scope_id: 'b1', title: 'A', rank: 1, work_cycle_id: 'wc-1' },
+ { scope_type: 'backlog_item', scope_id: 'b2', title: 'B', rank: 2, work_cycle_id: 'wc-2' },
+ ],
+ },
+ },
+ },
+ opsContext: {},
+ })
+ expect(slot).toHaveLength(1)
+ expect(slot[0].props.proposalKeys).toEqual(['sprint_commit'])
+ })
+
+ it('shows roadblockers strip when counts indicate blockers', () => {
+ const result = resolveSteeringComposition({
+ surfaceKey: 'control.status',
+ scope: 'initiative',
+ capabilities: CAPS,
+ steeringElements: [],
+ steeringSnapshot: { counts: { actions_blocked: 2, blockers_open: 1 } },
+ opsContext: { initiativeId: 'init-1' },
+ })
+ const alerts = result.slots['control.status.alerts'] || []
+ expect(alerts.some((p) => p.key === 'conditional.roadblockers')).toBe(true)
+ })
+
+ it('activates agent slots provider when slots present', () => {
+ const result = resolveSteeringComposition({
+ surfaceKey: 'control.status',
+ scope: 'initiative',
+ capabilities: CAPS,
+ steeringSnapshot: {
+ steering_kernel: {
+ agent_slots: [{ slot_key: 'review.due', title: 'Review fällig', scope_id: 'r1' }],
+ },
+ },
+ opsContext: {},
+ })
+ const agent = result.slots['control.status.agent'] || []
+ expect(agent.some((p) => p.key === 'agent.slots')).toBe(true)
+ })
+
+ it('buildProviderProps passes critical path UI to NextActionWidget', () => {
+ const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'steering.next_action')
+ const props = buildProviderProps(provider, {
+ steeringElements: ['critical_path', 'next_action_primary'],
+ steeringSnapshot: { next_actions: [] },
+ opsContext: { initiativeId: 'x', steeringSnapshotLoading: false },
+ capabilities: CAPS,
+ })
+ expect(props.title).toContain('kritischen Pfad')
+ })
+
+ it('buildProviderProps uses continuous product next-action copy', () => {
+ const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'steering.next_action')
+ const props = buildProviderProps(provider, {
+ steeringElements: ['next_action_primary', 'gate_fulfillment'],
+ steeringSnapshot: { next_actions: [] },
+ opsContext: { initiativeId: 'x', steeringSnapshotLoading: false },
+ capabilities: CAPS,
+ })
+ expect(props.title).toContain('Nächster sinnvoller Schritt')
+ })
+
+ it('buildProviderProps passes kernel execution graph to CriticalPathPanel', () => {
+ const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'steering.critical_path')
+ const props = buildProviderProps(provider, {
+ steeringElements: ['critical_path'],
+ steeringSnapshot: {
+ steering_kernel: {
+ horizon: { gate_roadmap_item_id: 'gate-1' },
+ read_models: {
+ execution_graph: { critical_path: ['a1'], ready_actions: ['a1'] },
+ },
+ },
+ },
+ opsContext: { initiativeId: 'init-1', actions: [], steeringSnapshotLoading: false },
+ capabilities: CAPS,
+ })
+ expect(props.graphState?.critical_path).toEqual(['a1'])
+ expect(props.scopeRoadmapItemId).toBe('gate-1')
+ })
+
+ it('buildProviderProps gates milestone horizon in snapshot panel', () => {
+ const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'core.steering_snapshot')
+ const withGate = buildProviderProps(provider, {
+ steeringElements: ['gate_fulfillment', 'next_action_primary'],
+ steeringSnapshot: { counts: {} },
+ opsContext: {},
+ capabilities: CAPS,
+ })
+ const withoutGate = buildProviderProps(provider, {
+ steeringElements: ['next_action_primary'],
+ steeringSnapshot: { counts: {} },
+ opsContext: {},
+ capabilities: CAPS,
+ })
+ expect(withGate.showGateHorizon).toBe(true)
+ expect(withoutGate.showGateHorizon).toBe(false)
+ })
+})
diff --git a/frontend/src/composition/uiSlotRegistry.js b/frontend/src/composition/uiSlotRegistry.js
new file mode 100644
index 0000000..6893a1b
--- /dev/null
+++ b/frontend/src/composition/uiSlotRegistry.js
@@ -0,0 +1,93 @@
+/**
+ * AP-UX-0 — UI-Slot-Registry: benannte Flächen pro Modus/Route.
+ * @see docs/architecture/ADP_UX_Composition_Kernel_v0.1.md
+ */
+
+/** @typedef {'cockpit' | 'control' | 'plan' | 'work' | 'team'} CompositionMode */
+
+/**
+ * @typedef {Object} UiSlotDefinition
+ * @property {string} key
+ * @property {CompositionMode} mode
+ * @property {string | null} routeKey
+ * @property {string} label
+ * @property {number} order
+ */
+
+/** @type {UiSlotDefinition[]} */
+export const UI_SLOTS = [
+ { key: 'cockpit.main', mode: 'cockpit', routeKey: null, label: 'Cockpit-Hauptfläche', order: 0 },
+
+ { key: 'control.status.core', mode: 'control', routeKey: 'status', label: 'Steuerungs-Snapshot', order: 0 },
+ { key: 'control.status.steering', mode: 'control', routeKey: 'status', label: 'Steuerungselemente', order: 10 },
+ { key: 'control.status.agent', mode: 'control', routeKey: 'status', label: 'Agent-Slots', order: 20 },
+ { key: 'control.status.alerts', mode: 'control', routeKey: 'status', label: 'Roadblocker', order: 30 },
+
+ { key: 'plan.inbox.proposals', mode: 'plan', routeKey: 'inbox', label: 'Triage-Vorschläge', order: 0 },
+ { key: 'plan.sprint.proposals', mode: 'plan', routeKey: 'sprint', label: 'Sprint-Vorschläge', order: 0 },
+ { key: 'plan.gates.proposals', mode: 'plan', routeKey: 'gates', label: 'Gate-Vorschläge', order: 0 },
+]
+
+/**
+ * @typedef {Object} CompositionSurfaceDefinition
+ * @property {CompositionMode} mode
+ * @property {string | null} routeKey
+ * @property {string[]} slotKeys
+ */
+
+/** Surfaces = zusammengehörige Slots für eine Page. */
+/** @type {Record} */
+export const COMPOSITION_SURFACES = {
+ cockpit: {
+ mode: 'cockpit',
+ routeKey: null,
+ slotKeys: ['cockpit.main'],
+ },
+ 'control.status': {
+ mode: 'control',
+ routeKey: 'status',
+ slotKeys: [
+ 'control.status.core',
+ 'control.status.steering',
+ 'control.status.agent',
+ 'control.status.alerts',
+ ],
+ },
+ 'plan.inbox': {
+ mode: 'plan',
+ routeKey: 'inbox',
+ slotKeys: ['plan.inbox.proposals'],
+ },
+ 'plan.sprint': {
+ mode: 'plan',
+ routeKey: 'sprint',
+ slotKeys: ['plan.sprint.proposals'],
+ },
+ 'plan.gates': {
+ mode: 'plan',
+ routeKey: 'gates',
+ slotKeys: ['plan.gates.proposals'],
+ },
+}
+
+/**
+ * @param {string} surfaceKey
+ */
+export function getCompositionSurface(surfaceKey) {
+ return COMPOSITION_SURFACES[surfaceKey] || null
+}
+
+/**
+ * @param {string} slotKey
+ */
+export function getUiSlot(slotKey) {
+ return UI_SLOTS.find((s) => s.key === slotKey) || null
+}
+
+/**
+ * @param {CompositionMode} mode
+ * @param {string | null} [routeKey]
+ */
+export function surfaceKeyFor(mode, routeKey = null) {
+ return routeKey ? `${mode}.${routeKey}` : mode
+}
diff --git a/frontend/src/composition/useSteeringComposition.js b/frontend/src/composition/useSteeringComposition.js
new file mode 100644
index 0000000..82e3663
--- /dev/null
+++ b/frontend/src/composition/useSteeringComposition.js
@@ -0,0 +1,65 @@
+import { useMemo } from 'react'
+import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
+import { resolveSteeringComposition } from './resolveSteeringComposition.js'
+
+/**
+ * Hook: baut Composition-Input aus InitiativeOperationsContext.
+ * @param {string} surfaceKey
+ * @param {Record} [options]
+ */
+export function useSteeringComposition(surfaceKey, options = {}) {
+ const ops = useInitiativeOperations()
+ const {
+ filterContext = options.filterContext || {},
+ scope = 'initiative',
+ } = options
+
+ const opsContext = useMemo(
+ () => ({
+ initiativeId: ops.initiativeId,
+ actions: ops.actions,
+ steeringSnapshotLoading: ops.steeringSnapshotLoading,
+ steeringSnapshotError: ops.steeringSnapshotError,
+ steeringMethods: ops.steeringMethods,
+ handleMethodChange: ops.handleMethodChange,
+ methodBusy: ops.methodBusy,
+ formBusy: ops.formBusy,
+ onAcceptBacklogProposal: options.onAcceptBacklogProposal,
+ }),
+ [
+ ops.initiativeId,
+ ops.actions,
+ ops.steeringSnapshotLoading,
+ ops.steeringSnapshotError,
+ ops.steeringMethods,
+ ops.handleMethodChange,
+ ops.methodBusy,
+ ops.formBusy,
+ options.onAcceptBacklogProposal,
+ ],
+ )
+
+ return useMemo(
+ () =>
+ resolveSteeringComposition({
+ surfaceKey,
+ scope,
+ steeringElements: ops.steeringElements,
+ operatingContext: ops.operatingContext,
+ steeringSnapshot: ops.steeringSnapshot,
+ capabilities: ops.capabilities,
+ filterContext,
+ opsContext,
+ }),
+ [
+ surfaceKey,
+ scope,
+ ops.steeringElements,
+ ops.operatingContext,
+ ops.steeringSnapshot,
+ ops.capabilities,
+ filterContext,
+ opsContext,
+ ],
+ )
+}
diff --git a/frontend/src/pages/initiative/InitiativeInboxPage.jsx b/frontend/src/pages/initiative/InitiativeInboxPage.jsx
index 4ace657..569abf0 100644
--- a/frontend/src/pages/initiative/InitiativeInboxPage.jsx
+++ b/frontend/src/pages/initiative/InitiativeInboxPage.jsx
@@ -1,7 +1,7 @@
-import { useEffect, useMemo } from 'react'
+import { useEffect, useCallback } from 'react'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
+import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
import { BacklogSection } from '../../components/BacklogSection.jsx'
-import { SteeringProposalsPanel } from '../../components/SteeringProposalsPanel.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
export function InitiativeInboxPage() {
@@ -24,17 +24,18 @@ export function InitiativeInboxPage() {
handleBulkConvertBacklog,
handleDeleteBacklog,
reloadSlices,
- steeringSnapshot,
- operatingContext,
} = useInitiativeOperations()
useEffect(() => {
reloadSlices(['steering_snapshot'])
}, [reloadSlices])
- const kernelProposals = useMemo(
- () => steeringSnapshot?.steering_kernel?.proposals || {},
- [steeringSnapshot],
+ const onAcceptBacklogProposal = useCallback(
+ async (itemId) => {
+ await handleConvertBacklog(itemId, {})
+ await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
+ },
+ [handleConvertBacklog, reloadSlices],
)
if (!capabilities.has('kairo.initiative.read')) {
@@ -48,16 +49,9 @@ export function InitiativeInboxPage() {
return (
<>
{error && {error}
}
- {
- await handleConvertBacklog(itemId, {})
- await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
- }}
- busy={formBusy}
+
{error && {error}
}
-
- {capabilities.has('kairo.initiative.read') && (
-
- )}
-
- {showCriticalPath && capabilities.has('kairo.action.read') && (
-
- )}
-
- {capabilities.has('kairo.workspace.read') && (
-
- )}
-
- {capabilities.has('kairo.initiative.read') && (
-
- )}
-
- {(counts.actions_blocked > 0 || counts.blockers_open > 0) && (
-
- Roadblocker
-
- {counts.actions_blocked > 0 && (
- {counts.actions_blocked} blockierte Arbeitspakete
- )}
- {counts.actions_blocked > 0 && counts.blockers_open > 0 && ' · '}
- {counts.blockers_open > 0 && (
- {counts.blockers_open} offene Blocker
- )}
-
-
- Zur Ausführung
-
-
- )}
+
>
)
}
diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx
index ed616ca..85fc995 100644
--- a/frontend/src/pages/initiative/InitiativePlanPage.jsx
+++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx
@@ -1,7 +1,7 @@
-import { useEffect, useMemo } from 'react'
+import { useEffect, useCallback } from 'react'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
+import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
import { GatesPlanPanel } from '../../components/GatesPlanPanel.jsx'
-import { SteeringProposalsPanel } from '../../components/SteeringProposalsPanel.jsx'
export function InitiativePlanPage() {
const {
@@ -14,8 +14,6 @@ export function InitiativePlanPage() {
handleDeleteRoadmapItem,
handleConvertBacklog,
reloadSlices,
- steeringSnapshot,
- operatingContext,
error,
} = useInitiativeOperations()
@@ -23,9 +21,12 @@ export function InitiativePlanPage() {
reloadSlices(['steering_snapshot'])
}, [reloadSlices])
- const kernelProposals = useMemo(
- () => steeringSnapshot?.steering_kernel?.proposals || {},
- [steeringSnapshot],
+ const onAcceptBacklogProposal = useCallback(
+ async (itemId) => {
+ await handleConvertBacklog(itemId, {})
+ await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
+ },
+ [handleConvertBacklog, reloadSlices],
)
if (!capabilities.has('kairo.initiative.read')) {
@@ -35,16 +36,9 @@ export function InitiativePlanPage() {
return (
<>
{error && {error}
}
- {
- await handleConvertBacklog(itemId, {})
- await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
- }}
- busy={formBusy}
+
capabilities, [capabilities])
const [showForm, setShowForm] = useState(false)
const [formBusy, setFormBusy] = useState(false)
const [formError, setFormError] = useState(null)
@@ -68,12 +68,12 @@ export function CockpitPage() {
)}
-
- {widgets.map((widget) => {
- const Component = widget.component
- return
- })}
-
+
{hasCapability('kairo.initiative.read') && (
diff --git a/frontend/src/pages/modes/PlanSprintPage.jsx b/frontend/src/pages/modes/PlanSprintPage.jsx
index a5182e9..04cdcf2 100644
--- a/frontend/src/pages/modes/PlanSprintPage.jsx
+++ b/frontend/src/pages/modes/PlanSprintPage.jsx
@@ -1,8 +1,8 @@
-import { useEffect, useMemo } from 'react'
+import { useEffect, useMemo, useCallback } from 'react'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
-import { SteeringProposalsPanel } from '../../components/SteeringProposalsPanel.jsx'
+import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
@@ -50,17 +50,23 @@ function PlanSprintInner() {
handleUnplanAction,
handleConvertBacklog,
reloadSlices,
- steeringSnapshot,
- operatingContext,
} = ops
useEffect(() => {
reloadSlices(['steering_snapshot'])
}, [reloadSlices])
- const kernelProposals = useMemo(
- () => steeringSnapshot?.steering_kernel?.proposals || {},
- [steeringSnapshot],
+ const onAcceptBacklogProposal = useCallback(
+ async (itemId, options) => {
+ await handleConvertBacklog(itemId, options)
+ await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
+ },
+ [handleConvertBacklog, reloadSlices],
+ )
+
+ const proposalFilterContext = useMemo(
+ () => (selectedWorkCycleId ? { workCycleId: selectedWorkCycleId } : {}),
+ [selectedWorkCycleId],
)
const actionCountByCycleId = useMemo(
@@ -107,17 +113,10 @@ function PlanSprintInner() {
busy={formBusy}
/>
{selectedWorkCycleId && (
- {
- await handleConvertBacklog(itemId, options)
- await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
- }}
- busy={formBusy}
+
)}
{selectedWorkCycleId && selectedWorkCycle && (
diff --git a/frontend/src/pages/modes/PlanWorkPage.jsx b/frontend/src/pages/modes/PlanWorkPage.jsx
index a2ac3c9..66dbbd1 100644
--- a/frontend/src/pages/modes/PlanWorkPage.jsx
+++ b/frontend/src/pages/modes/PlanWorkPage.jsx
@@ -5,15 +5,15 @@ import { PlanningDebtBanner } from '../../components/PlanningDebtBanner.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
-import { useEffect, useState } from 'react'
+import { hasSteeringElement } from '../../registry/steeringElementRegistry.js'
+import { useEffect, useMemo, useState } from 'react'
import { getInitiativeExecutionGraphState } from '../../api/executionPlan.js'
function PlanWorkInner() {
const ops = useInitiativeOperations()
const { initiativeId, projectId: scopeProjectId, hrefWithScope } = useProgramScope()
- const [executionGraph, setExecutionGraph] = useState(null)
+ const [fallbackExecutionGraph, setFallbackExecutionGraph] = useState(null)
const {
- initiative,
actions,
projects,
roadmapItems,
@@ -29,32 +29,61 @@ function PlanWorkInner() {
actorsUsedFallback,
reloadActors,
handleCreateAction,
+ steeringSnapshot,
+ steeringElements,
+ uiFeatures,
+ reloadSlices,
} = ops
useEffect(() => {
- if (!initiativeId) {
- setExecutionGraph(null)
+ reloadSlices(['steering_snapshot'])
+ }, [reloadSlices])
+
+ const kernelReadModels = steeringSnapshot?.steering_kernel?.read_models
+ const useKernelGraph = Boolean(
+ kernelReadModels?.execution_graph != null
+ || kernelReadModels?.planning_debt != null,
+ )
+
+ useEffect(() => {
+ if (!initiativeId || useKernelGraph) {
+ setFallbackExecutionGraph(null)
return undefined
}
let cancelled = false
getInitiativeExecutionGraphState(initiativeId)
.then((state) => {
- if (!cancelled) setExecutionGraph(state)
+ if (!cancelled) setFallbackExecutionGraph(state)
})
.catch(() => {
- if (!cancelled) setExecutionGraph(null)
+ if (!cancelled) setFallbackExecutionGraph(null)
})
return () => {
cancelled = true
}
- }, [initiativeId, actions.length])
+ }, [initiativeId, actions.length, useKernelGraph])
+
+ const executionGraph = useMemo(() => {
+ if (kernelReadModels?.execution_graph) {
+ return {
+ ...kernelReadModels.execution_graph,
+ planning_debt: kernelReadModels.planning_debt,
+ }
+ }
+ return fallbackExecutionGraph
+ }, [kernelReadModels, fallbackExecutionGraph])
+
+ const planningDebt = useMemo(() => {
+ if (!hasSteeringElement(steeringElements, 'gate_fulfillment')) return null
+ return kernelReadModels?.planning_debt ?? executionGraph?.planning_debt ?? null
+ }, [steeringElements, kernelReadModels, executionGraph])
if (loading) {
return
}
- const isProduct = initiative?.archetype_key === 'initiative.product'
- const sectionLead = isProduct
+ const isContinuousProduct = Boolean(uiFeatures?.continuousProductWorkMode)
+ const sectionLead = isContinuousProduct
? 'Arbeitspakete direkt anlegen (Pfad B) oder committetes Ist — ohne Eingang-Umweg. Sprint-fokussiert unter Ausführen → Sprint.'
: 'Gesamt-Ist am Vorhaben — Sprint-fokussierte Arbeit unter Ausführen → Sprint.'
@@ -62,7 +91,7 @@ function PlanWorkInner() {
<>
{error && {error}
}