This is an automated email from the ASF dual-hosted git repository.

weilee pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new ea3cb6ba58e Add UI for human in the loop operators (#53035)
ea3cb6ba58e is described below

commit ea3cb6ba58eebe8d377a79a727f11792357084da
Author: Guan Ming(Wesley) Chiu <[email protected]>
AuthorDate: Mon Aug 4 19:20:26 2025 +0800

    Add UI for human in the loop operators (#53035)
    
    Co-authored-by: Wei Lee <[email protected]>
---
 .../src/airflow/api_fastapi/common/types.py        |   1 +
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |   1 +
 airflow-core/src/airflow/security/permissions.py   |   1 +
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |   2 +-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |   2 +-
 .../airflow/ui/public/i18n/locales/en/common.json  |   1 +
 .../src/airflow/ui/public/i18n/locales/en/dag.json |   1 +
 .../ui/public/i18n/locales/en/dashboard.json       |   1 +
 .../airflow/ui/public/i18n/locales/en/hitl.json    |  23 +++
 .../components/FlexibleForm/FieldAdvancedArray.tsx |   3 +-
 .../ui/src/components/FlexibleForm/FieldBool.tsx   |   3 +-
 .../src/components/FlexibleForm/FieldDateTime.tsx  |   4 +-
 .../src/components/FlexibleForm/FieldDropdown.tsx  |   3 +-
 .../components/FlexibleForm/FieldMultiSelect.tsx   |   5 +-
 .../components/FlexibleForm/FieldMultilineText.tsx |   3 +-
 .../ui/src/components/FlexibleForm/FieldNumber.tsx |   3 +-
 .../ui/src/components/FlexibleForm/FieldObject.tsx |   3 +-
 .../src/components/FlexibleForm/FieldSelector.tsx  |   2 +-
 .../ui/src/components/FlexibleForm/FieldString.tsx |   3 +-
 .../components/FlexibleForm/FieldStringArray.tsx   |   3 +-
 .../src/components/FlexibleForm/FlexibleForm.tsx   | 153 +++++++++++--------
 airflow-core/src/airflow/ui/src/i18n/config.ts     |  11 +-
 .../airflow/ui/src/layouts/Nav/BrowseButton.tsx    |   5 +
 airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx  |  33 +++-
 .../airflow/ui/src/pages/Dashboard/Stats/Stats.tsx |  27 +++-
 .../pages/HITLTaskInstances/HITLResponseForm.tsx   | 138 +++++++++++++++++
 .../pages/HITLTaskInstances/HITLTaskInstances.tsx  | 170 +++++++++++++++++++++
 .../HITLTaskInstances/index.ts}                    |  27 +---
 airflow-core/src/airflow/ui/src/pages/Run/Run.tsx  |  28 +++-
 .../src/airflow/ui/src/pages/Task/Task.tsx         |  33 +++-
 .../TaskInstance/HITLResponse.tsx}                 |  44 +++---
 .../ui/src/pages/TaskInstance/TaskInstance.tsx     |  34 ++++-
 .../src/airflow/ui/src/queries/useDeleteDagRun.ts  |   9 +-
 .../ui/src/queries/useDeleteTaskInstance.ts        |   2 +
 .../src/airflow/ui/src/queries/useParamStore.ts    |   5 +
 ...eleteTaskInstance.ts => useUpdateHITLDetail.ts} |  82 +++++-----
 airflow-core/src/airflow/ui/src/router.tsx         |  11 ++
 airflow-core/src/airflow/ui/src/utils/hitl.ts      | 144 +++++++++++++++++
 .../providers/fab/auth_manager/fab_auth_manager.py |   7 +
 .../fab/auth_manager/security_manager/override.py  |   3 +
 .../providers/fab/www/security/permissions.py      |   1 +
 .../tests/unit/fab/auth_manager/test_security.py   |   1 +
 providers/fab/www-hash.txt                         |   2 +-
 43 files changed, 867 insertions(+), 171 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/common/types.py 
b/airflow-core/src/airflow/api_fastapi/common/types.py
index 7e965d5f99f..c5df6259f4d 100644
--- a/airflow-core/src/airflow/api_fastapi/common/types.py
+++ b/airflow-core/src/airflow/api_fastapi/common/types.py
@@ -88,6 +88,7 @@ class ExtraMenuItem:
 class MenuItem(Enum):
     """Define all menu items defined in the menu."""
 
+    REQUIRED_ACTIONS = "Required Actions"
     ASSETS = "Assets"
     AUDIT_LOG = "Audit Log"
     CONFIG = "Config"
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 742f4dafbc8..39b272fd4ad 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -1872,6 +1872,7 @@ components:
     MenuItem:
       type: string
       enum:
+      - Required Actions
       - Assets
       - Audit Log
       - Config
diff --git a/airflow-core/src/airflow/security/permissions.py 
b/airflow-core/src/airflow/security/permissions.py
index 008d7855ef9..6ae47faefbe 100644
--- a/airflow-core/src/airflow/security/permissions.py
+++ b/airflow-core/src/airflow/security/permissions.py
@@ -49,6 +49,7 @@ RESOURCE_ASSET = "Assets"
 RESOURCE_ASSET_ALIAS = "Asset Aliases"
 RESOURCE_DOCS = "Documentation"
 RESOURCE_DOCS_MENU = "Docs"
