ADR-0031: Entity Header Pattern¶
Status: Proposed Date: 2026-05-23 Related: ADR-0028 (status reasons surfaced in header), ADR-0030 (reuses EntityAction shape)
Related update: ADR-0035 specifies the shadcn/Radix primitives (Dialog, DropdownMenu, Button) used to implement the entity actions defined here. The header's universal status display now renders ADR-0033's
NormalizedStatevia theStateStackcomponent incomponents/lion/. This ADR remains authoritative for the header's information architecture and action set; the rendering details flow through ADR-0035.
Context¶
Studio's entity detail pages (/shows/<topic>, /runs/<id>, /projects/<name>, /sessions/<id>, etc.) each tell a different story in a different shape. The Run page leads with metric cards. The Show page leads with the plan text. The Session page leads with a branches table. The Project page is sparse — mostly counts.
This inconsistency is operational debt:
1. Operators relearn each page¶
Every page has its own visual hierarchy. The user has to figure out where "current status" is, where "what to do next" is, and where the raw evidence sits — separately for each entity type. The cost is not the time per page; it's the cognitive switching cost between pages during triage.
2. The "answer in 5 seconds" target is unmet¶
A well-designed entity page should answer in five seconds:
- What is this thing? (goal / purpose)
- What state is it in? (status)
- Why is it in that state? (reason — from ADR-0028)
- What's the last thing that happened? (last event)
- What action is available? (primary verb)
Today most pages bury at least three of these. The reviewer crash from last week (session.phantom.process_dead) required clicking through to the branches tab, scrolling logs, and inferring "this needs pruning". That inference should be a button.
3. Action buttons drift across pages¶
The Run page has "Open", "Inspect". The Session page has different verbs. The Show page has yet others. There is no shared catalogue of actions and no shared decision about which actions are enabled in which state. A consistent set of action descriptors — owned by the backend, rendered by the frontend — is the simplest way to keep action vocabulary aligned with entity state semantics.
4. The header pattern is the primitive the redesign needs¶
ChatGPT's frontend critique proposes redesigning every page. That is many separate decisions. The header is a single primitive that provides 60% of the consistency improvement and lets each page-level redesign happen incrementally without re-litigating the structure each time.
Decision¶
Introduce a reusable EntityHeader frontend component, backed by a backend-computed header field included in every entity detail endpoint response. The backend owns the data shape and action eligibility; the frontend owns presentation, confirmation UX, and route transitions.
Initial rollout targets four pages: Show, Run, Project, Session. Other entity pages (Play, Team, Invocation, Schedule, Agent, Playbook, Plugin) migrate incrementally.
When ADR-0028 (status reasons) has not yet landed for a particular entity type, the header degrades gracefully — it renders the status pill alone without a reason tooltip. No blocker dependency.
1. Backend header field shape¶
Pydantic models in apps/studio/server/schemas/entity_header.py:
from typing import Literal, Optional
from pydantic import BaseModel
# Mirrors ADR-0028's VALID_ENTITY_TYPES plus the library/config kinds
# the frontend renders headers for. Storage-backed entity kinds
# (left column) match ADR-0028 exactly; library/config kinds (right
# column) live on disk and don't participate in status_transitions.
#
# `run` is NOT in this list — /runs/<id> is a frontend route over the
# `session` entity, per ENTITY_ROUTE_ALIASES in ADR-0028.
EntityKind = Literal[
# Storage entities (have rows, status, status_reason; see ADR-0028)
"session", "show", "play", "invocation", "team", "schedule_run",
# Library / config (filesystem-backed; no status_transitions)
"project", "agent", "playbook", "plugin", "schedule",
]
# Deferred until ADR-0021 (chain_runs) lands: "chain", "chain_run".
ActionKind = Literal["primary", "secondary", "danger"]
ActionId = Literal[
"open", "inspect", "retry", "prune", "edit", "abort",
"open_artifacts", "open_logs", "open_workspace",
"reassign", "snooze", "dismiss",
"manual_trigger", # for schedules
]
class EvidenceRef(BaseModel):
kind: str
id: Optional[str] = None
path: Optional[str] = None
ref: Optional[str] = None
url: Optional[str] = None
label: Optional[str] = None
class StatusReasonView(BaseModel):
code: str
summary: str
evidence_refs: list[EvidenceRef] = []
class EntityAction(BaseModel):
id: ActionId
label: str
kind: ActionKind = "secondary"
method: Optional[Literal["GET", "POST", "DELETE", "PATCH"]] = None
href: Optional[str] = None # for navigation actions
endpoint: Optional[str] = None # for API actions
requires_confirm: bool = False
confirm_label: Optional[str] = None # custom confirm prompt
disabled: bool = False
disabled_reason: Optional[str] = None
class EntityHeader(BaseModel):
kind: EntityKind
id: str
title: str
subtitle: Optional[str] = None
goal: Optional[str] = None
status: str
status_taxonomy: str # 'session', 'show', 'play', etc. — picks the StatusPill scheme from ADR-0025
status_reason: Optional[StatusReasonView] = None
status_source: Optional[str] = None # e.g. shows.status_source from ADR-0011
last_event: Optional["LastEvent"] = None
next_action: Optional[EntityAction] = None
owner: Optional["EntityOwner"] = None
related: list["EntityLink"] = []
actions: list[EntityAction] = []
updated_at: Optional[float] = None
created_at: Optional[float] = None
class LastEvent(BaseModel):
summary: str
at: float
actor: Optional[str] = None # session_id, user, doctor_auto, etc.
href: Optional[str] = None
class EntityOwner(BaseModel):
kind: Literal["agent", "user", "system"]
id: Optional[str] = None
label: str
class EntityLink(BaseModel):
kind: EntityKind
id: str
label: str
href: str
next_action is a duplicate pointer into actions[] for emphasis — the header renders it prominently. If next_action is set, its id must appear in actions[]. The duplication is deliberate: the backend declares its judgment about the primary verb, the frontend honors it.
2. Endpoint contract¶
Every detail endpoint includes header in its response:
GET /api/shows/sweep
{
"header": {
"kind": "show",
"id": "sweep",
"title": "sweep",
"subtitle": "marketplace OSS discovery",
"goal": "Resolve 81 OSS discovery issues by merging implementations and bug fixes.",
"status": "active",
"status_taxonomy": "show",
"status_reason": {
"code": "show.blocked.no_ready_plays",
"summary": "All 12 plays are pending; no play has its dependencies resolved.",
"evidence_refs": [
{"kind": "play", "id": "...", "label": "rust-cleanup"},
{"kind": "play", "id": "...", "label": "ci-fixes"}
]
},
"last_event": {
"summary": "Plan committed at sweep/_show.md",
"at": 1716517000.0,
"actor": "operator"
},
"next_action": {
"id": "edit", "label": "Edit plan", "kind": "primary",
"href": "/shows/sweep/plan/edit"
},
"owner": {"kind": "user", "label": "operator"},
"related": [
{"kind": "project", "id": "lionagi", "label": "lionagi", "href": "/projects/lionagi"}
],
"actions": [
{"id": "edit", "label": "Edit plan", "kind": "primary", "href": "..."},
{"id": "open_workspace", "label": "Open workspace", "kind": "secondary", "href": "..."},
{"id": "abort", "label": "Abort show", "kind": "danger", "endpoint": "/api/shows/sweep/abort", "method": "POST", "requires_confirm": true, "confirm_label": "Abort the sweep show? Pending plays will be cancelled."}
],
"updated_at": 1716517300.0,
"created_at": 1716000000.0
},
"plays": [...],
...rest of the existing detail payload
}
The detail payload itself is unchanged — header is added alongside existing fields. Pages that don't yet render EntityHeader ignore it at no cost.
3. Frontend component¶
// apps/studio/frontend/components/entity/EntityHeader.tsx
// StatusPill is the existing component at apps/studio/frontend/components/StatusPill.tsx
// (default export). ADR-0028 adds a `reason` prop to it; this header
// passes the prop, NOT a tooltip child, so the API stays consistent
// with what ADR-0028 specifies and with the current component's
// prop-based API surface (see apps/studio/frontend/components/StatusPill.tsx:17).
import StatusPill from "@/components/StatusPill";
import { ActionButton } from "@/components/entity/ActionButton";
import type { EntityHeader as TEntityHeader } from "@/lib/types";
export function EntityHeader({ header }: { header: TEntityHeader }) {
return (
<header className="entity-header">
<div className="entity-header__title-row">
<h1>{header.title}</h1>
<StatusPill
taxonomy={header.status_taxonomy}
value={header.status}
reason={header.status_reason ?? undefined}
/>
{header.next_action && (
<ActionButton action={header.next_action} prominent />
)}
</div>
{header.goal && <p className="entity-header__goal">{header.goal}</p>}
<div className="entity-header__meta">
{header.last_event && <LastEventLine event={header.last_event} />}
{header.owner && <OwnerChip owner={header.owner} />}
{header.related.map((link) => (
<RelatedChip key={link.id} link={link} />
))}
</div>
<div className="entity-header__actions">
{header.actions
.filter((a) => a.id !== header.next_action?.id)
.map((action) => (
<ActionButton key={action.id} action={action} />
))}
</div>
</header>
);
}
The ActionButton component (reused from ADR-0030's Attention Queue items) handles:
href-only actions →<a>/ Next.jsLinkendpoint+methodactions → fetch + toast + refreshrequires_confirm→ modal withconfirm_labeldisabled→ grayed out with tooltip showingdisabled_reason
4. Action eligibility — backend decides¶
Backend computes actions[] from entity state. Examples:
def compute_show_actions(show: ShowRow) -> list[EntityAction]:
actions = [
EntityAction(id="edit", label="Edit plan", kind="primary",
href=f"/shows/{show.topic}/plan/edit"),
EntityAction(id="open_workspace", label="Open workspace",
href=f"/shows/{show.topic}/workspace"),
]
if show.status == "active":
actions.append(EntityAction(
id="abort", label="Abort show", kind="danger",
endpoint=f"/api/shows/{show.topic}/abort", method="POST",
requires_confirm=True,
confirm_label="Abort the show? Pending plays will be cancelled.",
))
return actions
def compute_session_actions(s: SessionRow) -> list[EntityAction]:
actions = [EntityAction(id="open", label="Open run",
kind="primary", href=f"/runs/{s.id}")]
if s.status == "failed" and s.invocation_kind in ("play", "agent"):
actions.append(EntityAction(
id="retry", label="Retry", kind="secondary",
endpoint=f"/api/runs/{s.id}/retry", method="POST",
))
if s.status_reason_code and s.status_reason_code.startswith("session.phantom"):
actions.append(EntityAction(
id="prune", label="Prune", kind="danger",
endpoint=f"/api/admin/sessions/{s.id}", method="DELETE",
requires_confirm=True,
confirm_label="Prune this phantom session? This deletes the session row and any associated artifacts directory.",
))
return actions
The frontend never decides "should this entity show a Retry button" — it just renders what the backend gave it. This keeps the state-to-action mapping in one place.
5. Graceful degradation when ADR-0028 not landed¶
If a backend service has not yet been updated to populate status_reason, the header passes reason={undefined} to <StatusPill>. ADR-0028 specifies that the pill renders unchanged when reason is undefined (no tooltip affordance, no popover). No errors, no broken UI.
Per-entity rollout order:
- ADR-0028 columns added (schema migration)
- CLI / executor writes reasons for one entity type (say, sessions)
- Session detail endpoint includes
status_reasonin its header - Repeat for next entity type
Pages migrate to EntityHeader independently of reason rollout. The header is useful even with status_reason = None.
6. Initial rollout: 4 pages¶
| Page | Existing struct | Why first |
|---|---|---|
Run detail (/runs/<id>) | Metric cards + tabs | Most-visited; current page hides "why this failed" |
Show detail (/shows/<topic>) | Plan text + plays table | Needs the goal + next-action clarity most |
Session detail (/sessions/<id>) | Branches table | Phantom diagnostics need reason + prune action in the header |
Project detail (/projects/<name>) | Sparse counts | Currently weakest page; header alone gives it shape |
Second iteration: Play, Team, Invocation. Third iteration: Agent, Playbook, Plugin, Schedule (library + admin items).
7. File map¶
New files:
apps/studio/server/schemas/entity_header.py # Pydantic models
apps/studio/server/services/entity_header.py # compute_*_header() per entity
apps/studio/frontend/components/entity/EntityHeader.tsx
apps/studio/frontend/components/entity/ActionButton.tsx
apps/studio/frontend/components/entity/EntityOwnerChip.tsx
apps/studio/frontend/components/entity/EntityRelatedChip.tsx
apps/studio/frontend/lib/types/entity_header.ts # mirror Pydantic via TS interfaces
Modified files:
apps/studio/server/routers/runs.py # include header on detail
apps/studio/server/routers/shows.py # include header on detail
apps/studio/server/routers/sessions.py # include header on detail
apps/studio/server/routers/projects.py # include header on detail
apps/studio/frontend/components/StatusPill.tsx # add `reason` prop (per ADR-0028)
apps/studio/frontend/app/runs/[id]/page.tsx # mount EntityHeader
apps/studio/frontend/app/shows/[topic]/page.tsx # mount EntityHeader
apps/studio/frontend/app/sessions/[id]/page.tsx # mount EntityHeader
apps/studio/frontend/app/projects/[name]/page.tsx # mount EntityHeader
Consequences¶
Positive
-
Every adopted page answers "what / status / why / last / next / evidence" in the same place, with the same component, styled the same way.
-
Backend-computed
actions[]keeps state-to-action eligibility in one place per entity, not duplicated across pages. -
ActionButtonandEntityActionare reused by ADR-0030's Attention Queue — one descriptor shape, two consumers. -
Migration is per-page, not big-bang. Pages without the header keep working; pages with the header look consistent.
-
Graceful degradation when ADR-0028 hasn't reached a given entity type — the header renders the status pill alone. No blocker dependency.
-
Frontend stays a renderer. Adding a new entity-type page in the future means writing one
compute_<entity>_header()function and mounting<EntityHeader />.
Negative
-
Adds a backend-computed payload to every detail response. For the initial 4 endpoints this is a few hundred bytes; negligible.
-
Pages in transition will look inconsistent — half have the header, half don't. Acceptable for a few weeks of rollout.
-
The
EntityActionenum (ActionId) is a closed set in v1. Adding a new action verb is a backend + frontend change. Trade-off: open string would mean drift. Closed set keeps the action vocabulary curated. -
Backend has to know about routes (
href: "/runs/<id>"). This couples server logic to frontend URL structure. The mitigation: a singleapps/studio/server/services/routes.pymodule that owns the route mapping, used by everycompute_*_header().
Alternatives Considered¶
| Alternative | Why Rejected |
|---|---|
| Compute the header entirely client-side from existing fields | Duplicates business logic. The frontend would have to know what status='gate_failed' AND attempt=2 means and which actions to offer — that's domain semantics, not presentation. |
New GET /api/entities/<kind>/<id>/header endpoint | Forces two round trips per page (header + detail). Embedding header in the existing detail endpoint costs nothing extra and removes the loading-state coordination problem. |
| Hardcode action buttons per page component | What we have today. Drift is inevitable. The whole reason for this ADR. |
| Redesign every entity page in one iteration | Too much surface in one PR; couples the primitive's design to every page's design simultaneously. Per-page rollout is safer. |
| Use a generic CRUD framework (e.g., react-admin) | Wrong shape — Studio's entities are not CRUD records, they are operational state. The header is curated, not generated from a schema. |
| Server-rendered HTML for the header (skip the API field) | Mixes server-rendered HTML into a Next.js client-rendered app. Would need a separate render path and break SSR consistency. |
Skip the next_action duplication, derive in frontend ("first primary action wins") | Loses backend's ability to pick a contextual primary action that isn't always position-0. Cheap to keep both. |
Non-Goals¶
-
No role-specific layouts. Studio is single-user (ADR-0008). When multi-user becomes a goal, role-specific defaults can plug into the same
EntityHeadervia props. -
No general redesign of entity pages. This ADR adds a header, not a page redesign. Tabs, tables, log panels, raw markdown viewers all stay where they are.
-
No hiding of raw internals. The header sits on top; existing internals stay accessible below it. Studio's users are technical; hiding state is the wrong move.
-
No header on list pages.
EntityHeaderis for detail pages. List rows have their own (much smaller) summary chip rendered by a separate component. -
No theming / customization of action verbs by users. Action vocabulary is fixed; localization is out of scope for v1.
-
No undo for executed actions. Confirmations are the only safety net. Undo is a separate, larger design.
-
No keyboard shortcuts on headers in v1. Command palette / shortcuts are deferred per ADR-0032's non-goal list.
References¶
- ADR-0025 —
StatusPillcomponent (reused viastatus_taxonomy). - ADR-0028 —
status_reasonfield surfaced in tooltip / popover. - ADR-0030 — Reuses
EntityActionshape andActionButtoncomponent. - ADR-0024 — Phantom session reasons feed Session header actions (Prune).
apps/studio/frontend/components/StatusPill.tsx— Existing pill component (default export; this ADR + ADR-0028 add areasonprop).- ChatGPT frontend design review (external) — proposed an entity header on every page; this ADR scopes the rollout to four pages first, defers the rest, and pins the
EntityActionshape so the Attention Queue and entity headers share one descriptor.
Prior art¶
-
Stripe Dashboard entity headers — every object (Customer, Payment, Subscription) has a top-of-page header with object id, status pill, primary action button, last event line, related links. The pattern is so consistent that operators can scan an unfamiliar object type and orient in seconds. Direct visual influence.
-
GitHub Issue header — title + status pill (open/closed/merged)
-
primary action (close/reopen) + assignees + labels — same shape, same intent. The header is the page; everything below is evidence.
-
Linear ticket detail — header with title, status, owner, related cycle/project links, then the body below. Same model.