Following the release of Forge Rovo agents, Rovo MCP, and Rovo actions, we’re exploring how your Forge agents and tools can present interactive UI on Atlassian Rovo surfaces (e.g. Rovo Chat) and in external AI experiences.
This RFC seeks early feedback on a proposed Early Access Program (EAP) for A2UI components in Rovo actions via Rovo Agents rendered in Rovo Chat. It also outlines our longer-term direction: making those components available through Rovo MCP (Preview) in external AI chats, alongside support for custom UI MCP-apps through rovo:mcp. These future integrations are currently planned but scope and implementation is yet to be aligned.
The ask: We’d like your feedback on the proposed API, any questions it raises, and whether it supports the experiences you want to build for your tools and agents. Share your use cases and anything you’d need that the proposal doesn’t yet cover.
Milestone 1: A2UI High-Level Components rendered in Rovo Chat
Milestone 1 introduces high-level A2UI components that your Rovo actions can return and render in Rovo Chat. This RFC covers the proposed API and interaction model, with an example component catalogue for the Early Access Program to follow.
The candidate EAP components are:
ChoiceList: Select from bounded options with descriptions, with explicit submission.
ConfirmationPanel: Request explicit confirmation with a title, summary, and confirm button.
ResultPanel: Display a read-only outcome with title and summary. Best used after an agent action has been completed
Analytical Chart: Visualise distributions and trends with percentiles, summary, and data access.
Explore more upcoming components and vote here:https://forge-a2ui-catalogue.kaizen.shared.atlassian-3p.com/
Future Plans: Exposing A2UI and custom UI via MCP-apps in rovo:mcp
Our goal is to let you build an interactive Forge action once and reuse it across Rovo Chat and supported external AI chats. We plan to make the same A2UI components available through rovo:mcp, so connecting your action to a Rovo agent or exposing it as an MCP tool would bring its UI with it, without requiring a separate frontend for each supported client.
For experiences that need bespoke layouts or more complex workflows, we also plan to support Custom UI through MCP Apps in rovo:mcp. This would give you the flexibility to build app-like experiences within chat, alongside the reusable A2UI components.
These plans are beyond the EAP scope and client compatibility and the supported interaction model are still being defined.
See Build a custom frontend below for the proposed MCP-app developer flow.
Choosing between A2UI prebuilt components and custom MCP-apps
| Consideration | Prebuilt A2UI components | Resource frontend MCP-apps |
|---|---|---|
| What it offers | 1. Consistent end-user experience in chat with Atlassian agents 2. Create once and will surface on both internal and external chat surfaces (Future plan) 3. Simple to use | 1. Creates custom UI experiences in Chat which may not exist in A2UI components 2. Enables re-use of existing frontend code |
| What you write | A component name, its props (display settings and data), and the operation to call for each event. | Your frontend code, its local state and calls to backend operations. |
What ui contains |
{ component, props, events? }, checked against the declared catalog. |
Your app’s data. It does not describe a required component tree. |
| Interaction | The component collects submitted values and calls the operation you specified. | Your frontend calls invokeOperation(name, { input }). |
| Limits | One component can be rendered per view, limited customisation. | Support depends on the host’s resource rendering and available bridge features. |
Example code for A2UI pre-built components and custom ui in MCP-apps
In both examples below, a user approves expenses in Rovo Chat: they select expenses, review the total, confirm approval and see the result. The Forge action uses a prebuilt component for each step. Your app checks permissions and handles the approval, while Forge displays the UI.
Build with prebuilt components
1. Find a component and read its contract
The sample component reference defines ChoiceList, ConfirmationPanel and ResultPanel, with mockups, props and events. These are proposed examples, not implemented catalog entries. That section includes their prop types, defaults, supported events and submitted-value shapes. The code below uses that same contract; you do not need to work with raw A2UI messages or schemas.
For example, a generic ChoiceList can present expenses, issue priorities or project choices. Its options have IDs, labels and optional descriptions; the component itself knows nothing about expenses. ChoiceList.props.selectedIds sets the initial selection. Its onSubmit event supplies values.selectedIds. The reference needs to explain both so you can connect a component to a handler without guessing.
2. Declare the tool’s output
This manifest excerpt uses the proposed outputs configuration. Standard app metadata, permissions and agent/MCP bindings are omitted. The handler import paths later in this document are also proposed.
modules:
action:
- key: quick-review-expenses
name: Review expenses
description: Select expenses, review the total, and confirm approval.
function: quick-review
actionVerb: UPDATE # Reachable operations include an approval write.
inputs:
reportId:
title: Report ID
type: string
required: true
description: The expense report to review.
outputs:
text: false # Completing this particular task requires UI.
ui:
type: prebuilt
catalog: forge-standard@1 # Illustrative catalog identifier.
components: [ChoiceList, ConfirmationPanel, ResultPanel]
function:
- key: quick-review
handler: prebuilt.handler
The function entry connects quick-review to the exported handler in src/prebuilt.js.
The components list names the components this tool is allowed to show. Each successful response chooses one. A later operation can switch to another, such as replacing the selection form with a confirmation panel. You define the operations in JavaScript, without repeating them in the manifest.
3. Implement the initial handler and operations
The following is the complete UI-handling example for prebuilt.js. The imported business services are app code; their responsibilities are described below. The sample component reference defines every component prop and event used here.
Full prebuilt handler: prebuilt.js
// Proposed SDK, not an installed Forge API. App business services are omitted.
import { defineTool } from '@forge/api/agentic-ui';
import { loadReport, prepareApproval, commitApproval } from './expenses.js';
function selection(report, selectedIds = [], errorMessage) {
return {
component: 'ChoiceList',
props: {
question: 'Review expenses',
selection: 'multiple',
options: report.expenses.map(expense => ({
id: expense.id, label: expense.description, description: expense.amountLabel,
})),
selectedIds,
submitLabel: 'Review selected',
...(errorMessage ? { errorMessage } : {}),
},
events: {
onSubmit: { operation: 'review', input: { reportId: report.id } },
},
};
}
export const handler = defineTool({
async run(input, forgeContext) {
// loadReport validates reportId and checks the current user's access.
const report = await loadReport(input.reportId, forgeContext);
return {
text: `Report ${report.id}: ${report.expenses.length} expenses awaiting selection.`,
ui: selection(report),
};
},
operations: {
async review({ input, values }, forgeContext) {
const report = await loadReport(input.reportId, forgeContext);
const ids = values?.selectedIds; // ChoiceList IDs identify expenses in this app.
const allowed = new Set(report.expenses.map(expense => expense.id));
if (!Array.isArray(ids) || ids.length === 0 ||
ids.some(id => typeof id !== 'string' || !allowed.has(id)) ||
new Set(ids).size !== ids.length) {
const message = 'Select at least one available expense.';
return {
text: message,
ui: selection(report, [], message), // Complete correction view.
};
}
// Rechecks eligibility, calculates totals on the backend and stores
// a prepared review bound to this user and report. No approval yet.
const review = await prepareApproval(report.id, ids, forgeContext);
return {
text: review.summary,
ui: {
component: 'ConfirmationPanel',
props: {
title: 'Confirm approval',
summary: review.summary,
confirmLabel: 'Approve',
},
events: {
onConfirm: {
operation: 'approve',
input: { reportId: report.id, reviewId: review.id },
},
},
},
};
},
async approve({ input }, forgeContext) {
// Rechecks authorization and freshness. Repeated commits of the same
// review must return its recorded outcome, without approving twice.
const result = await commitApproval(input, forgeContext);
return {
text: result.summary,
ui: {
component: 'ResultPanel',
props: { title: 'Expenses approved', summary: result.summary },
},
completed: true,
};
},
},
});
In the event configuration, input carries data your app already knows, such as the report ID. values carries the user’s submitted selection, collected by the component. For the selection event, the review operation receives:
{ input: { reportId: 'EXP-42' }, values: { selectedIds: ['train', 'hotel'] } }
forgeContext arrives separately and contains trusted Forge execution context. Your backend must still check whether the user can access the report and approve those expenses. An ID supplied by the UI is not permission to act on it.
The sample ChoiceList returns values.selectedIds. The review handler treats those IDs as expenses because this app created the options from its report. The component does not impose that business meaning.
Every successful prebuilt call returns everything needed to show the next component: its name, props and any events. A correction returns the selection component with an error message; success replaces it with confirmation. The final result has no event bindings and declares completed: true. Include the events needed by each new view; events from the previous view are not kept automatically.
Build a custom frontend
Use a resource frontend when you need your own layout, controls or frontend framework. This builds on Forge resources and Custom UI. It uses the same defineTool, operations and result fields. Its ui is app-owned data, not a prebuilt component description.
1. Declare a resource output
Both example tools are entries in the same modules.action list. They use the same action, input and function configuration. Their UI output settings select either prebuilt components or a frontend resource. Choose whichever tools your app needs; both are shown here for comparison. “Quick” and “detailed” are example app names, not different APIs or fixed internal/external categories.
| Setting | Prebuilt example | Custom frontend example |
|---|---|---|
Action key under modules.action |
quick-review-expenses |
detailed-review-expenses |
| Input | reportId |
reportId |
| Action verb | UPDATE |
UPDATE |
| Function key | quick-review |
detailed-review |
| Handler | prebuilt.handler |
resource.handler |
Proposed outputs.ui.type |
prebuilt |
resource |
| UI settings | catalog and allowed components |
A resource key referencing the frontend |
| Backend API | defineTool({ run, operations }) |
defineTool({ run, operations }) |
The manifest below connects both actions to their functions and UI configuration. It also shows agent/MCP lists referring to those same action keys. Standard app metadata and permissions are omitted. The outputs presentation settings are proposed additions.
modules:
rovo:agent:
- key: expense-assistant
name: Expense assistant
prompt: Help users review expense reports using quick-review-expenses.
actions: [quick-review-expenses] # Refers to an action below.
rovo:mcp:
- key: expense-tools
name: Expense tools
tools: [detailed-review-expenses] # Refers to an action below.
action: # Both UI approaches use this same list of tool definitions.
- key: quick-review-expenses
name: Quick expense review
description: Select expenses, review the total, and confirm approval.
function: quick-review
actionVerb: UPDATE
inputs:
reportId:
title: Report ID
type: string
required: true
description: The expense report to review.
outputs: # Proposed presentation settings.
text: false
ui:
type: prebuilt
catalog: forge-standard@1
components: [ChoiceList, ConfirmationPanel, ResultPanel]
- key: detailed-review-expenses
name: Detailed expense review
description: Review and approve expenses through the app's custom frontend.
function: detailed-review
actionVerb: UPDATE
inputs:
reportId:
title: Report ID
type: string
required: true
description: The expense report to review.
outputs: # Same output structure; a different UI approach.
text: false
ui:
type: resource
resource: expense-review-ui # Matches resources.key below.
function:
- key: quick-review
handler: prebuilt.handler # Exported handler in src/prebuilt.js.
- key: detailed-review
handler: resource.handler # Exported handler in src/resource.js.
resources:
- key: expense-review-ui
path: static/expense-review/dist # Built frontend, including index.html.
detailed-review-expenses.function selects the backend function. Its outputs.ui.resource selects the frontend. The top-level resources entry identifies the frontend files; it does not define another action. The binding arrangement above is an example: either tool can be connected to another integration when that integration supports its UI approach.
2. Return app-owned data
The full resource handler reuses the same business services. Its run returns:
{
text: 'Report EXP-42: 2 expenses awaiting selection.',
ui: {
step: 'selection', // An app-defined state, not a Forge enum.
report: {
id: 'EXP-42',
expenses: [
{ id: 'train', description: 'Train ticket', amountLabel: 'AUD 30.00' },
{ id: 'hotel', description: 'Hotel', amountLabel: 'AUD 90.00' },
],
},
selectedIds: [],
},
}
Its review operation reads input.expenseIds, rather than host-collected values. After preparing a review, it returns:
{
text: 'Approve 2 expenses totalling AUD 120.00?',
ui: {
step: 'confirmation', reportId: 'EXP-42', reviewId: 'review-7',
summary: 'Approve 2 expenses totalling AUD 120.00?',
},
}
Approval returns ui: { step: 'result', summary } and completed: true. These step, report and summary fields are defined by the app; Forge does not require this state model.
3. Read context and call operations
The complete React example renders those three app-defined views. The bridge calls at its center are:
import { getContext, invokeOperation } from '@forge/bridge/agentic-ui';
const initial = await getContext(); // Initial data for this mounted UI instance.
// Render initial.ui and honor initial.completed.
const review = await invokeOperation('review', {
input: { reportId: 'EXP-42', expenseIds: ['train', 'hotel'] },
});
// Render review.ui. The call does not itself approve anything.
// On a later, separate user confirmation:
const approval = await invokeOperation('approve', {
input: { reportId: review.ui.reportId, reviewId: review.ui.reviewId },
});
// Render approval.ui and disable interaction when approval.completed is true.
This excerpt shows the call sequence; the complete frontend includes loading, error and pending states. It prevents duplicate clicks in that view. The backend still handles repeated calls or calls from other views. Unsubmitted checkbox edits stay in frontend state and are not automatically sent to the agent.
Where can the tool appear?
The Rovo Agent and Rovo MCP (Preview) modules select where actions are available. They refer to the action keys from the combined manifest; they do not define separate prebuilt and custom-frontend action types.
| Manifest section | What it does in this example |
|---|---|
modules.action |
Defines both quick-review-expenses and detailed-review-expenses. |
rovo:agent.actions |
Lets the expense agent call the listed action. |
rovo:mcp.tools |
Exposes the listed action as an MCP tool. |
The proposal aims to let you reuse either UI approach across supported integrations. The example is not a support matrix or a promise that every client can display either UI today. An interactive tool needs rendering, operation calls and any required agent-context updates. If the app can complete the task through text, it can use that approach when UI is unavailable. If the task requires UI, the tool should only be offered where that UI can work.
Results, text and completion
| Result field | Meaning |
|---|---|
text |
A string or JSON object for the agent and conversation record, describing the same facts and status as the UI. |
ui |
The next component and its settings for prebuilt UI; data your own frontend understands for resource UI. |
completed |
true declares that this UI experience is finished. Omitted or false does not declare completion. |
outputs.text says whether the task can be completed through text; it does not set the type of the returned text field. It defaults to true: the app can finish the task without UI. Set it to false when the task requires interaction with UI. Either way, meaningful outcomes still need text so the conversation has an understandable record. For the examples here, the history must distinguish “awaiting selection,” “awaiting confirmation” and “approved”.
On supported integrations, operation text updates the agent’s context without requesting another model turn. A frontend edit alone does not do this. JSON text must preserve the same facts when shown as text; you should not rely on the model to reconstruct missing approval outcomes.
completed: true should retain the final view and stop further interaction; it does not request removal of the UI. External clients may keep older views active, so backend authorization and current-state checks still matter. Starting the tool again creates a new UI instance.
For prebuilt UI, omitting ui is not a supported successful response in this proposal. For resource UI, an omitted ui means no replacement data; the frontend keeps its current view. Omission does not mean completion. Read-only tools may return their final view and completed: true immediately.
Validation and failures
Return an ordinary result with a correction view when the user can fix a business input, as the empty-selection example does. Unexpected app or platform failures reject the frontend call with an error. Do not assume a timed-out approval failed to commit; check the report before retrying. The proposal does not automatically queue or retry your writes.
For prebuilt components, the host renders the complete response using the component contract. Your resource frontend renders its own pending, correction and failure states. Neither approach removes the need for backend freshness and repeat-call checks.
Compatibility and initial limits
The catalog name includes a version, illustrated by forge-standard@1. Changes that keep existing components working can stay in that version. If a component changes in a way that requires you to update your app, you must explicitly choose a new catalog version. Deploying your own frontend is separate from upgrading a component catalog.
The first version focuses on submitting a form or clicking a confirmation button. It does not define a backend call for every keystroke or checkbox change, combining prebuilt components into a custom layout, restoring every unsaved frontend edit, or using Forge Remote as the direct handler. Your own frontend can still support local interactions without calling the backend.
What the design should achieve
Alongside your specific use cases, please consider whether the examples achieve these goals:
| Goal | What to consider |
|---|---|
| Build without learning two different APIs | Can you understand both approaches and share business logic where useful? |
| Choose when to upgrade | Is it clear how component upgrades differ from deploying your own frontend? |
| Keep the conversation understandable | Does the text explain what is waiting for input and what has actually happened? |
| Use Forge permissions consistently | Is it clear which checks your backend still needs to perform? |
| Make completion clear | Can users tell when the experience has finished and what to do when returning later? |
Questions for partners
- What experience would you build, and which UI approach would you choose? What would you need that is missing here?
- After reading the sample component reference, is it clear how to connect a component to an operation?
- Did you have any other feedback on the API or proposed developer experience?
Appendix: terms
| Term | Meaning |
|---|---|
| Tool | A Forge action made available to an AI agent. |
| Operation | A UI-invoked backend function belonging to that tool. |
| UI instance | The experience produced by one tool invocation and its later updates. |
| Catalog | A versioned collection of component contracts supplied by the platform. |
| Resource frontend | Your app’s frontend bundle, selected by a Forge resource key. |
| Surface | The client displaying the conversation, such as Rovo Chat or Claude Desktop. |
Appendix: sample component reference
Proposal for partner review. These reusable components and their exact schemas are documentation examples, not available Forge catalog entries. They make the API walkthrough concrete. ChoiceList uses a candidate catalog name, but the precise props and event values below are proposed for this walkthrough. The catalog identifier forge-standard@1 is also proposed; no availability on a particular surface is implied.
The walkthrough shows how to use these components. The tables and event examples below define the proposed developer-facing contract. Forge handles the client-specific UI format. Your backend must still validate submitted data and authorize every operation.
Supply unique option IDs, ensure selected IDs refer to existing options, and select at most one ID in single-selection mode. Every enabled interactive component needs its event binding. These rules apply alongside the prop types below.
ChoiceList
Use this component when a user needs to choose one or more options: issue priorities, projects, candidate proposals or expenses. It renders a question and options with labels and optional descriptions. Single selection uses radio buttons; multiple selection uses checkboxes. The app decides what each option ID means and what submitting it does.
| Prop | Type | Required / default | Meaning |
|---|---|---|---|
question |
string | Required | The question or heading shown above the options. |
selection |
"single" or "multiple" |
Required | Whether the user may choose one or several options. |
options |
array of { id, label, description? } |
Required | Available options. Each field is a string; IDs must be unique. Descriptions are optional supporting text. |
selectedIds |
string array | Required | Initially selected option IDs. IDs must exist in options; single-selection mode permits at most one. A correction response can restore valid selections. |
submitLabel |
string | Required | Label for the submit button. |
errorMessage |
string | Optional; no error | Correction message above the submit button. |
disabled |
boolean | Optional; false |
Disables selection and submission while retaining visible content. |
Event: onSubmit
For this expense example, the app maps expense.id to option id, the expense description to label, and the formatted amount to option description. None of those expense concepts is a component prop.
Bind onSubmit under ui.events. The host collects the current selection and calls the bound operation. Submitting without selecting anything produces an empty array; the backend decides whether that is valid.
events: {
onSubmit: {
operation: 'review',
input: { reportId: 'EXP-42' }, // App-supplied context for this view.
},
}
The operation receives:
{
input: { reportId: 'EXP-42' },
values: { selectedIds: ['train', 'hotel'] }, // Current selection, collected by host.
}
selectedIds configures the displayed selection; values.selectedIds reports the selection at submission. They have different roles. An empty selection is represented as selectedIds: [], in either mode. This sample component has no per-option backend event. Unsubmitted edits stay local to the view. An enabled, interactive selection view needs an onSubmit binding.
The same component for a different task
For an issue-priority picker, use the same component contract with different options and an app-defined operation:
{
component: 'ChoiceList',
props: {
question: 'Which priority should this issue have?',
selection: 'single',
options: [
{ id: 'high', label: 'High', description: 'Needs attention soon' },
{ id: 'medium', label: 'Medium' },
],
selectedIds: ['medium'],
submitLabel: 'Set priority',
},
events: {
onSubmit: { operation: 'setPriority', input: { issueKey: 'PROJ-1' } },
},
}
Selecting High supplies values: { selectedIds: ['high'] }. The setPriority operation maps that option ID to the app’s priority value and checks permission to update the issue. There are no expense-specific props or events.
ConfirmationPanel
Use this component to show the backend’s prepared approval summary before the user commits it.
| Prop | Type | Required / default | Meaning |
|---|---|---|---|
title |
string | Required | Heading for the confirmation step. |
summary |
string | Required | Plain-text description of what will be approved. |
confirmLabel |
string | Required | Label for the confirmation button. |
errorMessage |
string | Optional; no error | A business correction message, if the app can safely continue. |
disabled |
boolean | Optional; false |
Disables confirmation and retains the summary. |
Event: onConfirm
events: {
onConfirm: {
operation: 'approve',
input: { reportId: 'EXP-42', reviewId: 'review-7' },
},
}
The operation receives input with those fields and no form values. The backend loads the prepared review and verifies it is still valid and authorized. A review ID does not grant permission. An enabled confirmation view needs an onConfirm binding.
ResultPanel
Use this read-only component to show the final outcome.
| Prop | Type | Required | Meaning |
|---|---|---|---|
title |
string | Yes | Outcome heading. |
summary |
string | Yes | Plain-text outcome, including relevant facts. |
ResultPanel has no events or disabled prop because it has no interactive controls. The tool result sets completed: true separately to declare the experience complete. Component appearance alone does not define completion across UI types.
Version and surface support
The walkthrough declares these example components under forge-standard@1. Compatible additions can remain in that catalog version; a breaking contract change requires an explicit version upgrade. A resource frontend is versioned with the app’s deployed frontend, separately from this catalog.
The proposal aims to support both Rovo Chat and compatible external clients. This sample reference does not establish which components are implemented on either. Published component documentation would need a verified support table before developers could rely on it.
Appendix: resource handler
This is resource.js, using the app-owned business services described in the walkthrough. The returned ui contains app data, with no catalog component names or props.
// Proposed SDK, not an installed Forge API. Reuses the prebuilt example's
// app-owned business services; this file is a separate tool handler.
import { defineTool } from '@forge/api/agentic-ui';
import { loadReport, prepareApproval, commitApproval } from './expenses.js';
function selection(report, errorMessage) {
return { step: 'selection', report, selectedIds: [],
...(errorMessage ? { errorMessage } : {}) };
}
export const handler = defineTool({
async run(input, forgeContext) {
const report = await loadReport(input.reportId, forgeContext);
return {
text: `Report ${report.id}: ${report.expenses.length} expenses awaiting selection.`,
ui: selection(report), // App data, not a catalog component description.
};
},
operations: {
async review({ input }, forgeContext) {
const report = await loadReport(input.reportId, forgeContext);
const ids = input.expenseIds;
const allowed = new Set(report.expenses.map(expense => expense.id));
if (!Array.isArray(ids) || ids.length === 0 ||
ids.some(id => typeof id !== 'string' || !allowed.has(id)) ||
new Set(ids).size !== ids.length) {
const message = 'Select at least one available expense.';
return { text: message, ui: selection(report, message) };
}
const review = await prepareApproval(report.id, ids, forgeContext);
return {
text: review.summary,
ui: { step: 'confirmation', reportId: report.id, reviewId: review.id,
summary: review.summary },
};
},
async approve({ input }, forgeContext) {
const result = await commitApproval(input, forgeContext);
return {
text: result.summary,
ui: { step: 'result', summary: result.summary },
completed: true,
};
},
},
});
Appendix: resource frontend
This React component renders the app-defined selection, confirmation and result views from the resource handler. Mount it in your resource frontend using your normal React entry point. It includes loading, pending and error states; the bridge entry point remains proposed.
import React, { useEffect, useRef, useState } from 'react';
import { getContext, invokeOperation } from '@forge/bridge/agentic-ui';
export function ExpenseReview() {
const [view, setView] = useState(null);
const [selectedIds, setSelectedIds] = useState([]);
const [completed, setCompleted] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const pending = useRef(false);
useEffect(() => {
let active = true;
getContext().then(result => {
if (!active) return;
setView(result.ui);
setSelectedIds(result.ui?.selectedIds ?? []);
setCompleted(result.completed === true);
}).catch(() => {
if (active) setError('Could not load this view. Check the conversation result.');
});
return () => { active = false; };
}, []);
async function call(name, input) {
if (pending.current || completed) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const result = await invokeOperation(name, { input });
if (result.ui !== undefined) {
setView(result.ui);
setSelectedIds(result.ui.selectedIds ?? []);
}
if (result.completed === true) setCompleted(true);
} catch {
// A timeout or failure does not prove that a write did not happen.
setError('Could not confirm the outcome. Check the report before retrying.');
} finally {
pending.current = false;
setBusy(false);
}
}
if (!view) return <p role="status">{error ?? 'Loading expenses…'}</p>;
return <section aria-busy={busy}>
{error && <p role="alert">{error}</p>}
{view.step === 'selection' && <>
<h2>Review expenses</h2>
{view.errorMessage && <p role="alert">{view.errorMessage}</p>}
<fieldset disabled={busy || completed}>
<legend>Select expenses to review</legend>
{view.report.expenses.map(expense => <label key={expense.id}>
<input type="checkbox"
checked={selectedIds.includes(expense.id)}
onChange={event => setSelectedIds(ids => event.target.checked
? [...ids, expense.id] : ids.filter(id => id !== expense.id))} />
{expense.description} — {expense.amountLabel}
</label>)}
</fieldset>
<button disabled={busy || completed}
onClick={() => call('review', {
reportId: view.report.id, expenseIds: selectedIds,
})}>Review selected</button>
</>}
{view.step === 'confirmation' && <>
<h2>Confirm approval</h2>
<p>{view.summary}</p>
<button disabled={busy || completed}
onClick={() => call('approve', {
reportId: view.reportId, reviewId: view.reviewId,
})}>Approve</button>
</>}
{view.step === 'result' && <>
<h2>Expenses approved</h2>
<p>{view.summary}</p>
</>}
</section>;
}