+RESOURCE_HITL_DETAIL = "HITL Detail"
 RESOURCE_IMPORT_ERROR = "ImportError"
 RESOURCE_JOB = "Jobs"
 RESOURCE_MY_PASSWORD = "My Password"
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 4a9683afdc9..33058f09082 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -7152,7 +7152,7 @@ export const $LightGridTaskInstanceSummary = {
 
 export const $MenuItem = {
     type: 'string',
-    enum: ['Assets', 'Audit Log', 'Config', 'Connections', 'Dags', 'Docs', 
'Plugins', 'Pools', 'Providers', 'Variables', 'XComs'],
+    enum: ['Required Actions', 'Assets', 'Audit Log', 'Config', 'Connections', 
'Dags', 'Docs', 'Plugins', 'Pools', 'Providers', 'Variables', 'XComs'],
     title: 'MenuItem',
     description: 'Define all menu items defined in the menu.'
 } as const;
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 563b35ca3d7..a99c634e14c 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -1849,7 +1849,7 @@ export type LightGridTaskInstanceSummary = {
 /**
  * Define all menu items defined in the menu.
  */
-export type MenuItem = 'Assets' | 'Audit Log' | 'Config' | 'Connections' | 
'Dags' | 'Docs' | 'Plugins' | 'Pools' | 'Providers' | 'Variables' | 'XComs';
+export type MenuItem = 'Required Actions' | 'Assets' | 'Audit Log' | 'Config' 
| 'Connections' | 'Dags' | 'Docs' | 'Plugins' | 'Pools' | 'Providers' | 
'Variables' | 'XComs';
 
 /**
  * Menu Item Collection serializer for responses.
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index fe8b605dc93..8dda576063e 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -15,6 +15,7 @@
   "backfill_other": "Backfills",
   "browse": {
     "auditLog": "Audit Log",
+    "requiredActions": "Required Actions",
     "xcoms": "XComs"
   },
   "collapseDetailsPanel": "Collapse Details Panel",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index cdc1aa3a289..0ea61a8b353 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -103,6 +103,7 @@
     "mappedTaskInstances_other": "Task Instances [{{count}}]",
     "overview": "Overview",
     "renderedTemplates": "Rendered Templates",
+    "requiredActions": "Required Actions",
     "runs": "Runs",
     "taskInstances": "Task Instances",
     "tasks": "Tasks",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
index 8b208cf41df..1d90ff0b777 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dashboard.json
@@ -36,6 +36,7 @@
     "activeDags": "Active Dags",
     "failedDags": "Failed Dags",
     "queuedDags": "Queued Dags",
+    "requiredActions": "Required Actions",
     "runningDags": "Running Dags",
     "stats": "Stats"
   },
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json
new file mode 100644
index 00000000000..067aa40a5cc
--- /dev/null
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/hitl.json
@@ -0,0 +1,23 @@
+{
+  "requiredAction_one": "Required Action",
+  "requiredAction_other": "Required Actions",
+  "requiredActionState": "Required Action State",
+  "response": {
+    "error": "Response failed",
+    "optionsDescription": "Choose your options for this task instance",
+    "optionsLabel": "Options",
+    "received": "Response received at ",
+    "respond": "Respond",
+    "success": "{{taskId}} response successful",
+    "title": "Human Task Instance - {{taskId}}"
+  },
+  "state": {
+    "approvalReceived": "Approval Received",
+    "approvalRequired": "Approval Required",
+    "choiceReceived": "Choice Received",
+    "choiceRequired": "Choice Required",
+    "rejectionReceived": "Rejection Received",
+    "responseReceived": "Response Received",
+    "responseRequired": "Response Required"
+  }
+}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
index 1020b700afd..79ba9fbfe53 100644
--- 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
@@ -25,7 +25,7 @@ import { JsonEditor } from "../JsonEditor";
 
 export const FieldAdvancedArray = ({ name, onUpdate }: 
FlexibleFormElementProps) => {
   const { t: translate } = useTranslation("components");
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   // Determine the expected type based on schema
   const expectedType = param.schema.items?.type ?? "object";
@@ -71,6 +71,7 @@ export const FieldAdvancedArray = ({ name, onUpdate }: 
FlexibleFormElementProps)
 
   return (
     <JsonEditor
+      editable={!Boolean(disabled)}
       id={`element_${name}`}
       onChange={handleChange}
       value={JSON.stringify(param.value ?? [], undefined, 2)}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
index 1d48249f7ea..a8b193ad729 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
@@ -22,7 +22,7 @@ import type { FlexibleFormElementProps } from ".";
 import { Switch } from "../ui";
 
 export const FieldBool = ({ name }: FlexibleFormElementProps) => {
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   const onCheck = (value: boolean) => {
     if (paramsDict[name]) {
@@ -36,6 +36,7 @@ export const FieldBool = ({ name }: FlexibleFormElementProps) 
=> {
     <Switch
       checked={Boolean(param.value)}
       colorPalette="blue"
+      disabled={disabled}
       id={`element_${name}`}
       name={`element_${name}`}
       onCheckedChange={(event) => onCheck(event.checked)}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
index cc8d23184b3..f26b06f3dc1 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
@@ -24,7 +24,7 @@ import type { FlexibleFormElementProps } from ".";
 import { DateTimeInput } from "../DateTimeInput";
 
 export const FieldDateTime = ({ name, onUpdate, ...rest }: 
FlexibleFormElementProps & InputProps) => {
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   const handleChange = (value: string) => {
     if (paramsDict[name]) {
@@ -46,6 +46,7 @@ export const FieldDateTime = ({ name, onUpdate, ...rest }: 
FlexibleFormElementPr
   if (rest.type === "datetime-local") {
     return (
       <DateTimeInput
+        disabled={disabled}
         id={`element_${name}`}
         name={`element_${name}`}
         onChange={(event) => handleChange(event.target.value)}
@@ -57,6 +58,7 @@ export const FieldDateTime = ({ name, onUpdate, ...rest }: 
FlexibleFormElementPr
 
   return (
     <Input
+      disabled={disabled}
       id={`element_${name}`}
       name={`element_${name}`}
       onChange={(event) => handleChange(event.target.value)}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
index d1ac1819ef7..503116427b7 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
@@ -36,7 +36,7 @@ const enumTypes = ["string", "number", "integer"];
 
 export const FieldDropdown = ({ name, onUpdate }: FlexibleFormElementProps) => 
{
   const { t: translate } = useTranslation("components");
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
 
   const selectOptions = createListCollection({
@@ -63,6 +63,7 @@ export const FieldDropdown = ({ name, onUpdate }: 
FlexibleFormElementProps) => {
   return (
     <Select.Root
       collection={selectOptions}
+      disabled={disabled}
       id={`element_${name}`}
       name={`element_${name}`}
       onValueChange={(event) => handleChange(event.value)}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
index 20c3fd92369..257e5a01876 100644
--- 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
@@ -34,7 +34,7 @@ const labelLookup = (key: string, valuesDisplay: 
Record<string, string> | undefi
 
 export const FieldMultiSelect = ({ name, onUpdate }: FlexibleFormElementProps) 
=> {
   const { t: translate } = useTranslation("components");
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
 
   // Initialize `selectedOptions` directly from `paramsDict`
@@ -74,11 +74,12 @@ export const FieldMultiSelect = ({ name, onUpdate }: 
FlexibleFormElementProps) =
       aria-label={translate("flexibleForm.placeholderMulti")}
       id={`element_${name}`}
       isClearable
+      isDisabled={disabled}
       isMulti
       name={`element_${name}`}
       onChange={handleChange}
       options={
-        param.schema.examples?.map((value) => ({
+        (param.schema.examples ?? param.schema.enum)?.map((value) => ({
           label: labelLookup(value, param.schema.values_display),
           value,
         })) ?? []
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
index 93c7410bb82..019cc7dd61c 100644
--- 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
@@ -23,7 +23,7 @@ import { paramPlaceholder, useParamStore } from 
"src/queries/useParamStore";
 import type { FlexibleFormElementProps } from ".";
 
 export const FieldMultilineText = ({ name, onUpdate }: 
FlexibleFormElementProps) => {
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   const handleChange = (value: string) => {
     if (paramsDict[name]) {
@@ -38,6 +38,7 @@ export const FieldMultilineText = ({ name, onUpdate }: 
FlexibleFormElementProps)
 
   return (
     <Textarea
+      disabled={disabled}
       id={`element_${name}`}
       name={`element_${name}`}
       onChange={(event) => handleChange(event.target.value)}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
index 635f461335c..67fab981079 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
@@ -22,7 +22,7 @@ import type { FlexibleFormElementProps } from ".";
 import { NumberInputField, NumberInputRoot } from "../ui/NumberInput";
 
 export const FieldNumber = ({ name, onUpdate }: FlexibleFormElementProps) => {
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   const handleChange = (value: string) => {
     if (value === "") {
@@ -46,6 +46,7 @@ export const FieldNumber = ({ name, onUpdate }: 
FlexibleFormElementProps) => {
   return (
     <NumberInputRoot
       allowMouseWheel
+      disabled={disabled}
       id={`element_${name}`}
       max={param.schema.maximum ?? undefined}
       min={param.schema.minimum ?? undefined}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
index ce5bf307311..9f6bd41d1ce 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
@@ -22,7 +22,7 @@ import type { FlexibleFormElementProps } from ".";
 import { JsonEditor } from "../JsonEditor";
 
 export const FieldObject = ({ name, onUpdate }: FlexibleFormElementProps) => {
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
 
   const handleChange = (value: string) => {
@@ -44,6 +44,7 @@ export const FieldObject = ({ name, onUpdate }: 
FlexibleFormElementProps) => {
 
   return (
     <JsonEditor
+      editable={!disabled}
       id={`element_${name}`}
       onChange={handleChange}
       value={JSON.stringify(param.value ?? [], undefined, 2)}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
index cd0061987a6..19c1359d231 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
@@ -70,7 +70,7 @@ const isFieldMultilineText = (fieldType: string, fieldSchema: 
ParamSchema) =>
   fieldType === "string" && fieldSchema.format === "multiline";
 
 const isFieldMultiSelect = (fieldType: string, fieldSchema: ParamSchema) =>
-  fieldType === "array" && Array.isArray(fieldSchema.examples);
+  fieldType === "array" && (Array.isArray(fieldSchema.examples) || 
Array.isArray(fieldSchema.enum));
 
 const isFieldNumber = (fieldType: string) => {
   const numberTypes = ["integer", "number"];
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
index bb7951ced9d..04ee952e7c3 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
@@ -25,7 +25,7 @@ import type { FlexibleFormElementProps } from ".";
 
 export const FieldString = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { t: translate } = useTranslation("components");
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   const handleChange = (value: string) => {
     if (paramsDict[name]) {
@@ -41,6 +41,7 @@ export const FieldString = ({ name, onUpdate }: 
FlexibleFormElementProps) => {
   return (
     <>
       <Input
+        disabled={disabled}
         id={`element_${name}`}
         list={param.schema.examples ? `list_${name}` : undefined}
         maxLength={param.schema.maxLength ?? undefined}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
index 3708411af3d..52ab2f7614a 100644
--- 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
@@ -25,7 +25,7 @@ import type { FlexibleFormElementProps } from ".";
 
 export const FieldStringArray = ({ name, onUpdate }: FlexibleFormElementProps) 
=> {
   const { t: translate } = useTranslation("components");
-  const { paramsDict, setParamsDict } = useParamStore();
+  const { disabled, paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
 
   const handleChange = (newValue: string) => {
@@ -57,6 +57,7 @@ export const FieldStringArray = ({ name, onUpdate }: 
FlexibleFormElementProps) =
 
   return (
     <Textarea
+      disabled={disabled}
       id={`element_${name}`}
       name={`element_${name}`}
       onBlur={handleBlur}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
index 1841c7dcfac..9cdfc58dce6 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
@@ -23,25 +23,32 @@ import { MdError } from "react-icons/md";
 import type { ParamsSpec } from "src/queries/useDagParams";
 import { useParamStore } from "src/queries/useParamStore";
 
+import ReactMarkdown from "../ReactMarkdown";
 import { Accordion } from "../ui";
 import { Row } from "./Row";
 import { isRequired } from "./isParamRequired";
 
 export type FlexibleFormProps = {
-  flexibleFormDefaultSection: string;
-  initialParamsDict: { paramsDict: ParamsSpec };
-  key?: string;
-  setError: (error: boolean) => void;
-  subHeader?: string;
+  readonly disabled?: boolean;
+  readonly flexFormDescription?: string;
+  readonly flexibleFormDefaultSection: string;
+  readonly initialParamsDict: { paramsDict: ParamsSpec };
+  readonly isHITL?: boolean;
+  readonly key?: string;
+  readonly setError: (error: boolean) => void;
+  readonly subHeader?: string;
 };
 
 export const FlexibleForm = ({
+  disabled,
+  flexFormDescription,
   flexibleFormDefaultSection,
   initialParamsDict,
+  isHITL,
   setError,
   subHeader,
 }: FlexibleFormProps) => {
-  const { paramsDict: params, setInitialParamDict, setParamsDict } = 
useParamStore();
+  const { paramsDict: params, setDisabled, setInitialParamDict, setParamsDict 
} = useParamStore();
   const processedSections = new Map();
   const [sectionError, setSectionError] = useState<Map<string, boolean>>(new 
Map());
 
@@ -86,6 +93,10 @@ export const FlexibleForm = ({
     }
   }, [params, setError, recheckSection, sectionError]);
 
+  useEffect(() => {
+    setDisabled(disabled ?? false);
+  }, [disabled, setDisabled]);
+
   const onUpdate = (_value?: string, error?: unknown) => {
     recheckSection();
     if (!Boolean(error) && sectionError.size === 0) {
@@ -95,60 +106,82 @@ export const FlexibleForm = ({
     }
   };
 
-  return Object.entries(params).some(([, param]) => typeof 
param.schema.section !== "string")
-    ? Object.entries(params).map(([, secParam]) => {
-        const currentSection = secParam.schema.section ?? 
flexibleFormDefaultSection;
-
-        if (processedSections.has(currentSection)) {
-          return undefined;
-        } else {
-          processedSections.set(currentSection, true);
-
-          return (
-            <Accordion.Item
-              // We need to make the item content overflow visible for 
dropdowns to work, but directly applying the style does not work
-              css={{
-                "& > div:nth-of-type(1)": {
-                  overflow: "visible",
-                },
-              }}
-              key={currentSection}
-              value={currentSection}
-            >
-              <Accordion.ItemTrigger cursor="button">
-                <Text color={sectionError.get(currentSection) ? "fg.error" : 
undefined}>
-                  {currentSection}
-                </Text>
-                {sectionError.get(currentSection) ? (
-                  <Icon color="fg.error" margin="-1">
-                    <MdError />
-                  </Icon>
+  return Object.entries(params).some(([, param]) => typeof 
param.schema.section !== "string") ? (
+    Object.entries(params).map(([, secParam]) => {
+      const currentSection = secParam.schema.section ?? 
flexibleFormDefaultSection;
+
+      if (processedSections.has(currentSection)) {
+        return undefined;
+      } else {
+        processedSections.set(currentSection, true);
+
+        return (
+          <Accordion.Item
+            // We need to make the item content overflow visible for dropdowns 
to work, but directly applying the style does not work
+            css={{
+              "& > div:nth-of-type(1)": {
+                overflow: "visible",
+              },
+            }}
+            key={currentSection}
+            value={currentSection}
+          >
+            <Accordion.ItemTrigger cursor="button">
+              <Text color={sectionError.get(currentSection) ? "fg.error" : 
undefined}>{currentSection}</Text>
+              {sectionError.get(currentSection) ? (
+                <Icon color="fg.error" margin="-1">
+                  <MdError />
+                </Icon>
+              ) : undefined}
+            </Accordion.ItemTrigger>
+
+            <Accordion.ItemContent pt={0}>
+              <Accordion.ItemBody>
+                {Boolean(subHeader) ? (
+                  <Text color="fg.muted" fontSize="xs" mb={2}>
+                    {subHeader}
+                  </Text>
                 ) : undefined}
-              </Accordion.ItemTrigger>
-
-              <Accordion.ItemContent pt={0}>
-                <Accordion.ItemBody>
-                  {Boolean(subHeader) ? (
-                    <Text color="fg.muted" fontSize="xs" mb={2}>
-                      {subHeader}
-                    </Text>
+                <Stack separator={<StackSeparator py={2} />}>
+                  {Boolean(flexFormDescription) ? (
+                    <ReactMarkdown>{flexFormDescription}</ReactMarkdown>
                   ) : undefined}
-                  <Stack separator={<StackSeparator />}>
-                    {Object.entries(params)
-                      .filter(
-                        ([, param]) =>
-                          param.schema.section === currentSection ||
-                          (currentSection === flexibleFormDefaultSection && 
!Boolean(param.schema.section)),
-                      )
-                      .map(([name]) => (
-                        <Row key={name} name={name} onUpdate={onUpdate} />
-                      ))}
-                  </Stack>
-                </Accordion.ItemBody>
-              </Accordion.ItemContent>
-            </Accordion.Item>
-          );
-        }
-      })
-    : undefined;
+                  {Object.entries(params)
+                    .filter(
+                      ([, param]) =>
+                        param.schema.section === currentSection ||
+                        (currentSection === flexibleFormDefaultSection && 
!Boolean(param.schema.section)),
+                    )
+                    .map(([name]) => (
+                      <Row key={name} name={name} onUpdate={onUpdate} />
+                    ))}
+                </Stack>
+              </Accordion.ItemBody>
+            </Accordion.ItemContent>
+          </Accordion.Item>
+        );
+      }
+    })
+  ) : isHITL ? (
+    <Accordion.Item key={flexibleFormDefaultSection} 
value={flexibleFormDefaultSection}>
+      <Accordion.ItemTrigger cursor="button">
+        <Text color={sectionError.get(flexibleFormDefaultSection) ? "fg.error" 
: undefined}>
+          {flexibleFormDefaultSection}
+        </Text>
+        {sectionError.get(flexibleFormDefaultSection) ? (
+          <Icon color="fg.error" margin="-1">
+            <MdError />
+          </Icon>
+        ) : undefined}
+      </Accordion.ItemTrigger>
+
+      <Accordion.ItemContent pt={0}>
+        <Accordion.ItemBody>
+          <Stack separator={<StackSeparator py={2} />}>
+            {Boolean(flexFormDescription) ? 
<ReactMarkdown>{flexFormDescription}</ReactMarkdown> : undefined}
+          </Stack>
+        </Accordion.ItemBody>
+      </Accordion.ItemContent>
+    </Accordion.Item>
+  ) : undefined;
 };
diff --git a/airflow-core/src/airflow/ui/src/i18n/config.ts 
b/airflow-core/src/airflow/ui/src/i18n/config.ts
index 86f25740bfc..33ffb377f19 100644
--- a/airflow-core/src/airflow/ui/src/i18n/config.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/config.ts
@@ -35,7 +35,16 @@ export const supportedLanguages = [
 ] as const;
 
 export const defaultLanguage = "en";
-export const namespaces = ["common", "dashboard", "dags", "admin", "browse", 
"assets", "components"] as const;
+export const namespaces = [
+  "common",
+  "dashboard",
+  "dags",
+  "admin",
+  "browse",
+  "assets",
+  "components",
+  "hitl",
+] as const;
 
 void i18n
   .use(Backend)
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
index 3df43b9fbac..e24700e0451 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
@@ -38,6 +38,11 @@ const links = [
     key: "xcoms",
     title: "XComs",
   },
+  {
+    href: "/required_actions",
+    key: "requiredActions",
+    title: "Required Actions",
+  },
 ];
 
 export const BrowseButton = ({
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
index 2857f4abd98..455dbc2e794 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
@@ -19,13 +19,17 @@
 import { ReactFlowProvider } from "@xyflow/react";
 import { useState } from "react";
 import { useTranslation } from "react-i18next";
-import { FiBarChart, FiCode } from "react-icons/fi";
+import { FiBarChart, FiCode, FiUser } from "react-icons/fi";
 import { LuChartColumn } from "react-icons/lu";
 import { MdDetails, MdOutlineEventNote } from "react-icons/md";
 import { RiArrowGoBackFill } from "react-icons/ri";
 import { useParams } from "react-router-dom";
 
-import { useDagServiceGetDagDetails, useDagServiceGetLatestRunInfo } from 
"openapi/queries";
+import {
+  useDagServiceGetDagDetails,
+  useDagServiceGetLatestRunInfo,
+  useHumanInTheLoopServiceGetHitlDetails,
+} from "openapi/queries";
 import { TaskIcon } from "src/assets/TaskIcon";
 import { usePluginTabs } from "src/hooks/usePluginTabs";
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
@@ -45,6 +49,7 @@ export const Dag = () => {
     { icon: <LuChartColumn />, label: translate("tabs.overview"), value: "" },
     { icon: <FiBarChart />, label: translate("tabs.runs"), value: "runs" },
     { icon: <TaskIcon />, label: translate("tabs.tasks"), value: "tasks" },
+    { icon: <FiUser />, label: translate("tabs.requiredActions"), value: 
"required_actions" },
     { icon: <RiArrowGoBackFill />, label: translate("tabs.backfills"), value: 
"backfills" },
     { icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value: 
"events" },
     { icon: <FiCode />, label: translate("tabs.code"), value: "code" },
@@ -67,7 +72,29 @@ export const Dag = () => {
   // pending state and new runs are initiated from other page
   useRefreshOnNewDagRuns(dagId, hasPendingRuns);
 
-  const displayTabs = tabs.filter((tab) => !(dag?.timetable_summary === null 
&& tab.value === "backfills"));
+  const { data: hitlData } = useHumanInTheLoopServiceGetHitlDetails(
+    {
+      dagIdPattern: dagId,
+    },
+    undefined,
+    {
+      enabled: Boolean(dagId),
+    },
+  );
+
+  const hasHitlTasks = (hitlData?.total_entries ?? 0) > 0;
+
+  const displayTabs = tabs.filter((tab) => {
+    if (dag?.timetable_summary === null && tab.value === "backfills") {
+      return false;
+    }
+
+    if (tab.value === "required_actions" && !hasHitlTasks) {
+      return false;
+    }
+
+    return true;
+  });
 
   const {
     data: latestRun,
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
index 70ae3025c79..1d54a3f9454 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
@@ -18,9 +18,9 @@
  */
 import { Box, Flex, Heading, HStack } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
-import { FiClipboard, FiZap } from "react-icons/fi";
+import { FiClipboard, FiZap, FiClock } from "react-icons/fi";
 
-import { useDashboardServiceDagStats } from "openapi/queries";
+import { useDashboardServiceDagStats, useHumanInTheLoopServiceGetHitlDetails } 
from "openapi/queries";
 import { useAutoRefresh } from "src/utils";
 
 import { DAGImportErrors } from "./DAGImportErrors";
@@ -32,10 +32,22 @@ export const Stats = () => {
   const { data: statsData, isLoading: isStatsLoading } = 
useDashboardServiceDagStats(undefined, {
     refetchInterval,
   });
+
+  const { data: hitlStatsData } = useHumanInTheLoopServiceGetHitlDetails(
+    {
+      responseReceived: false,
+    },
+    undefined,
+    {
+      refetchInterval,
+    },
+  );
+
   const failedDagsCount = statsData?.failed_dag_count ?? 0;
   const queuedDagsCount = statsData?.queued_dag_count ?? 0;
   const runningDagsCount = statsData?.running_dag_count ?? 0;
   const activeDagsCount = statsData?.active_dag_count ?? 0;
+  const hitlTIsCount = hitlStatsData?.hitl_details.length ?? 0;
   const { t: translate } = useTranslation("dashboard");
 
   return (
@@ -48,6 +60,17 @@ export const Stats = () => {
       </Flex>
 
       <HStack gap={4}>
+        {hitlTIsCount > 0 ? (
+          <StatsCard
+            colorScheme="failed"
+            count={hitlTIsCount}
+            icon={<FiClock />}
+            isLoading={isStatsLoading}
+            label={translate("stats.requiredActions")}
+            link="required_actions"
+          />
+        ) : undefined}
+
         <StatsCard
           colorScheme="failed"
           count={failedDagsCount}
diff --git 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
new file mode 100644
index 00000000000..2563d3ce721
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
@@ -0,0 +1,138 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Button, Box, Spacer, HStack, Accordion, Text } from 
"@chakra-ui/react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FiSend } from "react-icons/fi";
+
+import type { HITLDetail } from "openapi/requests/types.gen";
+import { FlexibleForm } from "src/components/FlexibleForm/FlexibleForm";
+import Time from "src/components/Time";
+import { useParamStore } from "src/queries/useParamStore";
+import { useUpdateHITLDetail } from "src/queries/useUpdateHITLDetail";
+import { getHITLParamsDict, getHITLFormData } from "src/utils/hitl";
+
+type HITLResponseFormProps = {
+  readonly hitlDetail?: HITLDetail;
+};
+
+const isHighlightOption = (option: string, hitlDetail: HITLDetail) => {
+  const isSelected = hitlDetail.chosen_options?.includes(option) && 
Boolean(hitlDetail.response_received);
+  const isDefault = hitlDetail.defaults?.includes(option) && 
!Boolean(hitlDetail.response_received);
+
+  // highlight if:
+  // 1. the option is selected and the response is received
+  // 2. the option is in default options and the response is not received
+  // 3. the option is not selected and the response is not received and there 
is no default options
+  return isSelected ?? isDefault ?? !Boolean(hitlDetail.defaults);
+};
+
+export const HITLResponseForm = ({ hitlDetail }: HITLResponseFormProps) => {
+  const { t: translate } = useTranslation();
+  const [errors, setErrors] = useState<boolean>(false);
+  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+  const { paramsDict } = useParamStore();
+  const { updateHITLResponse } = useUpdateHITLDetail({
+    dagId: hitlDetail?.task_instance.dag_id ?? "",
+    dagRunId: hitlDetail?.task_instance.dag_run_id ?? "",
+    mapIndex: hitlDetail?.task_instance.map_index ?? -1,
+    taskId: hitlDetail?.task_instance.task_id ?? "",
+  });
+
+  const handleSubmit = (option?: string) => {
+    if (errors || isSubmitting) {
+      return;
+    }
+
+    setIsSubmitting(true);
+
+    try {
+      const formData = getHITLFormData(paramsDict, option);
+
+      updateHITLResponse(formData);
+    } catch {
+      setErrors(true);
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  if (!hitlDetail) {
+    return undefined;
+  }
+
+  return (
+    <Box mt={4}>
+      {hitlDetail.response_received ? (
+        <Text color="fg.muted" fontSize="sm">
+          {translate("hitl:response.received")}
+          <Time datetime={hitlDetail.response_at} format="YYYY-MM-DD, 
HH:mm:ss" />
+        </Text>
+      ) : undefined}
+      <Accordion.Root
+        collapsible
+        defaultValue={[hitlDetail.subject]}
+        mb={4}
+        mt={4}
+        size="lg"
+        variant="enclosed"
+      >
+        <FlexibleForm
+          disabled={hitlDetail.response_received}
+          flexFormDescription={hitlDetail.body ?? undefined}
+          flexibleFormDefaultSection={hitlDetail.subject}
+          initialParamsDict={{
+            paramsDict: getHITLParamsDict(hitlDetail, translate),
+          }}
+          isHITL
+          key={hitlDetail.subject}
+          setError={setErrors}
+        />
+      </Accordion.Root>
+
+      <Box as="footer" display="flex" justifyContent="flex-end" mt={4}>
+        <HStack w="full">
+          <Spacer />
+          {hitlDetail.options.length < 4 && !hitlDetail.multiple ? (
+            hitlDetail.options.map((option) => (
+              <Button
+                colorPalette={isHighlightOption(option, hitlDetail) ? "blue" : 
"gray"}
+                disabled={(hitlDetail.response_received ?? errors) || 
isSubmitting}
+                key={option}
+                onClick={() => handleSubmit(option)}
+                variant={isHighlightOption(option, hitlDetail) ? "solid" : 
"subtle"}
+              >
+                {option}
+              </Button>
+            ))
+          ) : hitlDetail.response_received ? undefined : (
+            <Button
+              colorPalette="blue"
+              disabled={errors || isSubmitting}
+              loading={isSubmitting}
+              onClick={() => handleSubmit()}
+            >
+              <FiSend /> {translate("hitl:response.respond")}
+            </Button>
+          )}
+        </HStack>
+      </Box>
+    </Box>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
new file mode 100644
index 00000000000..896e481ca63
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
@@ -0,0 +1,170 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Link } from "@chakra-ui/react";
+import type { ColumnDef } from "@tanstack/react-table";
+import type { TFunction } from "i18next";
+import { useTranslation } from "react-i18next";
+import { Link as RouterLink, useParams } from "react-router-dom";
+
+import { useHumanInTheLoopServiceGetHitlDetails } from "openapi/queries";
+import type { HITLDetail } from "openapi/requests/types.gen";
+import { DataTable } from "src/components/DataTable";
+import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { ErrorAlert } from "src/components/ErrorAlert";
+import { StateBadge } from "src/components/StateBadge";
+import Time from "src/components/Time";
+import { TruncatedText } from "src/components/TruncatedText";
+import { useAutoRefresh } from "src/utils";
+import { getHITLState } from "src/utils/hitl";
+import { getTaskInstanceLink } from "src/utils/links";
+
+type TaskInstanceRow = { row: { original: HITLDetail } };
+
+const taskInstanceColumns = ({
+  dagId,
+  runId,
+  taskId,
+  translate,
+}: {
+  dagId?: string;
+  runId?: string;
+  taskId?: string;
+  translate: TFunction;
+}): Array<ColumnDef<HITLDetail>> => [
+  {
+    accessorKey: "task_instance.operator",
+    cell: ({ row: { original } }: TaskInstanceRow) => (
+      <StateBadge 
state={original.task_instance.state}>{getHITLState(translate, 
original)}</StateBadge>
+    ),
+    header: translate("Required Action State"),
+  },
+  {
+    accessorKey: "subject",
+    cell: ({ row: { original } }: TaskInstanceRow) => (
+      <Link asChild color="fg.info" fontWeight="bold">
+        <RouterLink 
to={`${getTaskInstanceLink(original.task_instance)}/required_actions`}>
+          <TruncatedText text={original.subject} />
+        </RouterLink>
+      </Link>
+    ),
+    header: translate("Subject"),
+  },
+  ...(Boolean(dagId)
+    ? []
+    : [
+        {
+          accessorKey: "task_instance.dag_id",
+          enableSorting: false,
+          header: translate("dagId"),
+        },
+      ]),
+  ...(Boolean(runId)
+    ? []
+    : [
+        {
+          accessorKey: "run_after",
+          // If we don't show the taskId column, make the dag run a link to 
the task instance
+          cell: ({ row: { original } }: TaskInstanceRow) =>
+            Boolean(taskId) ? (
+              <Link asChild color="fg.info" fontWeight="bold">
+                <RouterLink to={getTaskInstanceLink(original.task_instance)}>
+                  <Time datetime={original.task_instance.run_after} />
+                </RouterLink>
+              </Link>
+            ) : (
+              <Time datetime={original.task_instance.run_after} />
+            ),
+          header: translate("dagRun_one"),
+        },
+      ]),
+  ...(Boolean(taskId)
+    ? []
+    : [
+        {
+          accessorKey: "task_display_name",
+          cell: ({ row: { original } }: TaskInstanceRow) => (
+            <TruncatedText text={original.task_instance.task_display_name} />
+          ),
+          enableSorting: false,
+          header: translate("taskId"),
+        },
+      ]),
+  {
+    accessorKey: "rendered_map_index",
+    header: translate("mapIndex"),
+  },
+  {
+    accessorKey: "response_received",
+    header: translate("Response Received"),
+  },
+  {
+    accessorKey: "response_at",
+    cell: ({ row: { original } }) => <Time datetime={original.response_at} />,
+    header: translate("Response At"),
+  },
+];
+
+export const HITLTaskInstances = () => {
+  const { t: translate } = useTranslation();
+  const { dagId, groupId, runId, taskId } = useParams();
+  const { setTableURLState, tableURLState } = useTableURLState();
+  const { pagination } = tableURLState;
+
+  const refetchInterval = useAutoRefresh({});
+
+  const { data, error, isLoading } = useHumanInTheLoopServiceGetHitlDetails(
+    {
+      dagIdPattern: dagId,
+      dagRunId: runId,
+    },
+    undefined,
+    {
+      enabled: !isNaN(pagination.pageSize),
+      refetchInterval,
+    },
+  );
+
+  const filteredData = data?.hitl_details.filter((hitl) => {
+    if (taskId !== undefined) {
+      return hitl.task_instance.task_id === taskId;
+    } else if (groupId !== undefined) {
+      return hitl.task_instance.task_id.includes(groupId);
+    }
+
+    return true;
+  });
+
+  return (
+    <DataTable
+      columns={taskInstanceColumns({
+        dagId,
+        runId,
+        taskId: Boolean(groupId) ? undefined : taskId,
+        translate,
+      })}
+      data={filteredData ?? []}
+      errorMessage={<ErrorAlert error={error} />}
+      initialState={tableURLState}
+      isLoading={isLoading}
+      modelName={translate("hitl:requiredAction_other")}
+      onStateChange={setTableURLState}
+      total={filteredData?.length}
+    />
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/index.ts
similarity index 53%
copy from airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
copy to airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/index.ts
index 1d48249f7ea..98d257769f0 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/index.ts
@@ -16,29 +16,4 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
-
-import type { FlexibleFormElementProps } from ".";
-import { Switch } from "../ui";
-
-export const FieldBool = ({ name }: FlexibleFormElementProps) => {
-  const { paramsDict, setParamsDict } = useParamStore();
-  const param = paramsDict[name] ?? paramPlaceholder;
-  const onCheck = (value: boolean) => {
-    if (paramsDict[name]) {
-      paramsDict[name].value = value;
-    }
-
-    setParamsDict(paramsDict);
-  };
-
-  return (
-    <Switch
-      checked={Boolean(param.value)}
-      colorPalette="blue"
-      id={`element_${name}`}
-      name={`element_${name}`}
-      onCheckedChange={(event) => onCheck(event.checked)}
-    />
-  );
-};
+export { HITLTaskInstances } from "./HITLTaskInstances";
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
index 776e4e379cd..79dd15a307a 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
@@ -18,11 +18,11 @@
  */
 import { ReactFlowProvider } from "@xyflow/react";
 import { useTranslation } from "react-i18next";
-import { FiCode, FiDatabase } from "react-icons/fi";
+import { FiCode, FiDatabase, FiUser } from "react-icons/fi";
 import { MdDetails, MdOutlineEventNote, MdOutlineTask } from "react-icons/md";
 import { useParams } from "react-router-dom";
 
-import { useDagRunServiceGetDagRun } from "openapi/queries";
+import { useDagRunServiceGetDagRun, useHumanInTheLoopServiceGetHitlDetails } 
from "openapi/queries";
 import { usePluginTabs } from "src/hooks/usePluginTabs";
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 import { isStatePending, useAutoRefresh } from "src/utils";
@@ -38,6 +38,7 @@ export const Run = () => {
 
   const tabs = [
     { icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value: 
"" },
+    { icon: <FiUser />, label: translate("tabs.requiredActions"), value: 
"required_actions" },
     { icon: <FiDatabase />, label: translate("tabs.assetEvents"), value: 
"asset_events" },
     { icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value: 
"events" },
     { icon: <FiCode />, label: translate("tabs.code"), value: "code" },
@@ -62,9 +63,30 @@ export const Run = () => {
     },
   );
 
+  const { data: hitlData } = useHumanInTheLoopServiceGetHitlDetails(
+    {
+      dagIdPattern: dagId,
+      dagRunId: runId,
+    },
+    undefined,
+    {
+      enabled: Boolean(dagId && runId),
+    },
+  );
+
+  const hasHitlTasksForRun = Boolean(hitlData?.hitl_details.length);
+
+  const displayTabs = tabs.filter((tab) => {
+    if (tab.value === "required_actions" && !hasHitlTasksForRun) {
+      return false;
+    }
+
+    return true;
+  });
+
   return (
     <ReactFlowProvider>
-      <DetailsLayout error={error} isLoading={isLoading} tabs={tabs}>
+      <DetailsLayout error={error} isLoading={isLoading} tabs={displayTabs}>
         {dagRun === undefined ? undefined : (
           <Header
             dagRun={dagRun}
diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx 
b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
index d37e91fe9a9..282f5df4176 100644
--- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
@@ -18,11 +18,12 @@
  */
 import { ReactFlowProvider } from "@xyflow/react";
 import { useTranslation } from "react-i18next";
+import { FiUser } from "react-icons/fi";
 import { LuChartColumn } from "react-icons/lu";
 import { MdOutlineEventNote, MdOutlineTask } from "react-icons/md";
 import { useParams } from "react-router-dom";
 
-import { useTaskServiceGetTask } from "openapi/queries";
+import { useTaskServiceGetTask, useHumanInTheLoopServiceGetHitlDetails } from 
"openapi/queries";
 import { usePluginTabs } from "src/hooks/usePluginTabs";
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 import { useGridStructure } from "src/queries/useGridStructure.ts";
@@ -33,7 +34,7 @@ import { Header } from "./Header";
 
 export const Task = () => {
   const { t: translate } = useTranslation("dag");
-  const { dagId = "", groupId, taskId } = useParams();
+  const { dagId = "", groupId, runId, taskId } = useParams();
 
   // Get external views with task destination
   const externalTabs = usePluginTabs("task");
@@ -41,12 +42,11 @@ export const Task = () => {
   const tabs = [
     { icon: <LuChartColumn />, label: translate("tabs.overview"), value: "" },
     { icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value: 
"task_instances" },
+    { icon: <FiUser />, label: translate("tabs.requiredActions"), value: 
"required_actions" },
     { icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value: 
"events" },
     ...externalTabs,
   ];
 
-  const displayTabs = groupId === undefined ? tabs : tabs.filter((tab) => 
tab.value !== "events");
-
   const {
     data: task,
     error,
@@ -59,6 +59,31 @@ export const Task = () => {
 
   const groupTask = getGroupTask(dagStructure, groupId);
 
+  // Check if this task has any HITL details
+  const { data: hitlData } = useHumanInTheLoopServiceGetHitlDetails(
+    {
+      dagIdPattern: dagId,
+      dagRunId: runId,
+    },
+    undefined,
+    {
+      enabled: Boolean(dagId && (groupId !== undefined || taskId !== 
undefined)),
+    },
+  );
+
+  const hasHitlForTask =
+    (hitlData?.hitl_details.filter((hitl) => hitl.task_instance.task_id === 
taskId).length ?? 0) > 0;
+
+  const displayTabs = (groupId === undefined ? tabs : tabs.filter((tab) => 
tab.value !== "events")).filter(
+    (tab) => {
+      if (tab.value === "required_actions" && !hasHitlForTask) {
+        return false;
+      }
+
+      return true;
+    },
+  );
+
   return (
     <ReactFlowProvider>
       <DetailsLayout error={error} isLoading={isLoading} tabs={displayTabs}>
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/HITLResponse.tsx
similarity index 51%
copy from airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
copy to airflow-core/src/airflow/ui/src/pages/TaskInstance/HITLResponse.tsx
index 1d48249f7ea..442954d4614 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldBool.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/HITLResponse.tsx
@@ -16,29 +16,35 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
+import { Box } from "@chakra-ui/react";
+import { useParams } from "react-router-dom";
 
-import type { FlexibleFormElementProps } from ".";
-import { Switch } from "../ui";
+import { useHumanInTheLoopServiceGetMappedTiHitlDetail } from 
"openapi/queries";
+import { ProgressBar } from "src/components/ui";
 
-export const FieldBool = ({ name }: FlexibleFormElementProps) => {
-  const { paramsDict, setParamsDict } = useParamStore();
-  const param = paramsDict[name] ?? paramPlaceholder;
-  const onCheck = (value: boolean) => {
-    if (paramsDict[name]) {
-      paramsDict[name].value = value;
-    }
+import { HITLResponseForm } from "../HITLTaskInstances/HITLResponseForm";
 
-    setParamsDict(paramsDict);
-  };
+export const HITLResponse = () => {
+  const { dagId, mapIndex, runId, taskId } = useParams();
+
+  const { data: hitlDetail } = useHumanInTheLoopServiceGetMappedTiHitlDetail({
+    dagId: dagId ?? "~",
+    dagRunId: runId ?? "~",
+    mapIndex: Number(mapIndex ?? -1),
+    taskId: taskId ?? "~",
+  });
+
+  if (!hitlDetail) {
+    return (
+      <Box flexGrow={1}>
+        <ProgressBar />
+      </Box>
+    );
+  }
 
   return (
-    <Switch
-      checked={Boolean(param.value)}
-      colorPalette="blue"
-      id={`element_${name}`}
-      name={`element_${name}`}
-      onCheckedChange={(event) => onCheck(event.checked)}
-    />
+    <Box px={4}>
+      <HITLResponseForm hitlDetail={hitlDetail} />
+    </Box>
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
index f1a7c490d28..9a432a1f571 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
@@ -19,12 +19,15 @@
 import { ReactFlowProvider } from "@xyflow/react";
 import { useMemo } from "react";
 import { useTranslation } from "react-i18next";
-import { FiCode, FiDatabase } from "react-icons/fi";
+import { FiCode, FiDatabase, FiUser } from "react-icons/fi";
 import { MdDetails, MdOutlineEventNote, MdOutlineTask, MdReorder, MdSyncAlt } 
from "react-icons/md";
 import { PiBracketsCurlyBold } from "react-icons/pi";
 import { useParams } from "react-router-dom";
 
-import { useTaskInstanceServiceGetMappedTaskInstance } from "openapi/queries";
+import {
+  useHumanInTheLoopServiceGetHitlDetails,
+  useTaskInstanceServiceGetMappedTaskInstance,
+} from "openapi/queries";
 import { usePluginTabs } from "src/hooks/usePluginTabs";
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts";
@@ -41,6 +44,7 @@ export const TaskInstance = () => {
 
   const tabs = [
     { icon: <MdReorder />, label: translate("tabs.logs"), value: "" },
+    { icon: <FiUser />, label: translate("tabs.requiredActions"), value: 
"required_actions" },
     {
       icon: <PiBracketsCurlyBold />,
       label: translate("tabs.renderedTemplates"),
@@ -75,6 +79,22 @@ export const TaskInstance = () => {
 
   const { data: gridTISummaries } = useGridTiSummaries({ dagId, runId });
 
+  const { data: hitlDetails } = useHumanInTheLoopServiceGetHitlDetails(
+    {
+      dagIdPattern: dagId,
+      dagRunId: runId,
+    },
+    undefined,
+    {
+      enabled: Boolean(dagId && runId),
+      refetchInterval,
+    },
+  );
+
+  const hasHitlForTask = Boolean(
+    hitlDetails?.hitl_details.find((hitl) => hitl.task_instance.task_id === 
taskId),
+  );
+
   const taskInstanceSummary = gridTISummaries?.task_instances.find((ti) => 
ti.task_id === taskId);
   const taskCount = useMemo(
     () =>
@@ -101,9 +121,17 @@ export const TaskInstance = () => {
     ];
   }
 
+  const displayTabs = newTabs.filter((tab) => {
+    if (tab.value === "required_actions" && !hasHitlForTask) {
+      return false;
+    }
+
+    return true;
+  });
+
   return (
     <ReactFlowProvider>
-      <DetailsLayout error={error} isLoading={isLoading} tabs={newTabs}>
+      <DetailsLayout error={error} isLoading={isLoading} tabs={displayTabs}>
         {taskInstance === undefined ? undefined : (
           <Header
             isRefreshing={Boolean(isStatePending(taskInstance.state) && 
Boolean(refetchInterval))}
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts 
b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
index 8e83952815c..9535d31ae48 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts
@@ -23,6 +23,8 @@ import {
   useDagRunServiceDeleteDagRun,
   useDagRunServiceGetDagRunsKey,
   UseDagRunServiceGetDagRunKeyFn,
+  useTaskInstanceServiceGetTaskInstancesKey,
+  useHumanInTheLoopServiceGetHitlDetailsKey,
 } from "openapi/queries";
 import { toaster } from "src/components/ui";
 
@@ -45,7 +47,12 @@ export const useDeleteDagRun = ({ dagId, dagRunId, 
onSuccessConfirm }: DeleteDag
   };
 
   const onSuccess = async () => {
-    const queryKeys = [UseDagRunServiceGetDagRunKeyFn({ dagId, dagRunId }), 
[useDagRunServiceGetDagRunsKey]];
+    const queryKeys = [
+      UseDagRunServiceGetDagRunKeyFn({ dagId, dagRunId }),
+      [useDagRunServiceGetDagRunsKey],
+      [useTaskInstanceServiceGetTaskInstancesKey],
+      [useHumanInTheLoopServiceGetHitlDetailsKey],
+    ];
 
     await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({ 
queryKey: key })));
 
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts 
b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
index d2ec715a011..abf22e0d787 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
@@ -25,6 +25,7 @@ import {
   useTaskInstanceServiceGetTaskInstancesKey,
   useDagRunServiceGetDagRunsKey,
   UseDagRunServiceGetDagRunKeyFn,
+  useHumanInTheLoopServiceGetHitlDetailsKey,
 } from "openapi/queries";
 import { toaster } from "src/components/ui";
 
@@ -60,6 +61,7 @@ export const useDeleteTaskInstance = ({
       [useDagRunServiceGetDagRunsKey],
       [useTaskInstanceServiceGetTaskInstancesKey],
       [useTaskInstanceServiceGetTaskInstanceKey, { dagId, dagRunId, mapIndex, 
taskId }],
+      [useHumanInTheLoopServiceGetHitlDetailsKey],
     ];
 
     await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({ 
queryKey: key })));
diff --git a/airflow-core/src/airflow/ui/src/queries/useParamStore.ts 
b/airflow-core/src/airflow/ui/src/queries/useParamStore.ts
index 749bf2b34f4..6008985797d 100644
--- a/airflow-core/src/airflow/ui/src/queries/useParamStore.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useParamStore.ts
@@ -44,15 +44,18 @@ export const paramPlaceholder: ParamSpec = {
 
 type FormStore = {
   conf: string;
+  disabled: boolean;
   initialParamDict: ParamsSpec;
   paramsDict: ParamsSpec;
   setConf: (confString: string) => void;
+  setDisabled: (disabled: boolean) => void;
   setInitialParamDict: (newParamsDict: ParamsSpec) => void;
   setParamsDict: (newParamsDict: ParamsSpec) => void;
 };
 
 export const useParamStore = create<FormStore>((set) => ({
   conf: "{}",
+  disabled: false,
   initialParamDict: {},
   paramsDict: {},
 
@@ -83,6 +86,8 @@ export const useParamStore = create<FormStore>((set) => ({
       return { conf: confString, paramsDict: updatedParamsDict };
     }),
 
+  setDisabled: (disabled: boolean) => set(() => ({ disabled })),
+
   setInitialParamDict: (newParamsDict: ParamsSpec) => set(() => ({ 
initialParamDict: newParamsDict })),
 
   setParamsDict: (newParamsDict: ParamsSpec) =>
diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts 
b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
similarity index 53%
copy from airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
copy to airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
index d2ec715a011..3740bdba7c4 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts
@@ -17,68 +17,82 @@
  * under the License.
  */
 import { useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
 import { useTranslation } from "react-i18next";
 
 import {
-  useTaskInstanceServiceDeleteTaskInstance,
+  UseDagRunServiceGetDagRunKeyFn,
+  useDagRunServiceGetDagRunsKey,
+  useHumanInTheLoopServiceGetHitlDetailsKey,
+  useHumanInTheLoopServiceGetMappedTiHitlDetailKey,
+  useHumanInTheLoopServiceUpdateMappedTiHitlDetail,
   useTaskInstanceServiceGetTaskInstanceKey,
   useTaskInstanceServiceGetTaskInstancesKey,
-  useDagRunServiceGetDagRunsKey,
-  UseDagRunServiceGetDagRunKeyFn,
 } from "openapi/queries";
-import { toaster } from "src/components/ui";
+import { toaster } from "src/components/ui/Toaster";
+import type { HITLResponseParams } from "src/utils/hitl";
 
-type DeleteTaskInstanceParams = {
-  dagId: string;
-  dagRunId: string;
-  mapIndex?: number;
-  onSuccessConfirm: () => void;
-  taskId: string;
-};
-
-export const useDeleteTaskInstance = ({
+export const useUpdateHITLDetail = ({
   dagId,
   dagRunId,
   mapIndex,
-  onSuccessConfirm,
   taskId,
-}: DeleteTaskInstanceParams) => {
+}: {
+  dagId: string;
+  dagRunId: string;
+  mapIndex: number | undefined;
+  taskId: string;
+}) => {
   const queryClient = useQueryClient();
-  const { t: translate } = useTranslation(["common", "dags"]);
-
-  const onError = (error: Error) => {
-    toaster.create({
-      description: error.message,
-      title: translate("dags:runAndTaskActions.delete.error", { type: 
translate("taskInstance_one") }),
-      type: "error",
-    });
-  };
-
+  const [error, setError] = useState<unknown>(undefined);
+  const { t: translate } = useTranslation(["common", "hitl"]);
   const onSuccess = async () => {
     const queryKeys = [
       UseDagRunServiceGetDagRunKeyFn({ dagId, dagRunId }),
       [useDagRunServiceGetDagRunsKey],
-      [useTaskInstanceServiceGetTaskInstancesKey],
+      [useTaskInstanceServiceGetTaskInstancesKey, { dagId, dagRunId }],
       [useTaskInstanceServiceGetTaskInstanceKey, { dagId, dagRunId, mapIndex, 
taskId }],
+      [useHumanInTheLoopServiceGetHitlDetailsKey, { dagIdPattern: dagId, 
dagRunId }],
+      [useHumanInTheLoopServiceGetMappedTiHitlDetailKey, { dagId, dagRunId }],
     ];
 
     await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({ 
queryKey: key })));
 
     toaster.create({
-      description: 
translate("dags:runAndTaskActions.delete.success.description", {
-        type: translate("taskInstance_one"),
-      }),
-      title: translate("dags:runAndTaskActions.delete.success.title", {
-        type: translate("taskInstance_one"),
-      }),
+      title: translate("hitl:response.success", { taskId }),
       type: "success",
     });
+  };
 
-    onSuccessConfirm();
+  const onError = (_error: Error) => {
+    toaster.create({
+      description: _error.message,
+      title: translate("hitl:response.error"),
+      type: "error",
+    });
   };
 
-  return useTaskInstanceServiceDeleteTaskInstance({
+  const { isPending, mutate } = 
useHumanInTheLoopServiceUpdateMappedTiHitlDetail({
     onError,
     onSuccess,
   });
+
+  const updateHITLResponse = (updateHITLResponseRequestBody: 
HITLResponseParams) => {
+    try {
+      mutate({
+        dagId,
+        dagRunId,
+        mapIndex: mapIndex ?? -1,
+        requestBody: {
+          chosen_options: updateHITLResponseRequestBody.chosen_options ?? [],
+          params_input: updateHITLResponseRequestBody.params_input ?? {},
+        },
+        taskId,
+      });
+    } catch (parseError) {
+      setError(parseError);
+    }
+  };
+
+  return { error, isPending, setError, updateHITLResponse };
 };
diff --git a/airflow-core/src/airflow/ui/src/router.tsx 
b/airflow-core/src/airflow/ui/src/router.tsx
index fca1828e8ba..07ac8b7dd56 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -40,6 +40,7 @@ import { ErrorPage } from "src/pages/Error";
 import { Events } from "src/pages/Events";
 import { ExternalView } from "src/pages/ExternalView";
 import { GroupTaskInstance } from "src/pages/GroupTaskInstance";
+import { HITLTaskInstances } from "src/pages/HITLTaskInstances";
 import { MappedTaskInstance } from "src/pages/MappedTaskInstance";
 import { Plugins } from "src/pages/Plugins";
 import { Pools } from "src/pages/Pools";
@@ -53,6 +54,7 @@ import { Overview as TaskOverview } from 
"src/pages/Task/Overview";
 import { TaskInstance, Logs } from "src/pages/TaskInstance";
 import { AssetEvents as TaskInstanceAssetEvents } from 
"src/pages/TaskInstance/AssetEvents";
 import { Details as TaskInstanceDetails } from 
"src/pages/TaskInstance/Details";
+import { HITLResponse } from "src/pages/TaskInstance/HITLResponse";
 import { RenderedTemplates } from "src/pages/TaskInstance/RenderedTemplates";
 import { TaskInstances } from "src/pages/TaskInstances";
 import { Variables } from "src/pages/Variables";
@@ -74,6 +76,7 @@ const taskInstanceRoutes = [
   { element: <RenderedTemplates />, path: "rendered_templates" },
   { element: <TaskInstances />, path: "task_instances" },
   { element: <TaskInstanceAssetEvents />, path: "asset_events" },
+  { element: <HITLResponse />, path: "required_actions" },
   pluginRoute,
 ];
 
@@ -84,6 +87,10 @@ export const routerConfig = [
         element: <Dashboard />,
         index: true,
       },
+      {
+        element: <HITLTaskInstances />,
+        path: "required_actions",
+      },
       {
         element: <DagsList />,
         path: "dags",
@@ -154,6 +161,7 @@ export const routerConfig = [
           { element: <Overview />, index: true },
           { element: <DagRuns />, path: "runs" },
           { element: <Tasks />, path: "tasks" },
+          { element: <HITLTaskInstances />, path: "required_actions" },
           { element: <Backfills />, path: "backfills" },
           { element: <Events />, path: "events" },
           { element: <Code />, path: "code" },
@@ -166,6 +174,7 @@ export const routerConfig = [
       {
         children: [
           { element: <TaskInstances />, index: true },
+          { element: <HITLTaskInstances />, path: "required_actions" },
           { element: <Events />, path: "events" },
           { element: <Code />, path: "code" },
           { element: <DagRunDetails />, path: "details" },
@@ -194,6 +203,7 @@ export const routerConfig = [
         children: [
           { element: <TaskOverview />, index: true },
           { element: <TaskInstances />, path: "task_instances" },
+          { element: <HITLTaskInstances />, path: "required_actions" },
           pluginRoute,
         ],
         element: <Task />,
@@ -208,6 +218,7 @@ export const routerConfig = [
         children: [
           { element: <TaskOverview />, index: true },
           { element: <TaskInstances />, path: "task_instances" },
+          { element: <HITLTaskInstances />, path: "required_actions" },
           { element: <Events />, path: "events" },
           pluginRoute,
         ],
diff --git a/airflow-core/src/airflow/ui/src/utils/hitl.ts 
b/airflow-core/src/airflow/ui/src/utils/hitl.ts
new file mode 100644
index 00000000000..8577ccd8911
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/hitl.ts
@@ -0,0 +1,144 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { TFunction } from "i18next";
+
+import type { HITLDetail } from "openapi/requests/types.gen";
+import type { ParamsSpec } from "src/queries/useDagParams";
+
+export type HITLResponseParams = {
+  chosen_options?: Array<string>;
+  params_input?: Record<string, unknown>;
+};
+
+const getChosenOptionsValue = (hitlDetail: HITLDetail) => {
+  // if response_received is true, display the chosen_options, otherwise 
display the defaults
+  const sourceValues = hitlDetail.response_received ? 
hitlDetail.chosen_options : hitlDetail.defaults;
+
+  return hitlDetail.multiple ? sourceValues : sourceValues?.[0];
+};
+
+export const getHITLParamsDict = (hitlDetail: HITLDetail, translate: 
TFunction): ParamsSpec => {
+  const paramsDict: ParamsSpec = {};
+
+  if (hitlDetail.options.length > 4 || hitlDetail.multiple) {
+    paramsDict.chosen_options = {
+      description: translate("hitl:response.optionsDescription"),
+      schema: {
+        const: undefined,
+        description_md: translate("hitl:response.optionsDescription"),
+        enum: hitlDetail.options.length > 0 ? hitlDetail.options : undefined,
+        examples: undefined,
+        format: undefined,
+        items: hitlDetail.multiple ? { type: "string" } : undefined,
+        maximum: undefined,
+        maxLength: undefined,
+        minimum: undefined,
+        minLength: undefined,
+        section: undefined,
+        title: translate("hitl:response.optionsLabel"),
+        type: hitlDetail.multiple ? "array" : "string",
+        values_display: undefined,
+      },
+
+      value: getChosenOptionsValue(hitlDetail),
+    };
+  }
+
+  if (hitlDetail.params) {
+    const sourceParams = hitlDetail.response_received ? 
hitlDetail.params_input : hitlDetail.params;
+
+    Object.entries(sourceParams ?? {}).forEach(([key, value]) => {
+      const valueType = typeof value === "number" ? "number" : "string";
+
+      paramsDict[key] = {
+        description: "",
+        schema: {
+          const: undefined,
+          description_md: "",
+          enum: undefined,
+          examples: undefined,
+          format: undefined,
+          items: undefined,
+          maximum: undefined,
+          maxLength: undefined,
+          minimum: undefined,
+          minLength: undefined,
+          section: undefined,
+          title: key,
+          type: valueType,
+          values_display: undefined,
+        },
+        value,
+      };
+    });
+  }
+
+  return paramsDict;
+};
+
+export const getHITLFormData = (paramsDict: ParamsSpec, option?: string): 
HITLResponseParams => {
+  const chosenOptionsValue = paramsDict.chosen_options?.value;
+  let chosenOptions: Array<string> = [];
+
+  if (option === undefined) {
+    if (typeof chosenOptionsValue === "string" && chosenOptionsValue) {
+      chosenOptions = [chosenOptionsValue];
+    } else if (Array.isArray(chosenOptionsValue) && chosenOptionsValue.length 
> 0) {
+      chosenOptions = chosenOptionsValue.filter(
+        (value): value is string => value !== null && value !== undefined,
+      );
+    }
+  } else {
+    chosenOptions = [option];
+  }
+
+  const paramsInput = Object.keys(paramsDict)
+    .filter((key) => key !== "chosen_options")
+    .reduce<Record<string, unknown>>((acc, key) => {
+      acc[key] = paramsDict[key]?.value;
+
+      return acc;
+    }, {});
+
+  return {
+    chosen_options: chosenOptions,
+    params_input: paramsInput,
+  };
+};
+
+export const getHITLState = (translate: TFunction, hitlDetail: HITLDetail) => {
+  const { chosen_options: chosenOptions, options, params, response_received: 
responseReceived } = hitlDetail;
+
+  let stateType: [string, string] = ["responseRequired", "responseReceived"];
+
+  if (options.length === 2 && options.includes("Approve") && 
options.includes("Reject")) {
+    // If options contain only "Approve" and "Reject" -> approval task
+    stateType = [
+      "approvalRequired",
+      responseReceived && chosenOptions?.includes("Approve") ? 
"approvalReceived" : "rejectionReceived",
+    ];
+  } else if (params && Object.keys(params).length === 0) {
+    // If it's not an approval task and params are empty -> choice task
+    stateType = ["choiceRequired", "choiceReceived"];
+  }
+
+  const [required, received] = stateType;
+
+  return translate(`hitl:state.${responseReceived ? received : required}`);
+};
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py 
b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
index b8db2507cc2..b560205298a 100644
--- a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
@@ -66,6 +66,7 @@ from 
airflow.providers.fab.auth_manager.cli_commands.definition import (
 )
 from airflow.providers.fab.auth_manager.models import Permission, Role, User
 from airflow.providers.fab.auth_manager.models.anonymous_user import 
AnonymousUser
+from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_PLUS
 from airflow.providers.fab.www.app import create_app
 from airflow.providers.fab.www.constants import SWAGGER_BUNDLE, SWAGGER_ENABLED
 from airflow.providers.fab.www.extensions.init_views import (
@@ -170,6 +171,12 @@ _MAP_MENU_ITEM_TO_FAB_RESOURCE_TYPE = {
 }
 
 
+if AIRFLOW_V_3_1_PLUS:
+    from airflow.providers.fab.www.security.permissions import 
RESOURCE_HITL_DETAIL
+
+    _MAP_MENU_ITEM_TO_FAB_RESOURCE_TYPE[MenuItem.REQUIRED_ACTIONS] = 
RESOURCE_HITL_DETAIL
+
+
 class FabAuthManager(BaseAuthManager[User]):
     """
     Flask-AppBuilder auth manager.
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
 
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
index b242e5547d8..1366f45dee4 100644
--- 
a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py
@@ -233,6 +233,7 @@ class 
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_TASK_INSTANCE),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_TASK_LOG),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_XCOM),
+        (permissions.ACTION_CAN_READ, permissions.RESOURCE_HITL_DETAIL),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
         (permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_BROWSE_MENU),
         (permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_DAG),
@@ -258,6 +259,7 @@ class 
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
         (permissions.ACTION_CAN_CREATE, permissions.RESOURCE_DAG_RUN),
         (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_DAG_RUN),
         (permissions.ACTION_CAN_DELETE, permissions.RESOURCE_DAG_RUN),
+        (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_HITL_DETAIL),
         (permissions.ACTION_CAN_CREATE, RESOURCE_ASSET),
     ]
     # [END security_user_perms]
@@ -272,6 +274,7 @@ class 
FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
         (permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_VARIABLE),
         (permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_PROVIDER),
         (permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_XCOM),
+        (permissions.ACTION_CAN_ACCESS_MENU, permissions.RESOURCE_HITL_DETAIL),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_CONFIG),
         (permissions.ACTION_CAN_CREATE, permissions.RESOURCE_CONNECTION),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_CONNECTION),
diff --git 
a/providers/fab/src/airflow/providers/fab/www/security/permissions.py 
b/providers/fab/src/airflow/providers/fab/www/security/permissions.py
index 35a66222fce..43a9f4628cd 100644
--- a/providers/fab/src/airflow/providers/fab/www/security/permissions.py
+++ b/providers/fab/src/airflow/providers/fab/www/security/permissions.py
@@ -39,6 +39,7 @@ RESOURCE_ASSET = "Assets"
 RESOURCE_ASSET_ALIAS = "Asset Aliases"
 RESOURCE_DOCS = "Documentation"
 RESOURCE_DOCS_MENU = "Docs"
+RESOURCE_HITL_DETAIL = "HITL Detail"
 RESOURCE_IMPORT_ERROR = "ImportError"
 RESOURCE_JOB = "Jobs"
 RESOURCE_MY_PASSWORD = "My Password"
diff --git a/providers/fab/tests/unit/fab/auth_manager/test_security.py 
b/providers/fab/tests/unit/fab/auth_manager/test_security.py
index fe16650e3c2..adc94b4b7bd 100644
--- a/providers/fab/tests/unit/fab/auth_manager/test_security.py
+++ b/providers/fab/tests/unit/fab/auth_manager/test_security.py
@@ -448,6 +448,7 @@ def test_get_user_roles_for_anonymous_user(app, 
security_manager):
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_TASK_INSTANCE),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_TASK_LOG),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_XCOM),
+        (permissions.ACTION_CAN_READ, permissions.RESOURCE_HITL_DETAIL),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
         (permissions.ACTION_CAN_READ, permissions.RESOURCE_MY_PASSWORD),
         (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_MY_PASSWORD),
diff --git a/providers/fab/www-hash.txt b/providers/fab/www-hash.txt
index f27a39fa77f..c9b88e261fe 100644
--- a/providers/fab/www-hash.txt
+++ b/providers/fab/www-hash.txt
@@ -1 +1 @@
-7c75393001a5280d25bca03d39c13ec8072a12a3628628398379473fa85da21e
+3170cf1d4c18142de4e6821f6cb9be641110623b6ea6b0235f4cb7ee97644e1f

Reply via email to