feat(AP2.2b, UX): Linear E2E, Composition-Kernel und Steering-Paritaet
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m24s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s

AP2.2b: Integrationstests Neue-Kueche-Happy-Path, Truth Table und Execution Plan abgeschlossen. UX Composition Kernel (Control/Plan/Cockpit-Slots), CriticalPathPanel aus Kernel-Read-Model mit Gate-Horizont, Next Action elementgesteuert ohne Snapshot-Doppelung, Product-Continuous-Copy und Plan/Arbeit ueber Kernel planning_debt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 13:34:13 +02:00
parent b6434bcc50
commit 43d33d0edb
22 changed files with 1527 additions and 243 deletions

View File

@ -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

View File

@ -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 `<CompositionSurface surfaceKey="control.status" />` 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/`.*

View File

@ -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 | P1P8 ✓ · K-Ext-1/2/3/4/5 ✓ |
| Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) |
| AP2.2be | Referenz-Archetypen End-to-End ◐→✓ |
| AP2.2b | A2 Linear E2E ✓ (2026-07-27) |
| AP2.2ce | Referenz-Archetypen End-to-End ◐→✓ |
| AP2.1 | MVP-Abnahfe ✗→✓ |
| AP1.7b | Op-API Parität |

View File

@ -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)

View File

@ -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({
<ErrorState message={error} onRetry={() => window.location.reload()} />
)}
{!loading && !error && steps.length === 0 && (
<EmptyState message="Noch kein kritischer Pfad — Arbeitspakete mit Abhängigkeiten unter Plan → Zielzustände / Durchführungsplan anlegen." />
<EmptyState message="Noch kein kritischer Pfad im aktiven Gate-Horizont — Arbeitspakete mit Abhängigkeiten unter Plan → Zielzustände / Durchführungsplan anlegen." />
)}
{!loading && !error && steps.length > 0 && (
<>
<p className="critical-path-summary muted">{summary.message}</p>
{nextReady && (
<div className="critical-path-next card-list-item">
<span className="badge status-badge status-active">Nächster Schritt</span>
<strong>{nextReady.action.title}</strong>
<p className="muted">
Kritischer Pfad ausführungsbereit, keine blockierenden Vorgänger.
</p>
<Link
to={actionPath(nextReady.actionId)}
className="btn btn-primary btn-sm"
>
Arbeitspaket öffnen
</Link>
</div>
)}
<ol className="critical-path-steps item-list">
{steps.map((step) => (
<li
@ -120,8 +112,8 @@ export function CriticalPathPanel({
<div>
<h2>Kritischer Pfad</h2>
<p className="section-lead muted">
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).
</p>
</div>
</div>

View File

@ -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 (
<section className="card steering-snapshot steering-snapshot--hero">
<header className="steering-hero-header">
@ -182,35 +160,8 @@ export function SteeringSnapshotPanel({
</p>
)}
{(showGateHorizon && upcoming_milestones.length > 0) && (
<div className="steering-hero-columns">
{next_actions.length > 0 && (
<div className="steering-next-actions">
<h3>Nächste Schritte</h3>
<ol className="item-list compact-list">
{next_actions.map((item, i) => {
const link = nextActionLink(item)
return (
<li key={`${item.kind}-${i}`} className="list-item card-list-item compact">
<div className="list-item-main">
<span className="next-action-rank">{i + 1}</span>
<strong>{item.title}</strong>
<span className="muted list-item-sub">
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
{item.recommended_action ? `${item.recommended_action}` : ''}
</span>
</div>
{link && (
<Link to={link.to} className="btn btn-primary btn-sm">
{link.label}
</Link>
)}
</li>
)})}
</ol>
</div>
)}
{upcoming_milestones.length > 0 && (
<div className="steering-milestones">
<h3>Meilenstein-Horizont</h3>
<ul className="item-list compact-list">
@ -227,8 +178,8 @@ export function SteeringSnapshotPanel({
))}
</ul>
</div>
)}
</div>
)}
{(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && (
<p className="steering-unlinked-hint muted">

View File

@ -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 (
<div className={className} data-composition-slot={slotKey}>
{providers.map((provider) => {
const Component = getProviderComponent(provider.componentKey)
if (!Component) return null
return <Component key={provider.instanceKey} {...provider.props} />
})}
</div>
)
}

View File

@ -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> | string[],
* filterContext?: Record<string, unknown>,
* 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 (
<div className={wrapperClassName} data-composition-surface={surfaceKey}>
{surface.slotKeys.map((slotKey) => (
<CompositionSlot
key={slotKey}
slotKey={slotKey}
providers={composition.slots[slotKey] || []}
className={slotClassName}
/>
))}
</div>
)
}

View File

@ -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<string, unknown>,
* onAcceptBacklogProposal?: (itemId: string, options?: object) => Promise<void> | 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 (
<CompositionSurface
surfaceKey={surfaceKey}
scope="initiative"
operatingContext={ops.operatingContext}
steeringSnapshot={ops.steeringSnapshot}
capabilities={ops.capabilities}
filterContext={filterContext}
opsContext={opsContext}
slotClassName={slotClassName}
wrapperClassName={wrapperClassName}
/>
)
}

View File

@ -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
}

View File

@ -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 (
<section className="card initiative-summary-strip">
<h2 className="card-title">Roadblocker</h2>
<p className="muted">
{actionsBlocked > 0 && <span>{actionsBlocked} blockierte Arbeitspakete</span>}
{actionsBlocked > 0 && blockersOpen > 0 && ' · '}
{blockersOpen > 0 && <span>{blockersOpen} offene Blocker</span>}
</p>
<Link to={scopedPath('/work/today', { initiativeId })} className="btn btn-secondary btn-sm">
Zur Ausführung
</Link>
</section>
)
}
export function WidgetHost({ widget }) {
if (!widget?.component) return null
const Component = widget.component
return <Component key={widget.key} />
}
/** @type {Record<string, import('react').ComponentType<any>>} */
export const PROVIDER_COMPONENTS = {
SteeringSnapshotPanel,
CriticalPathPanel,
NextActionWidget,
AgentSlotsPanel,
SteeringProposalsPanel,
RoadblockersStrip,
WidgetHost,
}
/**
* @param {string} componentKey
*/
export function getProviderComponent(componentKey) {
return PROVIDER_COMPONENTS[componentKey] || null
}

View File

@ -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> | string[]} [capabilities]
* @property {Record<string, unknown>} [filterContext]
* @property {object | null} [opsContext]
*/
/**
* @typedef {CompositionProviderDefinition & { instanceKey: string, props: Record<string, unknown> }} ResolvedProvider
*/
const CONDITIONAL_PREDICATES = {
hasRoadblockers(ctx) {
const counts = ctx.steeringSnapshot?.counts || {}
return (counts.actions_blocked || 0) > 0 || (counts.blockers_open || 0) > 0
},
}
/**
* @param {Set<string> | string[] | undefined} capabilities
* @param {string | undefined} required
*/
function hasCapability(capabilities, required) {
if (!required) return true
const capSet = capabilities instanceof Set ? capabilities : new Set(capabilities || [])
return capSet.has(required)
}
/**
* @param {CompositionInput} input
*/
function resolveSteeringElements(input) {
if (Array.isArray(input.steeringElements)) return input.steeringElements
return input.operatingContext?.steering_elements || []
}
/**
* @param {CompositionInput} input
*/
function resolveKernelProposals(input) {
return input.steeringSnapshot?.steering_kernel?.proposals || {}
}
/**
* @param {CompositionInput} input
*/
function resolveAgentSlots(input) {
return (
input.steeringSnapshot?.agent_slots
|| input.steeringSnapshot?.steering_kernel?.agent_slots
|| []
)
}
/**
* Next-Action-Überschriften: kritischster passender steering_element (Priorität).
* @param {string[]} elements
* @param {object | null | undefined} steeringSnapshot
*/
export function resolveNextActionUi(elements, steeringSnapshot) {
const priority = ['critical_path', '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<string, ResolvedProvider[]>} */
const slots = {}
for (const slotKey of surface.slotKeys) {
slots[slotKey] = resolveSlotComposition(slotKey, input)
}
return { surfaceKey, slots }
}
/** Exported for tests */
export { COMPOSITION_PROVIDERS, isProviderActive, CONDITIONAL_PREDICATES }

View File

@ -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)
})
})

View File

@ -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<string, CompositionSurfaceDefinition>} */
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
}

View File

@ -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<string, unknown>} [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,
],
)
}

View File

@ -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 && <p className="error">{error}</p>}
<SteeringProposalsPanel
proposalsByKey={kernelProposals}
proposalKeys={['intake_triage']}
backlogVocabulary={operatingContext?.backlog_vocabulary}
canManage={capabilities.has('kairo.backlog.manage')}
onAcceptBacklogProposal={async (itemId) => {
await handleConvertBacklog(itemId, {})
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
}}
busy={formBusy}
<InitiativeCompositionSurface
surfaceKey="plan.inbox"
onAcceptBacklogProposal={onAcceptBacklogProposal}
/>
<BacklogSection
items={backlogItems}

View File

@ -1,94 +1,13 @@
import { Link } from 'react-router-dom'
import { scopedPath } from '../../utils/routes.js'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
import { AgentSlotsPanel } from '../../components/AgentSlotsPanel.jsx'
import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
import {
hasSteeringElement,
steeringElementUi,
} from '../../registry/steeringElementRegistry.js'
import { InitiativeCompositionSurface } from '../../composition/InitiativeCompositionSurface.jsx'
export function InitiativeOverviewPage() {
const {
initiativeId,
actions,
error,
steeringSnapshot,
steeringSnapshotLoading,
steeringSnapshotError,
steeringMethods,
methodBusy,
capabilities,
handleMethodChange,
steeringElements,
} = useInitiativeOperations()
const counts = steeringSnapshot?.counts || {}
const showCriticalPath = hasSteeringElement(steeringElements, 'critical_path')
const criticalPathUi = steeringElementUi(steeringElements, 'critical_path')
const { error } = useInitiativeOperations()
return (
<>
{error && <p className="error">{error}</p>}
{capabilities.has('kairo.initiative.read') && (
<SteeringSnapshotPanel
snapshot={steeringSnapshot}
loading={steeringSnapshotLoading}
error={steeringSnapshotError}
methods={steeringMethods}
canManageMethod={capabilities.has('kairo.initiative.manage')}
onMethodChange={handleMethodChange}
methodBusy={methodBusy}
/>
)}
{showCriticalPath && capabilities.has('kairo.action.read') && (
<CriticalPathPanel initiativeId={initiativeId} actions={actions} />
)}
{capabilities.has('kairo.workspace.read') && (
<NextActionWidget
scope="initiative"
initiativeId={initiativeId}
items={steeringSnapshot?.next_actions}
loading={steeringSnapshotLoading}
embedded
title={criticalPathUi?.nextActionTitle}
subtitle={criticalPathUi?.nextActionSubtitle}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<AgentSlotsPanel
slots={
steeringSnapshot?.agent_slots ||
steeringSnapshot?.steering_kernel?.agent_slots ||
[]
}
loading={steeringSnapshotLoading}
/>
)}
{(counts.actions_blocked > 0 || counts.blockers_open > 0) && (
<section className="card initiative-summary-strip">
<h2 className="card-title">Roadblocker</h2>
<p className="muted">
{counts.actions_blocked > 0 && (
<span>{counts.actions_blocked} blockierte Arbeitspakete</span>
)}
{counts.actions_blocked > 0 && counts.blockers_open > 0 && ' · '}
{counts.blockers_open > 0 && (
<span>{counts.blockers_open} offene Blocker</span>
)}
</p>
<Link to={scopedPath('/work/today', { initiativeId })} className="btn btn-secondary btn-sm">
Zur Ausführung
</Link>
</section>
)}
<InitiativeCompositionSurface surfaceKey="control.status" />
</>
)
}

View File

@ -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 && <p className="error">{error}</p>}
<SteeringProposalsPanel
proposalsByKey={kernelProposals}
proposalKeys={['gate_next_actions']}
backlogVocabulary={operatingContext?.backlog_vocabulary}
canManage={capabilities.has('kairo.backlog.manage')}
onAcceptBacklogProposal={async (itemId) => {
await handleConvertBacklog(itemId, {})
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
}}
busy={formBusy}
<InitiativeCompositionSurface
surfaceKey="plan.gates"
onAcceptBacklogProposal={onAcceptBacklogProposal}
/>
<GatesPlanPanel
initiativeId={initiativeId}

View File

@ -1,20 +1,20 @@
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useCapabilities } from '../../hooks/useCapabilities.js'
import { getWidgetsForArea } from '../../registry/widgetRegistry.js'
import { useSession } from '../../context/SessionContext.jsx'
import { InitiativeForm } from '../../components/InitiativeForm.jsx'
import { createInitiative } from '../../api/initiatives.js'
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../../api/entityFields.js'
import { ModeShell } from '../../components/ModeShell.jsx'
import { initiativeCatalogPath, scopedPath } from '../../utils/routes.js'
import { CompositionSurface } from '../../composition/CompositionSurface.jsx'
export function CockpitPage() {
const navigate = useNavigate()
const { context } = useSession()
const { capabilities, hasCapability } = useCapabilities()
const widgets = getWidgetsForArea('workspace', capabilities)
const cockpitCapabilities = useMemo(() => capabilities, [capabilities])
const [showForm, setShowForm] = useState(false)
const [formBusy, setFormBusy] = useState(false)
const [formError, setFormError] = useState(null)
@ -68,12 +68,12 @@ export function CockpitPage() {
</section>
)}
<div className="widget-grid">
{widgets.map((widget) => {
const Component = widget.component
return <Component key={widget.key} />
})}
</div>
<CompositionSurface
surfaceKey="cockpit"
scope="portfolio"
capabilities={cockpitCapabilities}
wrapperClassName="widget-grid"
/>
{hasCapability('kairo.initiative.read') && (
<p className="page-footer-link">

View File

@ -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 && (
<SteeringProposalsPanel
proposalsByKey={kernelProposals}
proposalKeys={['sprint_commit']}
filterContext={{ workCycleId: selectedWorkCycleId }}
backlogVocabulary={operatingContext?.backlog_vocabulary}
canManage={capabilities.has('kairo.backlog.manage')}
onAcceptBacklogProposal={async (itemId, options) => {
await handleConvertBacklog(itemId, options)
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
}}
busy={formBusy}
<InitiativeCompositionSurface
surfaceKey="plan.sprint"
filterContext={proposalFilterContext}
onAcceptBacklogProposal={onAcceptBacklogProposal}
/>
)}
{selectedWorkCycleId && selectedWorkCycle && (

View File

@ -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 <LoadingState message="Lade Arbeitspakete …" />
}
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 && <p className="error">{error}</p>}
<PlanningDebtBanner
planningDebt={executionGraph?.planning_debt}
planningDebt={planningDebt}
hrefWithScope={hrefWithScope}
/>
<PlanActionsSection

View File

@ -9,6 +9,16 @@ export const STEERING_ELEMENT_UI = {
nextActionSubtitle:
'Empfehlung aus sequenzieller Abhängigkeit — bereite Arbeitspakete zuerst.',
},
work_cycle_scope: {
nextActionTitle: 'Nächster Schritt im aktiven Sprint',
nextActionSubtitle:
'Empfehlung aus dem Sprint-Backlog — Continuous Next außerhalb des Sprints.',
},
gate_fulfillment: {
nextActionTitle: 'Nächster sinnvoller Schritt',
nextActionSubtitle:
'Continuous Betrieb — committete Arbeitspakete und Eingang triagieren.',
},
queue_inbox: {
nextActionTitle: 'Nächster Schritt aus der Queue',
nextActionSubtitle: 'Pull-Empfehlung aus dem Eingang.',