Add POST /runs/{id}/preview endpoint and Preview button on Run page

Adds a new API endpoint for generating sandbox preview URLs (stubbed to
return google.com in demo mode). The Run detail page now shows a Preview
button that POSTs to this endpoint and opens the returned URL in a new tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-02 12:16:01 -05:00
parent 9e74e6f913
commit 47f032e535
9 changed files with 216 additions and 2 deletions

View file

@ -1,11 +1,12 @@
import { useEffect } from "react";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link, Outlet, useLocation } from "react-router";
import { Link, Outlet, useFetcher, useLocation } from "react-router";
import { statusColors } from "../data/runs";
import type { ColumnStatus } from "../data/runs";
import { apiJson } from "../api-client";
import { formatElapsedSecs, formatDurationSecs } from "../lib/format";
import type { RunListItem } from "@qltysh/arc-api-client";
import type { RunListItem, PreviewUrlResponse } from "@qltysh/arc-api-client";
import type { Route } from "./+types/run-detail";
const tabs = [
@ -38,6 +39,18 @@ export async function loader({ params }: Route.LoaderArgs) {
};
}
export async function action({ params, request }: Route.ActionArgs) {
const formData = await request.formData();
const port = formData.get("port");
const expiresInSecs = formData.get("expires_in_secs");
const result = await apiJson<PreviewUrlResponse>(`/runs/${params.id}/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ port: Number(port), expires_in_secs: Number(expiresInSecs) }),
});
return result;
}
export function meta({ data }: Route.MetaArgs) {
const run = data?.run;
return [{ title: run ? `${run.title} — Arc` : "Run — Arc" }];
@ -47,6 +60,13 @@ export default function RunDetail({ loaderData, params }: Route.ComponentProps)
const { run } = loaderData;
const { pathname } = useLocation();
const basePath = `/runs/${params.id}`;
const previewFetcher = useFetcher<PreviewUrlResponse>();
useEffect(() => {
if (previewFetcher.data?.url) {
window.open(previewFetcher.data.url, "_blank");
}
}, [previewFetcher.data]);
if (!run) {
return <p className="py-8 text-center text-sm text-fg-muted">Run not found.</p>;
@ -90,6 +110,23 @@ export default function RunDetail({ loaderData, params }: Route.ComponentProps)
</svg>
Open PR
</button>
{run.sandboxId && (
<previewFetcher.Form method="post">
<input type="hidden" name="port" value="3000" />
<input type="hidden" name="expires_in_secs" value="3600" />
<button
type="submit"
disabled={previewFetcher.state !== "idle"}
className="flex shrink-0 items-center gap-1.5 rounded-md border border-teal-500/20 px-3 py-1.5 text-sm font-medium text-teal-500 transition-colors hover:border-teal-500/50 hover:bg-teal-500/10 hover:text-fg disabled:opacity-50"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="size-3.5" aria-hidden="true">
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
<path fillRule="evenodd" d="M.664 10.59a1.651 1.651 0 0 1 0-1.186A10.004 10.004 0 0 1 10 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0 1 10 17c-4.257 0-7.893-2.66-9.336-6.41ZM14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" clipRule="evenodd" />
</svg>
{previewFetcher.state !== "idle" ? "Opening..." : "Preview"}
</button>
</previewFetcher.Form>
)}
{run.sandboxId && (
<Menu as="div" className="relative">
<MenuButton className="flex shrink-0 items-center gap-1.5 rounded-md border border-teal-500/20 px-3 py-1.5 text-sm font-medium text-teal-500 transition-colors hover:border-teal-500/50 hover:bg-teal-500/10 hover:text-fg">

View file

@ -99,6 +99,14 @@ pub async fn steer_run_stub(
(StatusCode::OK, Json(serde_json::json!({"accepted": true}))).into_response()
}
pub async fn generate_preview_url_stub(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
(StatusCode::OK, Json(serde_json::json!({"url": "https://google.com"}))).into_response()
}
pub async fn get_run_status(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,

View file

@ -79,6 +79,7 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
.route("/runs/{id}/verifications", get(crate::demo::get_run_verifications))
.route("/runs/{id}/configuration", get(crate::demo::get_run_configuration))
.route("/runs/{id}/steer", post(crate::demo::steer_run_stub))
.route("/runs/{id}/preview", post(crate::demo::generate_preview_url_stub))
.route("/workflows", get(crate::demo::list_workflows))
.route("/workflows/{name}", get(crate::demo::get_workflow))
.route("/workflows/{name}/runs", get(crate::demo::list_workflow_runs).post(crate::demo::trigger_workflow_run_stub))
@ -115,6 +116,7 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
.route("/runs/{id}/verifications", get(not_implemented))
.route("/runs/{id}/configuration", get(not_implemented))
.route("/runs/{id}/steer", post(not_implemented))
.route("/runs/{id}/preview", post(not_implemented))
.route("/workflows", get(not_implemented))
.route("/workflows/{name}", get(not_implemented))
.route("/workflows/{name}/runs", get(not_implemented).post(not_implemented))

View file

@ -398,6 +398,29 @@ paths:
"404":
description: Run not found
/runs/{id}/preview:
post:
operationId: generatePreviewUrl
tags: [Runs]
summary: Generate a sandbox preview URL
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PreviewUrlRequest"
responses:
"200":
description: Preview URL generated
content:
application/json:
schema:
$ref: "#/components/schemas/PreviewUrlResponse"
"404":
description: Run not found
# ── Workflows ─────────────────────────────────────────────────────────
/workflows:
@ -1252,6 +1275,25 @@ components:
guidance:
type: string
PreviewUrlRequest:
type: object
required:
- port
- expires_in_secs
properties:
port:
type: integer
expires_in_secs:
type: integer
PreviewUrlResponse:
type: object
required:
- url
properties:
url:
type: string
# ── Workflow Schemas ─────────────────────────────────────────────────
WorkflowListItem:

View file

@ -32,6 +32,8 @@ models/file-checkpoint.ts
models/file-diff.ts
models/history-entry.ts
models/index.ts
models/preview-url-request.ts
models/preview-url-response.ts
models/project.ts
models/recent-control-result.ts
models/retro-list-item.ts

View file

@ -28,6 +28,10 @@ import type { CancelRun200Response } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { PreviewUrlRequest } from '../models';
// @ts-ignore
import type { PreviewUrlResponse } from '../models';
// @ts-ignore
import type { RunFiles } from '../models';
// @ts-ignore
import type { RunListItem } from '../models';
@ -92,6 +96,45 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @summary Generate a sandbox preview URL
* @param {string} id
* @param {PreviewUrlRequest} previewUrlRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
generatePreviewUrl: async (id: string, previewUrlRequest: PreviewUrlRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('generatePreviewUrl', 'id', id)
// verify required parameter 'previewUrlRequest' is not null or undefined
assertParamExists('generatePreviewUrl', 'previewUrlRequest', previewUrlRequest)
const localVarPath = `/runs/{id}/preview`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(previewUrlRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Get run checkpoint
@ -712,6 +755,20 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.cancelRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Generate a sandbox preview URL
* @param {string} id
* @param {PreviewUrlRequest} previewUrlRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async generatePreviewUrl(id: string, previewUrlRequest: PreviewUrlRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PreviewUrlResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.generatePreviewUrl(id, previewUrlRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.generatePreviewUrl']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Get run checkpoint
@ -956,6 +1013,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
cancelRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<CancelRun200Response> {
return localVarFp.cancelRun(id, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Generate a sandbox preview URL
* @param {string} id
* @param {PreviewUrlRequest} previewUrlRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
generatePreviewUrl(id: string, previewUrlRequest: PreviewUrlRequest, options?: RawAxiosRequestConfig): AxiosPromise<PreviewUrlResponse> {
return localVarFp.generatePreviewUrl(id, previewUrlRequest, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Get run checkpoint
@ -1148,6 +1216,18 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).cancelRun(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Generate a sandbox preview URL
* @param {string} id
* @param {PreviewUrlRequest} previewUrlRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public generatePreviewUrl(id: string, previewUrlRequest: PreviewUrlRequest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).generatePreviewUrl(id, previewUrlRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Get run checkpoint

View file

@ -18,6 +18,8 @@ export * from './execute-query-response';
export * from './file-checkpoint';
export * from './file-diff';
export * from './history-entry';
export * from './preview-url-request';
export * from './preview-url-response';
export * from './project';
export * from './recent-control-result';
export * from './retro-list-item';

View file

@ -0,0 +1,21 @@
/* tslint:disable */
/* eslint-disable */
/**
* Arc Run API
* HTTP API for managing Arc workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PreviewUrlRequest {
'port': number;
'expires_in_secs': number;
}

View file

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
/**
* Arc Run API
* HTTP API for managing Arc workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PreviewUrlResponse {
'url': string;
}