diff --git a/packages/views/locales/zh-Hans/runtimes.json b/packages/views/locales/zh-Hans/runtimes.json
index 7cbbd7e15..9139c9700 100644
--- a/packages/views/locales/zh-Hans/runtimes.json
+++ b/packages/views/locales/zh-Hans/runtimes.json
@@ -81,7 +81,7 @@
"all_runtimes": "全部运行时",
"read_only": "只读",
"delete_button": "删除运行时",
- "delete_disabled_tooltip": "该运行时由正在运行的本地 daemon 托管,删除后会在数秒内自动重新注册。需停止 daemon 进程才能彻底移除。",
+ "delete_disabled_tooltip": "该运行时由正在运行的本地守护进程托管,删除后会在数秒内自动重新注册。需停止守护进程才能彻底移除。",
"last_seen": "最后活跃 {{when}}",
"fact_owner": "所有者",
"fact_device": "设备",
diff --git a/packages/views/runtimes/components/runtime-columns.tsx b/packages/views/runtimes/components/runtime-columns.tsx
index 9f6b76f52..87c141d7c 100644
--- a/packages/views/runtimes/components/runtime-columns.tsx
+++ b/packages/views/runtimes/components/runtime-columns.tsx
@@ -47,6 +47,7 @@ import { HealthIcon, useHealthLabel } from "./shared";
import {
computeCostInWindow,
formatLastSeen,
+ isSelfHealingRuntime,
isVersionNewer,
pctChange,
} from "../utils";
@@ -485,8 +486,13 @@ function RowMenu({
const { t } = useT("runtimes");
const deleteMutation = useDeleteRuntime(wsId);
const [deleteOpen, setDeleteOpen] = useState(false);
+ // Delete is currently the only row action; if the row can't run it, drop
+ // the kebab entirely so the column doesn't render an empty popover. The
+ // self-healing case (local + online) is the runtime-detail parity fix —
+ // see isSelfHealingRuntime for the rationale.
+ const selfHealing = isSelfHealingRuntime(runtime);
- if (!canDelete) {
+ if (!canDelete || selfHealing) {
return ;
}
diff --git a/packages/views/runtimes/components/runtime-detail.tsx b/packages/views/runtimes/components/runtime-detail.tsx
index 82efb3f27..ee5287504 100644
--- a/packages/views/runtimes/components/runtime-detail.tsx
+++ b/packages/views/runtimes/components/runtime-detail.tsx
@@ -41,7 +41,7 @@ import {
import { ActorAvatar } from "../../common/actor-avatar";
import { AppLink, useNavigation } from "../../navigation";
import { availabilityConfig, workloadConfig } from "../../agents/presence";
-import { formatLastSeen } from "../utils";
+import { formatLastSeen, isSelfHealingRuntime } from "../utils";
import { HealthBadge } from "./shared";
import { ProviderLogo } from "./provider-logo";
import { UpdateSection } from "./update-section";
@@ -484,10 +484,7 @@ function DiagnosticsCard({
}) {
const { t } = useT("runtimes");
const isLocal = runtime.runtime_mode === "local";
- // A live local daemon re-registers itself within seconds of a server-side
- // delete (daemon self-heal, #2404), so deleting an online local runtime
- // from the UI has no lasting effect — disable it and explain why.
- const selfHealing = isLocal && runtime.status === "online";
+ const selfHealing = isSelfHealingRuntime(runtime);
// canDelete here doubles as the "can edit runtime" predicate — it already
// means "workspace owner/admin OR runtime owner", which is the same gate
// the server enforces for the visibility PATCH.
diff --git a/packages/views/runtimes/components/runtime-row-menu.test.tsx b/packages/views/runtimes/components/runtime-row-menu.test.tsx
new file mode 100644
index 000000000..8a4ae6d57
--- /dev/null
+++ b/packages/views/runtimes/components/runtime-row-menu.test.tsx
@@ -0,0 +1,163 @@
+// @vitest-environment jsdom
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import type { AgentRuntime } from "@multica/core/types";
+import { I18nProvider } from "@multica/core/i18n/react";
+import enCommon from "../../locales/en/common.json";
+import enRuntimes from "../../locales/en/runtimes.json";
+import enAgents from "../../locales/en/agents.json";
+
+const TEST_RESOURCES = {
+ en: { common: enCommon, runtimes: enRuntimes, agents: enAgents },
+};
+
+// Stub the workspace queries the columns reach into. None of them feed the
+// row menu directly, but `createRuntimeColumns` wires CliCell + CostCell
+// against the same query client, so we still need useQuery to resolve.
+vi.mock("@tanstack/react-query", async () => {
+ const actual =
+ await vi.importActual(
+ "@tanstack/react-query",
+ );
+ return {
+ ...actual,
+ useQuery: vi.fn(() => ({ data: [], isLoading: false })),
+ };
+});
+
+vi.mock("@multica/core/runtimes/mutations", () => ({
+ useDeleteRuntime: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+vi.mock("@multica/core/runtimes", () => ({
+ deriveRuntimeHealth: () => "online",
+ runtimeUsageOptions: () => ({ kind: "usage" }),
+}));
+
+vi.mock("@multica/core/agents", () => ({
+ deriveWorkload: () => "idle",
+}));
+
+vi.mock("../../common/use-viewing-timezone", () => ({
+ useViewingTimezone: () => "UTC",
+}));
+
+vi.mock("./provider-logo", () => ({ ProviderLogo: () => null }));
+vi.mock("./shared", () => ({
+ HealthIcon: () => null,
+ useHealthLabel: () => () => "Online",
+}));
+
+import { createRuntimeColumns, type RuntimeRow } from "./runtime-columns";
+import { useT } from "../../i18n";
+
+function makeRuntime(overrides: Partial): AgentRuntime {
+ return {
+ id: "rt-1",
+ workspace_id: "ws-1",
+ daemon_id: null,
+ name: "rt",
+ runtime_mode: "local",
+ provider: "claude",
+ launch_header: "",
+ status: "online",
+ device_info: "",
+ metadata: {},
+ owner_id: "user-1",
+ visibility: "private",
+ last_seen_at: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function makeRow(runtime: AgentRuntime, canDelete = true): RuntimeRow {
+ return {
+ runtime,
+ ownerMember: null,
+ workload: { agentIds: [], runningCount: 0, queuedCount: 0 },
+ canDelete,
+ };
+}
+
+// The row menu lives inside the "actions" column cell. To exercise it
+// without rendering the entire DataTable, we resolve the cell renderer from
+// createRuntimeColumns and render its output directly inside a minimal table
+// row (the cell expects React table context, but our shape — `row.original`
+// — is the only field RowMenu reads, so a hand-built shim suffices).
+function renderActionsCell(row: RuntimeRow) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ function Harness() {
+ const { t } = useT("runtimes");
+ const columns = createRuntimeColumns({
+ showOwner: false,
+ latestCliVersion: null,
+ wsId: "ws-1",
+ now: Date.now(),
+ t,
+ });
+ const actions = columns.find((c) => c.id === "actions");
+ if (!actions || typeof actions.cell !== "function") {
+ throw new Error("actions column missing or has no cell renderer");
+ }
+ // The cell renderer only reads `row.original`. Casting through unknown
+ // keeps us honest about not implementing the full tanstack-table cell
+ // context.
+ const cell = actions.cell({
+ row: { original: row },
+ } as unknown as Parameters[0]);
+ return <>{cell}>;
+ }
+
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("runtime list row menu", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("hides the kebab menu for an online local runtime (self-healing)", () => {
+ // Deleting an online local runtime is a no-op (daemon re-registers in
+ // seconds), so the row menu drops the only action — Delete — entirely.
+ renderActionsCell(
+ makeRow(makeRuntime({ runtime_mode: "local", status: "online" })),
+ );
+ expect(screen.queryByLabelText("Row actions")).not.toBeInTheDocument();
+ });
+
+ it("renders the kebab menu for an offline local runtime", () => {
+ renderActionsCell(
+ makeRow(makeRuntime({ runtime_mode: "local", status: "offline" })),
+ );
+ expect(screen.getByLabelText("Row actions")).toBeInTheDocument();
+ });
+
+ it("renders the kebab menu for a cloud runtime regardless of status", () => {
+ renderActionsCell(
+ makeRow(makeRuntime({ runtime_mode: "cloud", status: "online" })),
+ );
+ expect(screen.getByLabelText("Row actions")).toBeInTheDocument();
+ });
+
+ it("hides the kebab menu when the caller lacks delete permission", () => {
+ // Pre-existing behavior — re-asserted so the new self-healing guard
+ // doesn't accidentally regress it (both paths return the same empty
+ // span).
+ renderActionsCell(
+ makeRow(
+ makeRuntime({ runtime_mode: "local", status: "offline" }),
+ /* canDelete */ false,
+ ),
+ );
+ expect(screen.queryByLabelText("Row actions")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/views/runtimes/utils.test.ts b/packages/views/runtimes/utils.test.ts
index 120a76a1a..baa6acbf0 100644
--- a/packages/views/runtimes/utils.test.ts
+++ b/packages/views/runtimes/utils.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
-import type { RuntimeUsage } from "@multica/core/types";
+import type { AgentRuntime, RuntimeUsage } from "@multica/core/types";
import {
addDaysIso,
@@ -10,6 +10,7 @@ import {
computeCostInWindow,
estimateCost,
isModelPriced,
+ isSelfHealingRuntime,
sliceWindow,
todayIso,
weekStartIso,
@@ -27,6 +28,61 @@ const zeroUsage = {
cache_write_tokens: 0,
};
+describe("isSelfHealingRuntime", () => {
+ function makeRuntime(overrides: Partial): AgentRuntime {
+ return {
+ id: "rt-1",
+ workspace_id: "ws-1",
+ daemon_id: null,
+ name: "rt",
+ runtime_mode: "local",
+ provider: "claude",
+ launch_header: "",
+ status: "online",
+ device_info: "",
+ metadata: {},
+ owner_id: null,
+ visibility: "private",
+ last_seen_at: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ ...overrides,
+ };
+ }
+
+ it("flags an online local runtime as self-healing", () => {
+ expect(
+ isSelfHealingRuntime(
+ makeRuntime({ runtime_mode: "local", status: "online" }),
+ ),
+ ).toBe(true);
+ });
+
+ it("treats an offline local runtime as safe to delete", () => {
+ // Daemon isn't running, so the server-side delete is final — no
+ // re-registration race to worry about.
+ expect(
+ isSelfHealingRuntime(
+ makeRuntime({ runtime_mode: "local", status: "offline" }),
+ ),
+ ).toBe(false);
+ });
+
+ it("treats cloud runtimes as safe to delete regardless of status", () => {
+ // Cloud workers are managed by Fleet, not a self-restarting local daemon.
+ expect(
+ isSelfHealingRuntime(
+ makeRuntime({ runtime_mode: "cloud", status: "online" }),
+ ),
+ ).toBe(false);
+ expect(
+ isSelfHealingRuntime(
+ makeRuntime({ runtime_mode: "cloud", status: "offline" }),
+ ),
+ ).toBe(false);
+ });
+});
+
describe("estimateCost", () => {
it("prices the canonical Anthropic Sonnet 4.6 SKU", () => {
const cost = estimateCost({
diff --git a/packages/views/runtimes/utils.ts b/packages/views/runtimes/utils.ts
index 9c1a6bc8c..833466786 100644
--- a/packages/views/runtimes/utils.ts
+++ b/packages/views/runtimes/utils.ts
@@ -1,9 +1,18 @@
import type {
+ AgentRuntime,
RuntimeUsage,
RuntimeUsageByAgent,
} from "@multica/core/types";
import { getCustomPricing } from "@multica/core/runtimes/custom-pricing-store";
+// A live local daemon re-registers itself within seconds of a server-side
+// delete (daemon self-heal, #2404), so deleting an online local runtime from
+// the UI has no lasting effect. Both the detail page and the list row menu
+// gate their Delete affordance on this same predicate.
+export function isSelfHealingRuntime(runtime: AgentRuntime): boolean {
+ return runtime.runtime_mode === "local" && runtime.status === "online";
+}
+
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------