diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx
index 6a181abc16b..76ed0faeaa1 100644
--- a/ui/src/components/ProjectProperties.tsx
+++ b/ui/src/components/ProjectProperties.tsx
@@ -860,6 +860,18 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
service.command ?? "No URL"
)}
+ {service.exposure && service.exposure.state !== "removed" ? (
+
+ HTTPS {service.exposure.state.replace("_", " ")}
+
+ ) : null}
{service.lifecycle}
diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx
index 1bc5a8ac571..baa1c5e0190 100644
--- a/ui/src/components/WorkspaceRuntimeControls.test.tsx
+++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx
@@ -47,6 +47,7 @@ function createRuntimeService(overrides: Partial
= {}):
stoppedAt: overrides.stoppedAt ?? null,
stopPolicy: overrides.stopPolicy ?? null,
healthStatus: overrides.healthStatus ?? "unknown",
+ exposure: overrides.exposure ?? null,
configIndex: overrides.configIndex ?? null,
createdAt: overrides.createdAt ?? new Date("2026-04-12T00:00:00.000Z"),
updatedAt: overrides.updatedAt ?? new Date("2026-04-12T00:00:00.000Z"),
@@ -627,6 +628,36 @@ describe("buildWorkspaceServiceControlEntries", () => {
expect(entries[0].state).toBe("failed");
expect(entries[0].failureDetail).toMatch(/^Service failed · /);
});
+
+ it("surfaces HTTPS failure independently while the backend remains running", () => {
+ const running = createRuntimeService({
+ status: "running",
+ healthStatus: "healthy",
+ port: 42000,
+ url: null,
+ exposure: {
+ provider: "tailscale_https",
+ state: "failed",
+ publicUrl: null,
+ hostname: "runner.tail123.ts.net",
+ listeners: [{ purpose: "app", publicPort: 42000, targetPort: 42000 }],
+ brokerRef: "service-1",
+ lastError: "cli_error",
+ updatedAt: "2026-08-11T00:00:00.000Z",
+ },
+ });
+ const built = buildWorkspaceRuntimeControlSections({
+ runtimeConfig: { commands: [{ id: "web", name: "web", kind: "service", command: "pnpm dev" }] },
+ runtimeServices: [running],
+ canStartServices: true,
+ });
+ const [entry] = buildWorkspaceServiceControlEntries({ sections: built, runtimeServices: [running] });
+
+ expect(entry.state).toBe("running");
+ expect(entry.exposureState).toBe("failed");
+ expect(entry.exposureDetail).toMatch(/^HTTPS unavailable/);
+ expect(entry.exposureDetail).not.toContain("cli_error");
+ });
});
describe("resolveWorkspaceServiceControlRequests", () => {
diff --git a/ui/src/components/WorkspaceRuntimeControls.tsx b/ui/src/components/WorkspaceRuntimeControls.tsx
index 3afed0427bc..d3d2708da87 100644
--- a/ui/src/components/WorkspaceRuntimeControls.tsx
+++ b/ui/src/components/WorkspaceRuntimeControls.tsx
@@ -1,5 +1,6 @@
import type {
WorkspaceCommandDefinition,
+ RuntimeExposureStatus,
WorkspaceRuntimeControlTarget,
WorkspaceRuntimeService,
} from "@paperclipai/shared";
@@ -30,6 +31,7 @@ export type WorkspaceRuntimeControlItem = {
statusLabel: string;
lifecycle: "shared" | "ephemeral" | null;
healthStatus: "unknown" | "healthy" | "unhealthy" | null;
+ exposure: RuntimeExposureStatus | null;
command: string | null;
cwd: string | null;
port: number | null;
@@ -96,6 +98,7 @@ function buildServiceItem(
statusLabel: runtimeService?.status ?? "stopped",
lifecycle: runtimeService?.lifecycle ?? command.lifecycle,
healthStatus: runtimeService?.healthStatus ?? "unknown",
+ exposure: runtimeService?.exposure ?? null,
command: runtimeService?.command ?? command.command,
cwd: runtimeService?.cwd ?? command.cwd,
port: runtimeService?.port ?? null,
@@ -120,6 +123,7 @@ function buildJobItem(
statusLabel: "run once",
lifecycle: null,
healthStatus: null,
+ exposure: null,
command: command.command,
cwd: command.cwd,
port: null,
@@ -169,6 +173,7 @@ export function buildWorkspaceRuntimeControlSections(input: {
statusLabel: runtimeService.status,
lifecycle: runtimeService.lifecycle,
healthStatus: runtimeService.healthStatus,
+ exposure: runtimeService.exposure ?? null,
command: runtimeService.command ?? null,
cwd: runtimeService.cwd ?? null,
port: runtimeService.port ?? null,
@@ -260,6 +265,16 @@ export function buildWorkspaceServiceControlEntries(input: {
const failureDetail = state === "failed"
? `Service failed${runtimeService?.stoppedAt ? ` · ${timeAgo(runtimeService.stoppedAt)}` : ""}`
: null;
+ const exposure = runtimeService?.exposure ?? item.exposure;
+ const exposureDetail = exposure?.state === "pending"
+ ? "Provisioning HTTPS…"
+ : exposure?.state === "ready"
+ ? "HTTPS ready"
+ : exposure?.state === "failed"
+ ? "HTTPS unavailable · Check the Tailscale broker and node HTTPS configuration."
+ : exposure?.state === "cleanup_pending"
+ ? "HTTPS cleanup pending · Restart the host broker before reusing this port."
+ : null;
return {
key: item.key,
@@ -269,6 +284,8 @@ export function buildWorkspaceServiceControlEntries(input: {
url: item.url,
port: item.port,
failureDetail,
+ exposureState: exposure?.state ?? null,
+ exposureDetail,
canStart: item.canStart,
};
});
diff --git a/ui/src/components/WorkspaceServiceControlBar.test.tsx b/ui/src/components/WorkspaceServiceControlBar.test.tsx
index 113f1253600..3f3617da950 100644
--- a/ui/src/components/WorkspaceServiceControlBar.test.tsx
+++ b/ui/src/components/WorkspaceServiceControlBar.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { act } from "react";
+import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceServiceControlBar } from "./WorkspaceServiceControlBar";
@@ -8,6 +8,17 @@ import { WorkspaceServiceControlBar } from "./WorkspaceServiceControlBar";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+async function act(callback: () => void | Promise) {
+ let pending: void | Promise;
+ flushSync(() => {
+ pending = callback();
+ });
+ await pending!;
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ flushSync(() => {});
+}
+
describe("WorkspaceServiceControlBar", () => {
let container: HTMLDivElement;
let root: Root;
@@ -105,4 +116,26 @@ describe("WorkspaceServiceControlBar", () => {
expect(runningSegment).not.toBeNull();
expect(runningSegment?.className).toBe(stoppedSegment?.className);
});
+
+ it("renders HTTPS cleanup separately from process running state", async () => {
+ await act(() => {
+ root.render(
+ {}}
+ />,
+ );
+ });
+
+ expect(container.textContent).toContain("Running");
+ expect(container.textContent).toContain("HTTPS cleanup pending");
+ expect(container.querySelector(".text-destructive")).not.toBeNull();
+ });
});
diff --git a/ui/src/components/WorkspaceServiceControlBar.tsx b/ui/src/components/WorkspaceServiceControlBar.tsx
index 8862e2bf40e..4e3b632a106 100644
--- a/ui/src/components/WorkspaceServiceControlBar.tsx
+++ b/ui/src/components/WorkspaceServiceControlBar.tsx
@@ -35,6 +35,9 @@ export type WorkspaceServiceControlEntry = {
port?: number | null;
/** Short human-readable failure summary, e.g. "dev exited with code 1, 12s ago". */
failureDetail?: string | null;
+ /** HTTPS exposure lifecycle, intentionally separate from process health. */
+ exposureState?: "pending" | "ready" | "failed" | "cleanup_pending" | "removed" | null;
+ exposureDetail?: string | null;
canStart?: boolean;
};
@@ -276,18 +279,20 @@ function ActionSlots({
);
}
-function FailureDetail({
+function ServiceDetail({
entry,
onViewLogs,
}: {
entry: WorkspaceServiceControlEntry;
onViewLogs?: () => void;
}) {
- if (entry.state !== "failed" || !entry.failureDetail) return null;
+ const detail = entry.exposureDetail ?? (entry.state === "failed" ? entry.failureDetail : null);
+ if (!detail) return null;
+ const exposureFailed = entry.exposureState === "failed" || entry.exposureState === "cleanup_pending";
return (
-
-
{entry.failureDetail}
- {onViewLogs ? (
+
+ {detail}
+ {onViewLogs && entry.state === "failed" ? (
<>
·
-
+
);
}
@@ -354,8 +359,10 @@ function ServicePopoverRow({
const meta = statusMeta(entry);
const displayUrl = formatServiceUrl(entry.url);
const live = entry.state === "running" && Boolean(entry.url);
- const secondary = live
- ? displayUrl
+ const secondary = entry.exposureDetail
+ ? entry.exposureDetail
+ : live
+ ? displayUrl
: entry.state === "starting" && entry.port
? `starting on :${entry.port}…`
: entry.state === "failed" && entry.failureDetail