Imperal Docs
Recipes

Master-detail panel

Build a master-detail panel in Imperal Cloud: a list on the left drives an editor in the center via ui.Call, with the newest item auto-opened on first load.

The most common multi-panel layout: a list on the left, an editor in the center. The list drives the editor via ui.Call. The editor returns ui.Empty() on first load so the center slot stays renderable. auto_action on the sidebar root then claims the center slot for the most-recent item on first load.


panels.py — complete minimal example
from __future__ import annotations

from imperal_sdk import Extension, ui

ext = Extension(
    "my-ext",
    display_name="My Extension",
    description="A master-detail extension for managing items.",
    actions_explicit=True,
)

# ── Inline stub data — replace with your real data fetch in production ─────
_ITEMS = [
    {"id": "item-1", "title": "First item",  "body": "Content for item one."},
    {"id": "item-2", "title": "Second item", "body": "Content for item two."},
    {"id": "item-3", "title": "Third item",  "body": "Content for item three."},
]


def _get_item(item_id: str) -> dict | None:
    return next((i for i in _ITEMS if i["id"] == item_id), None)


# ── Left sidebar — discovery-safe, sets auto_action on root ────────────────

@ext.panel(
    "sidebar",
    slot="left",
    title="Items",
    icon="📜",
    default_width=280,
    min_width=200,
    max_width=500,
)
async def sidebar(ctx: object, active_item_id: str = "", **kwargs: object) -> object:
    items = _ITEMS  # replace with: items = await fetch_items(ctx)

    root = ui.Stack(
        children=[
            ui.List(
                items=[
                    ui.ListItem(
                        id=item["id"],
                        title=item["title"],
                        selected=(item["id"] == active_item_id),
                        on_click=ui.Call(
                            "__panel__editor",
                            item_id=item["id"],
                        ),
                    )
                    for item in items
                ]
            )
        ],
        gap=2,
    )

    # Set auto_action only when there is content to show and no item is
    # already active (first load). The host fires this once per discovery
    # cycle to claim the center slot automatically.
    if items and not active_item_id:
        root.props["auto_action"] = ui.Call(
            "__panel__editor", item_id=items[0]["id"]
        )

    return root


# ── Center editor — returns ui.Empty() at batch discovery ─────────────────

@ext.panel(
    "editor",
    slot="center",
    center_overlay=True,
    title="Editor",
    icon="✏️",
)
async def editor(ctx: object, item_id: str = "", **kwargs: object) -> object:
    # On first load the platform calls this with no item_id.
    # Return ui.Empty() so the slot is held but visually indicates "select an item".
    # This keeps the center slot renderable while the sidebar's auto_action
    # claims it with the first real item.
    if not item_id:
        return ui.Empty(message="Select an item", icon="🖱️")

    item = _get_item(item_id)
    if item is None:
        return ui.Error(message="Item not found")

    return ui.Stack(
        children=[
            ui.Header(item["title"]),
            ui.RichEditor(
                content=item["body"],
                on_change=ui.Call("save_item", item_id=item_id),
            ),
        ],
        gap=4,
    )

Walk-through

Why ui.Empty() and not return None? Returning ui.Empty() (instead of None) keeps the center slot renderable with a visible placeholder and lets the sidebar's auto_action claim the center with the first real item on load. Returning None from a center panel is appropriate only in edge cases where you do not want the slot populated at all (for example, a center panel that should only ever appear as a center overlay — see the center-overlay recipe).

Why auto_action on the sidebar root, not a child node? auto_action is read only from the root UINode of the left panel — not from nested children or the right panel. The root is the ui.Stack returned from the sidebar handler — set root.props["auto_action"] after building the root, before returning.

Why the if items and not active_item_id guard? auto_action fires once after the panel loads. The not active_item_id guard prevents the sidebar from re-declaring auto_action when a specific item is already active (e.g., when the sidebar is refreshed after a list update), so it won't overwrite whatever the user already has open. The items guard prevents setting auto_action when there is nothing to open — opening an empty editor is worse UX than showing the empty-state placeholder.

How item_id flows from click to editor handler. ui.Call("__panel__editor", item_id=item["id"]) dispatches an action carrying item_id. The platform calls __panel__editor with that value, and the editor handler receives item_id as a kwarg and branches on it. The platform also remembers the parameters a panel was last called with, so a later refresh_panels=["editor"] re-renders with the same item_id — you do not need to thread it through every action result.


On this page