diff --git a/katana_mcp_server/src/katana_mcp/tools/_modification.py b/katana_mcp_server/src/katana_mcp/tools/_modification.py index 6154b5b4..1cb1de6c 100644 --- a/katana_mcp_server/src/katana_mcp/tools/_modification.py +++ b/katana_mcp_server/src/katana_mcp/tools/_modification.py @@ -554,25 +554,40 @@ def to_tool_result( ) -> ToolResult: """Build a :class:`ToolResult` with a Prefab UI from a ModificationResponse. - Preview branch: emits ``build_modification_preview_ui`` with the - direct-apply rail (Confirm fires ``tools/call`` directly + iframe - pushes the structured result back via ``ui/update-model-context``). - Non-preview branch: emits ``build_modification_result_ui`` summarizing - each action's terminal status. - - ``confirm_request`` is the original Pydantic request (its ``preview`` - field flips to ``False`` when the iframe re-issues for apply); - ``confirm_tool`` is the registered MCP tool name to re-call. The - preview branch wires both into the Confirm-button CallTool; the - result branch uses ``confirm_tool`` to derive the title verb so a - delete reads "Product Delete" instead of "Product Modification". + Dispatches by ``response.entity_type`` to a per-entity builder that + handles BOTH preview and applied states (one entrypoint per entity, + matching the create-card pattern in #728 and the modify-card design + in #721). Each per-entity builder shares its entity-view renderer + with its create-card sibling (e.g. ``_render_po_entity_view``) — + create cards call it with no diff overlay; modify cards pass the + per-field diff lookup built from ``response.actions[*].changes``. + + Entities not yet migrated fall back to ``build_modification_preview_ui`` + / ``build_modification_result_ui`` — the legacy single-entrypoint pair + today's modify/delete/correct tools render through. Removed once + every #721 child PR has shipped. """ from katana_mcp.tools.prefab_ui import ( build_modification_preview_ui, build_modification_result_ui, + build_po_modify_ui, ) response_dict = response.model_dump() + + # Per-entity dispatch — PO migrated in #722; remaining entities + # (SO, MO, stock_transfer, item) fall through to the legacy + # builders until their respective child PRs land. + if response.entity_type == "purchase_order": + ui = build_po_modify_ui( + response_dict, + confirm_request=confirm_request, + confirm_tool=confirm_tool, + ) + return make_tool_result(response, ui=ui) + + # Legacy path — preserves today's behavior for not-yet-migrated + # entity types so #722 can land without cross-entity test churn. if response.is_preview: ui = build_modification_preview_ui( response_dict, diff --git a/katana_mcp_server/src/katana_mcp/tools/_modification_dispatch.py b/katana_mcp_server/src/katana_mcp/tools/_modification_dispatch.py index 110d04b1..e28bf053 100644 --- a/katana_mcp_server/src/katana_mcp/tools/_modification_dispatch.py +++ b/katana_mcp_server/src/katana_mcp/tools/_modification_dispatch.py @@ -762,12 +762,23 @@ async def run_modify_plan( ) raise ValueError(msg) + # ``prior_state`` populated on BOTH branches: apply path uses it for + # the revert reference; preview path uses it for renderer-side entity + # view (the modify-card design in #721 wants the unchanged header / + # reference fields to render as context around the diff-decorated + # changing fields — without prior_state, the card would show only the + # changed fields and a mostly-empty header). ``existing`` may be None + # if the diff fetch failed; ``serialize_for_prior_state`` tolerates + # that and returns ``None`` so the renderer sees no snapshot. + prior_state = serialize_for_prior_state(existing) + if request.preview: return ModificationResponse( entity_type=entity_type, entity_id=request.id, is_preview=True, actions=plan_to_preview_results(plan), + prior_state=prior_state, warnings=warnings, next_actions=[ f"Review {len(plan)} planned action(s)", @@ -777,7 +788,6 @@ async def run_modify_plan( message=f"Preview: {len(plan)} action(s) planned for {entity_label}", ) - prior_state = serialize_for_prior_state(existing) actions = await execute_plan(plan) message, next_actions = summarize_modify_outcome( actions, len(plan), entity_label=entity_label, tool_name=tool_name diff --git a/katana_mcp_server/src/katana_mcp/tools/foundation/corrections.py b/katana_mcp_server/src/katana_mcp/tools/foundation/corrections.py index 781e969c..1a71a58b 100644 --- a/katana_mcp_server/src/katana_mcp/tools/foundation/corrections.py +++ b/katana_mcp_server/src/katana_mcp/tools/foundation/corrections.py @@ -578,6 +578,14 @@ async def _correct_manufacturing_order_impl( close_phase = _build_close_mo_actions(request.id, snapshot, services) phases = [revert_phase, edit_phase, recreate_phase, close_phase] + # ``prior_state`` populated on BOTH branches: apply path uses it for + # the revert reference; preview path uses it for renderer-side entity + # view (#721 modify-card design — without prior_state, the rendered + # card has only the changed-field diffs and an almost-empty header). + prior_state = _augment_prior_state_with_snapshot( + serialize_for_prior_state(existing_mo), snapshot + ) + if request.preview: full_plan = [action for phase in phases for action in phase] return ModificationResponse( @@ -585,6 +593,7 @@ async def _correct_manufacturing_order_impl( entity_id=request.id, is_preview=True, actions=plan_to_preview_results(full_plan), + prior_state=prior_state, warnings=_close_state_warnings_mo(snapshot), next_actions=[ f"Review {len(full_plan)} planned action(s) for MO {request.id}", @@ -600,10 +609,6 @@ async def _correct_manufacturing_order_impl( f"({len(full_plan)} action(s))" ), ) - - prior_state = _augment_prior_state_with_snapshot( - serialize_for_prior_state(existing_mo), snapshot - ) aggregated, failed = await _run_phases_until_failure(phases) if failed: return _build_failure_response( @@ -1097,6 +1102,13 @@ async def _correct_sales_order_impl( close_phase = [_build_close_so_action(request.id, services)] phases = [delete_phase, revert_phase, edit_phase, recreate_phase, close_phase] + # See #722 note on the MO correction above — prior_state populated + # on both branches so the per-entity modify card can render the + # unchanged-field context around the diff overlay on preview too. + prior_state = _augment_prior_state_with_snapshot( + serialize_for_prior_state(existing_so), snapshot + ) + if request.preview: full_plan = [action for phase in phases for action in phase] return ModificationResponse( @@ -1104,6 +1116,7 @@ async def _correct_sales_order_impl( entity_id=request.id, is_preview=True, actions=plan_to_preview_results(full_plan), + prior_state=prior_state, warnings=_close_state_warnings_so(snapshot), next_actions=[ f"Review {len(full_plan)} planned action(s) for SO {request.id}", @@ -1118,10 +1131,6 @@ async def _correct_sales_order_impl( f"{request.id} ({len(full_plan)} action(s))" ), ) - - prior_state = _augment_prior_state_with_snapshot( - serialize_for_prior_state(existing_so), snapshot - ) aggregated, failed = await _run_phases_until_failure(phases) if failed: return _build_failure_response( @@ -1537,6 +1546,11 @@ async def _correct_purchase_order_impl( ] phases = [revert_phase, edit_phase, receive_phase] + # See #722 note on the MO / SO correction above. + prior_state = _augment_prior_state_with_snapshot( + serialize_for_prior_state(existing_po), snapshot + ) + if request.preview: full_plan = [action for phase in phases for action in phase] return ModificationResponse( @@ -1544,6 +1558,7 @@ async def _correct_purchase_order_impl( entity_id=request.id, is_preview=True, actions=plan_to_preview_results(full_plan), + prior_state=prior_state, warnings=_close_state_warnings_po(snapshot), next_actions=[ f"Review {len(full_plan)} planned action(s) for PO {request.id}", @@ -1557,10 +1572,6 @@ async def _correct_purchase_order_impl( f"{request.id} ({len(full_plan)} action(s))" ), ) - - prior_state = _augment_prior_state_with_snapshot( - serialize_for_prior_state(existing_po), snapshot - ) aggregated, failed = await _run_phases_until_failure(phases) if failed: return _build_failure_response( diff --git a/katana_mcp_server/src/katana_mcp/tools/prefab_ui.py b/katana_mcp_server/src/katana_mcp/tools/prefab_ui.py index af155d66..5265dd76 100644 --- a/katana_mcp_server/src/katana_mcp/tools/prefab_ui.py +++ b/katana_mcp_server/src/katana_mcp/tools/prefab_ui.py @@ -497,39 +497,127 @@ def _render_apply_button_row( ) return - if direct_apply: - # `pending` is the in-flight guard — set on click before CallTool - # fires, cleared in on_success/on_error. Including it in `locked` - # is what prevents double-click from firing two applies. - locked = Rx("pending") | Rx("applied") | Rx("error") | Rx("cancelled") - else: - locked = Rx("pending") | Rx("cancelled") + # The primary button slot morphs through the state machine — same + # DOM position across all states (layout-stable), but the label / + # variant / on_click change to reflect what action is relevant for + # the current state. Header Badge keeps showing the overall card + # state (PREVIEW / APPLIED / FAILED) separately; the button is + # specifically about "what action ran or can run next." + # + # State machine (direct-apply rail): + # - Preview: ``Confirm Changes`` (default, + loader icon + # ``loader`` on the pending replacement) → + # fires apply + # - Pending: ``Applying…`` (default + ``loader`` icon, + # disabled) + # - Applied + url: ``View in Katana`` (success + ``external-link`` + # icon) → opens URL + # - Applied no url: ``Applied`` (success + ``check`` icon, + # disabled) — successful delete nulls + # katana_url + # - Error: ``Retry`` (warning + ``rotate-cw`` icon) → + # re-fires apply; warning (amber) not + # destructive (red) per the /ui-review audit — + # destructive variant implies "this will + # delete data" which is semantically wrong + # for a retry affordance + # - Cancelled: ``Cancelled`` (outline, disabled) + # + # The SendMessage-rail (``direct_apply=False``) variant only + # transitions Preview → Pending; the agent re-issue replaces the + # card so applied/error/cancelled are out-of-process. with Row(gap=2): if direct_apply: with If("pending"): - Badge(label="Applying…", variant="secondary") + # Loader icon + spinner-style label conveys in-flight + # state more clearly than the bare "Applying…" text. + Button( + label="Applying…", + variant="default", + icon="loader", + disabled=True, + ) with Elif("applied"): - Badge(label="Applied", variant="default") + with If("result.katana_url"): + # Success variant (green) + external-link icon — + # primary affordance on a successful apply is to + # see the result in Katana. + Button( + label="View in Katana", + variant="success", + icon="external-link", + on_click=OpenLink(url="{{ result.katana_url }}"), + ) + with Else(): + # No URL (typically a successful delete) — keep + # the success variant + check icon so the user + # gets unambiguous confirmation the action ran. + Button( + label="Applied", + variant="success", + icon="check", + disabled=True, + ) with Elif("error"): - Badge(label="Error", variant="destructive") + # Warning variant (amber) + rotate icon signals "the + # previous attempt failed, click to redo." Destructive + # variant (red) would imply "this will delete data" — + # semantically wrong for a retry affordance (per the + # /ui-review audit). The action re-fires apply_action, + # which resets the rail via SetState("error", None) and + # restarts the apply chain. + Button( + label="Retry", + variant="warning", + icon="rotate-cw", + on_click=apply_action, + ) with Elif("cancelled"): - Badge(label="Cancelled", variant="secondary") + Button(label="Cancelled", variant="outline", disabled=True) + with Else(): + # Preview state — explicit ``disabled=Rx("pending")`` is + # the belt-and-suspenders double-click guard: the + # SetState("pending", True) at the start of the on_click + # chain disables the button before If/Elif has a chance + # to swap it. Both layers protect against rapid + # double-click firing two CallTools. + Button( + label=confirm_label, + variant="default", + on_click=apply_action, + disabled=Rx("pending"), + ) else: + # SendMessage rail — simpler state machine, only + # Preview ↔ Pending ↔ Cancelled visible (agent re-issue + # replaces the card for terminal apply outcomes). with If("pending"): - Badge(label="Pending…", variant="secondary") + Button( + label="Pending…", + variant="default", + icon="loader", + disabled=True, + ) with Elif("cancelled"): - Badge(label="Cancelled", variant="secondary") - Button( - label=confirm_label, - variant="default", - on_click=apply_action, - disabled=locked, - ) + Button(label="Cancelled", variant="outline", disabled=True) + with Else(): + Button( + label=confirm_label, + variant="default", + on_click=apply_action, + disabled=Rx("pending") | Rx("cancelled"), + ) + # Cancel button — disabled in all terminal states so the row + # width stays constant (two buttons always visible). Cancel + # only does anything in Preview. + cancel_locked: Any = Rx("pending") | Rx("cancelled") + if direct_apply: + cancel_locked = cancel_locked | Rx("applied") | Rx("error") Button( label="Cancel", variant="outline", on_click=cancel_action, - disabled=locked, + disabled=cancel_locked, ) @@ -1405,6 +1493,197 @@ def _iso_date_only(value: object) -> str: return str(value) +# ============================================================================ +# Field-level diff helpers — shared between create and modify cards (#722). +# ============================================================================ +# +# Modify cards render the same entity view as create cards (#728), with three +# overlays: before→after for changed fields, leading ✗ + inline error line for +# failed fields, +/- prefixes for added/removed nested rows. The card-level +# header Badge carries the all-applied / partial-failure status; per-field +# decoration only appears when it carries information (the changed fields and +# the failed ones). +# +# The wire shape is ``ActionResult.changes: list[FieldChange]`` (server side, +# in ``_modification.py``). ``FieldChangeView`` is the renderer-facing +# projection — it pre-resolves the bookkeeping the renderer needs without +# leaking the wire shape into the entity view's render code. + + +class FieldChangeView(BaseModel): + """Per-field diff projection scoped to the modify-card renderer. + + Pre-resolves ``ActionResult.changes`` items into the shape the entity-view + helpers consume: a side-by-side ``before`` / ``after`` plus a ``kind`` + discriminator and a ``failed`` flag that drives the leading ``✗`` glyph + + trailing error line on failed actions. + + ``unknown_prior`` carries the wire's ``FieldChange.is_unknown_prior`` + forward — set when the best-effort fetch for the prior entity state + failed, so the renderer should display ``(prior unknown) → new`` + rather than ``(unset) → new`` (the latter would imply the field had + been blank, which we can't actually attest to). + + ``label`` is unused by the renderer today (the entity view picks the + user-facing label per field) but carries the human-readable name for + test assertions and any future generic renderer that doesn't know the + field-name-to-label mapping at build time. + """ + + field: str + before: Any | None = None + after: Any | None = None + kind: Literal["changed", "added", "removed", "unchanged"] = "changed" + failed: bool = False + error: str | None = None + unknown_prior: bool = False + label: str | None = None + + +def _index_changes_by_field( + actions: list[dict[str, Any]], +) -> dict[str, FieldChangeView]: + """Flatten ``ActionResult.changes`` lists into a field-name keyed map. + + Each ActionResult carries a ``changes: list[FieldChange]`` (wire shape). + The modify-card renderer wants a single lookup by field name so each + entity-view line can ask ``changes.get("expected_arrival_date")`` and + decorate inline. + + Maps each ``FieldChange`` to a ``FieldChangeView``, propagating the + parent action's ``succeeded`` / ``error`` so failed actions surface + per-field on every field they were going to write. A field appearing + in two actions (rare) takes the last write — the iteration order + matches the action plan's execution order, so the last write is the + one that ran (or would have run) most recently. + """ + out: dict[str, FieldChangeView] = {} + for action in actions: + succeeded = action.get("succeeded") + action_error = action.get("error") + # ``succeeded`` is None during preview, True/False after apply. + # A failed action's writes never landed — render the field's + # intended change with the ✗ glyph + error. + failed = succeeded is False + for change in action.get("changes") or []: + if not isinstance(change, dict): + continue + field = change.get("field") + if not isinstance(field, str): + continue + is_added = bool(change.get("is_added")) + is_unchanged = bool(change.get("is_unchanged")) + unknown_prior = bool(change.get("is_unknown_prior")) + new_val = change.get("new") + old_val = change.get("old") + kind: Literal["changed", "added", "removed", "unchanged"] + if is_unchanged: + kind = "unchanged" + elif is_added: + kind = "added" + elif new_val is None and old_val is not None: + # No explicit "is_removed" flag today — see the + # FieldChange docstring; a None new with non-None old + # only appears in synthesized reverts. + kind = "removed" + else: + kind = "changed" + out[field] = FieldChangeView( + field=field, + before=old_val, + after=new_val, + kind=kind, + failed=failed, + error=action_error if failed else None, + unknown_prior=unknown_prior, + ) + return out + + +def _format_diff_value(value: Any) -> str: + """Coerce a diff-side value (old or new) to display text. + + Wire shape allows None, str, int, float, bool, list, dict; the entity + view renders each as text. Strings, numbers, and booleans render + directly; lists/dicts fall back to ``repr`` (rare — the diff producer + avoids nested types). None renders as ``(unset)`` so a transition + from blank to populated reads naturally as ``(unset) → Net-30``. + """ + if value is None: + return "(unset)" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, (int, float, str)): + return str(value) + return repr(value) + + +def _render_field_diff_line( + label: str, + *, + value: Any = None, + change: FieldChangeView | None = None, +) -> None: + """Render one field row. + + Output modes: + + - ``change`` is None → ``Label: value``. The create-card path, plus + modify-card lines for fields that aren't changing. + - ``change.kind == "unchanged"`` → same as ``change=None``, but the + display value comes from ``change.after``/``change.before`` if the + caller didn't pass ``value`` (no-op diffs from ``compute_field_diff`` + carry the field's current value on the change itself). + - ``change`` set, not failed → `` Label: before → after`` (leading + 2-char gutter so the failed-state ``✗ `` glyph doesn't shift the + field text position; see ``_render_apply_button_row`` layout- + stability note). + - ``change.failed`` is True → ``✗ Label: before → after``. The + actual error message is NOT rendered inline — it aggregates into + the consolidated bottom Alert via ``_render_failed_changes_block`` + so the diff lines above don't reflow when the apply outcome lands. + - ``change.unknown_prior`` is True → the before side renders as + ``(prior unknown)`` instead of the formatted before value. + Distinguishes "we couldn't read the prior" from "the prior was + unset" — see FieldChange's ``is_unknown_prior`` docstring. + + The leading ``✗`` glyph is the only inline per-field status marker. + Successful applies render exactly like preview (no badges, no glyphs) + because the card-level header Badge already carries that signal — + avoids the visual chatter of a status pill on every changed field. + """ + if change is None or change.kind == "unchanged": + # When ``compute_field_diff`` emits ``is_unchanged=True``, the + # field's current value rides on the change itself (before == + # after). Prefer that over ``value=None`` so a no-op update + # doesn't mislead the user with ``(unset)`` when the field + # actually has a value — the caller would otherwise need to + # thread the entity value alongside ``change`` just to avoid + # this case. + display: Any = value + if display is None and change is not None and change.kind == "unchanged": + display = change.after if change.after is not None else change.before + Text( + content=f"{label}: " + f"{_format_diff_value(display) if display is not None else '(unset)'}" + ) + return + before_txt = ( + "(prior unknown)" if change.unknown_prior else _format_diff_value(change.before) + ) + after_txt = _format_diff_value(change.after) + # Always reserve a 2-char gutter on the leading edge so a failure-state + # ``✗ `` glyph doesn't shift the field text position when the apply + # outcome lands. Non-failed lines use two spaces; failed lines swap to + # the glyph + space — same horizontal offset either way. + prefix = "✗ " if change.failed else " " + Text(content=f"{prefix}{label}: {before_txt} → {after_txt}") + # Per-field error line is NOT rendered inline — errors aggregate + # into a consolidated block at the bottom of the entity view (see + # ``_render_failed_changes_block``) so the diff lines above don't + # reflow when an apply fails. + + # Initial state slots written by the direct-apply rail's Confirm/Cancel # action chains. Builders that opt into the rail seed these to ``False`` / # ``None`` so the iframe's If/Elif blocks have something to bind to before @@ -1705,28 +1984,63 @@ def _render_preview_header( order_number: str, status: str | None, extra_badges: tuple[tuple[str, str], ...] = (), + applied_title_suffix: str = "Created", + applied_state_label: str = "CREATED", + applied_state_variant: str = "default", ) -> None: """Tier 1: CardHeader for a preview/apply card. - Renders the title (toggles ``"X Preview"`` ↔ ``"X Created"`` on - ``state.applied``), an order-number Badge, a PREVIEW/CREATED state - Badge (toggle on ``state.applied``), the entity status Badge with the - bucket-driven variant from ``status_badge_variant``, and any - caller-provided extras (e.g. ``[("outsourced", "outline")]`` for PO). + Renders the title (toggles ``"X Preview"`` ↔ ``"X {applied_title_suffix}"`` + on ``state.applied``), an order-number Badge, a PREVIEW/{applied_state_label} + state Badge (toggle on ``state.applied``), the entity status Badge with + the bucket-driven variant from ``status_badge_variant``, and any + caller-provided extras (e.g. ``[("outsourced", "outline")]`` for PO + entity_type). + + Create cards default to ``"Created"`` / ``"CREATED"`` with the + ``"default"`` (green) variant. Modify / delete / correct cards should + pass ``applied_title_suffix="Applied"`` and ``applied_state_label="APPLIED"`` + so the rendered copy matches the actual operation. + + Partial-failure / failure outcomes on the standalone-applied path + (where the response payload tells us the outcome at build time) + override ``applied_state_label`` to ``"FAILED"`` / ``"PARTIAL FAILURE"`` + AND pass ``applied_state_variant="destructive"`` so the rendered chrome + is single, internally consistent, and visually matches the outcome. + The in-place morph path can't predict failure at build time and so + keeps the defaults — failed actions surface there via the per-field + ``✗`` glyphs ``_render_field_diff_line`` emits. + + ``extra_badges`` is reserved for orthogonal entity-shape signals + (e.g. ``[("outsourced", "outline")]`` for a PO with + ``entity_type="outsourced"``) — NOT for apply-outcome status, which + goes on ``applied_state_label`` / ``applied_state_variant``. Must be called inside ``with PrefabApp(...) as app, Card():`` — the helper does NOT open the Card; it only adds the CardHeader row. """ + # Reserve a fixed-width slot for the state Badge so the in-place + # morph (PREVIEW → APPLIED / FAILED / PARTIAL FAILURE) doesn't reflow + # the rest of the header — order-number badge, status badge, and + # extra badges to the right of the state Badge stay put across the + # state transition. ``min-w-32`` ≈ 8rem, comfortable for the longest + # current label "PARTIAL FAILURE"; ``text-center`` keeps shorter + # labels centered within the slot. + _state_badge_css = "min-w-32 text-center" with CardHeader(), Row(gap=2): with If("applied"): - CardTitle(content=f"{title_prefix} Created") + CardTitle(content=f"{title_prefix} {applied_title_suffix}") with Else(): CardTitle(content=f"{title_prefix} Preview") Badge(label=order_number, variant="outline") with If("applied"): - Badge(label="CREATED", variant="default") + Badge( + label=applied_state_label, + variant=applied_state_variant, + css_class=_state_badge_css, + ) with Else(): - Badge(label="PREVIEW", variant="secondary") + Badge(label="PREVIEW", variant="secondary", css_class=_state_badge_css) if status: Badge(label=status, variant=status_badge_variant(entity, status)) for label, variant in extra_badges: @@ -1765,9 +2079,15 @@ def _render_preview_footer( apply_action: list[Action] | None, cancel_action: list[Action], next_action_buttons: tuple[tuple[str, str], ...] = (), + applied_verb: str = "created", ) -> None: """Tier 4: CardFooter for a preview/apply card. + ``applied_verb`` controls the muted body line in applied state — create + cards default to ``"created"`` ("Purchase Order created."); modify cards + should pass ``"applied"`` ("Purchase Order Modify applied.") so the user- + visible copy matches the actual operation. + The applied-state View-in-Katana link and the per-entity next-action SendMessage buttons bind to ``{{ result. }}`` templates so they work in both entry paths: @@ -1782,7 +2102,7 @@ def _render_preview_footer( The View-in-Katana button is gated by ``If("result.katana_url")`` so it hides when the apply response carries no URL (defensive — every - create_* tool sets one today). + create_* tool sets one today; successful deletes null it out upstream). Must be called inside ``with PrefabApp(...) as app, Card():``. """ @@ -1800,7 +2120,7 @@ def _render_preview_footer( ) return with If("applied"): - Muted(content=f"{title_prefix} created.") + Muted(content=f"{title_prefix} {applied_verb}.") with Row(gap=2): with If("result.katana_url"): Button( @@ -1889,6 +2209,298 @@ def _render_party_line( Text(content=f"{label} ID: {entity_id}") +def _render_party_diff_line( + label: str, + *, + id_change: FieldChangeView, + name_change: FieldChangeView | None, + prior_name: str | None, +) -> None: + """Render the composite ``