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) => (
  1. 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 ( -
    1. -
      - {i + 1} - {item.title} - - {NEXT_ACTION_KIND_LABELS[item.kind] || item.kind} - {item.recommended_action ? ` — ${item.recommended_action}` : ''} - -
      - {link && ( - - {link.label} - - )} -
    2. - )})} -
    -
    - )} - - {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}

    }