diff --git a/packages/views/settings/components/delete-workspace-dialog.test.tsx b/packages/views/settings/components/delete-workspace-dialog.test.tsx
new file mode 100644
index 000000000..c19144b58
--- /dev/null
+++ b/packages/views/settings/components/delete-workspace-dialog.test.tsx
@@ -0,0 +1,205 @@
+import type { ReactNode } from "react";
+import { describe, expect, it, beforeEach, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+// The shared Dialog is a Base UI portal that's awkward to test — strip it to
+// simple pass-through wrappers. The typed-confirmation logic lives in the
+// dialog body, not in Base UI, so this doesn't reduce coverage.
+vi.mock("@multica/ui/components/ui/dialog", () => ({
+ Dialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
+ open ?
,
+}));
+
+import { DeleteWorkspaceDialog } from "./delete-workspace-dialog";
+
+describe("DeleteWorkspaceDialog", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("disables Delete when input is empty", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Delete workspace" })).toBeDisabled();
+ });
+
+ it("keeps Delete disabled when input doesn't match (case-sensitive)", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.type(screen.getByRole("textbox"), "ACME"); // wrong case
+ expect(screen.getByRole("button", { name: "Delete workspace" })).toBeDisabled();
+
+ await user.clear(screen.getByRole("textbox"));
+ await user.type(screen.getByRole("textbox"), "acme "); // trailing space
+ expect(screen.getByRole("button", { name: "Delete workspace" })).toBeDisabled();
+ });
+
+ it("enables Delete on exact match and calls onConfirm when clicked", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render(
+ ,
+ );
+
+ await user.type(screen.getByRole("textbox"), "acme");
+ const deleteBtn = screen.getByRole("button", { name: "Delete workspace" });
+ expect(deleteBtn).toBeEnabled();
+
+ await user.click(deleteBtn);
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it("submits on Enter when matched; ignores Enter when not matched", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "acm{Enter}"); // not yet matched
+ expect(onConfirm).not.toHaveBeenCalled();
+
+ await user.type(input, "e{Enter}"); // now matches "acme"
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it("Cancel closes the dialog and does not call onConfirm", async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ const onConfirm = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ it("shows loading state and disables both buttons while pending", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Deleting..." })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
+ });
+
+ it("matches names with spaces, unicode, and other non-ASCII characters literally", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render(
+ ,
+ );
+ const input = screen.getByRole("textbox");
+ await user.type(input, "My 团队 🚀");
+ expect(screen.getByRole("button", { name: "Delete workspace" })).toBeEnabled();
+ await user.click(screen.getByRole("button", { name: "Delete workspace" }));
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it("resets the input when the workspace being deleted changes (e.g. rename mid-dialog)", () => {
+ const { rerender } = render(
+ ,
+ );
+ const input = screen.getByRole("textbox") as HTMLInputElement;
+ // Simulate user typing (set value directly since userEvent.type would
+ // lose focus across re-renders).
+ input.value = "old-name";
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("textbox")).toHaveValue("");
+ });
+
+ it("clears the input when reopened so prior attempts don't leak", async () => {
+ const user = userEvent.setup();
+ const { rerender } = render(
+ ,
+ );
+
+ await user.type(screen.getByRole("textbox"), "partial");
+ expect(screen.getByRole("textbox")).toHaveValue("partial");
+
+ // Simulate close → reopen (e.g. user canceled, then clicked Delete again)
+ rerender(
+ ,
+ );
+ rerender(
+ ,
+ );
+
+ expect(screen.getByRole("textbox")).toHaveValue("");
+ });
+});
diff --git a/packages/views/settings/components/delete-workspace-dialog.tsx b/packages/views/settings/components/delete-workspace-dialog.tsx
new file mode 100644
index 000000000..64e0240d6
--- /dev/null
+++ b/packages/views/settings/components/delete-workspace-dialog.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@multica/ui/components/ui/dialog";
+import { Input } from "@multica/ui/components/ui/input";
+import { Label } from "@multica/ui/components/ui/label";
+import { Button } from "@multica/ui/components/ui/button";
+
+/**
+ * Typed-confirmation dialog for workspace deletion — GitHub's repo-delete
+ * pattern. The destructive button stays disabled until the user types
+ * the workspace name exactly (case-sensitive, no trimming). The friction
+ * is deliberate: deleting a workspace cascades into every issue, agent,
+ * skill, and run under it, and the backend has no soft-delete.
+ *
+ * Case-sensitive match matches GitHub's pattern and catches the "I
+ * remember the gist of the name but not the casing" misfire. No trim —
+ * leading/trailing whitespace indicates a typo, and silently accepting
+ * it would weaken the whole point of the gate.
+ *
+ * Input value resets whenever the dialog closes so reopening doesn't
+ * leak the previous attempt (which might have been for a different
+ * workspace after a swap).
+ */
+export function DeleteWorkspaceDialog({
+ workspaceName,
+ loading = false,
+ open,
+ onOpenChange,
+ onConfirm,
+}: {
+ workspaceName: string;
+ loading?: boolean;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+}) {
+ const [typed, setTyped] = useState("");
+ const matched = typed === workspaceName;
+
+ // Reset on close (so reopening for a different workspace doesn't leak
+ // the prior attempt) AND on workspaceName change (if another owner
+ // renames the workspace while the dialog is open, the already-typed
+ // string stops matching and there'd be no feedback explaining why).
+ useEffect(() => {
+ setTyped("");
+ }, [open, workspaceName]);
+
+ const submit = () => {
+ if (!matched || loading) return;
+ onConfirm();
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/views/settings/components/workspace-tab.tsx b/packages/views/settings/components/workspace-tab.tsx
index 57149ac7a..29ddf82a7 100644
--- a/packages/views/settings/components/workspace-tab.tsx
+++ b/packages/views/settings/components/workspace-tab.tsx
@@ -32,12 +32,13 @@ import { api } from "@multica/core/api";
import { paths } from "@multica/core/paths";
import type { Workspace } from "@multica/core/types";
import { useNavigation } from "../../navigation";
+import { DeleteWorkspaceDialog } from "./delete-workspace-dialog";
export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
const workspace = useCurrentWorkspace();
const wsId = useWorkspaceId();
- const { data: members = [] } = useQuery(memberListOptions(wsId));
+ const { data: members = [], isFetched: membersFetched } = useQuery(memberListOptions(wsId));
const qc = useQueryClient();
const leaveWorkspace = useLeaveWorkspace();
const deleteWorkspace = useDeleteWorkspace();
@@ -73,10 +74,18 @@ export function WorkspaceTab() {
variant?: "destructive";
onConfirm: () => Promise;
} | null>(null);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
const isOwner = currentMember?.role === "owner";
+ // Mirror the backend invariant (server/internal/handler/workspace.go:569):
+ // a workspace must always have at least one owner, so the sole owner can't
+ // leave. Pre-flight here instead of letting the 400 round-trip become a
+ // confusing toast — disable Leave and tell the user what they need to do.
+ const ownerCount = members.filter((m) => m.role === "owner").length;
+ const isSoleOwner = isOwner && ownerCount <= 1;
+ const isSoleMember = members.length <= 1;
useEffect(() => {
setName(workspace?.name ?? "");
@@ -124,24 +133,18 @@ export function WorkspaceTab() {
});
};
- const handleDeleteWorkspace = () => {
+ const handleConfirmDelete = async () => {
if (!workspace) return;
- setConfirmAction({
- title: "Delete workspace",
- description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`,
- variant: "destructive",
- onConfirm: async () => {
- setActionId("delete-workspace");
- try {
- await deleteWorkspace.mutateAsync(workspace.id);
- await navigateAwayFromCurrentWorkspace();
- } catch (e) {
- toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
- } finally {
- setActionId(null);
- }
- },
- });
+ setActionId("delete-workspace");
+ try {
+ await deleteWorkspace.mutateAsync(workspace.id);
+ setDeleteDialogOpen(false);
+ await navigateAwayFromCurrentWorkspace();
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
+ } finally {
+ setActionId(null);
+ }
};
if (!workspace) return null;
@@ -211,7 +214,10 @@ export function WorkspaceTab() {
- {/* Danger Zone */}
+ {/* Danger Zone — gated on the member query settling so the owner-only
+ Delete button and the sole-owner Leave guidance don't flash in
+ after mount. */}
+ {membersFetched && (
@@ -224,14 +230,18 @@ export function WorkspaceTab() {
Leave workspace
- Remove yourself from this workspace.
+ {isSoleOwner
+ ? isSoleMember
+ ? "You're the only member. Delete the workspace to leave."
+ : "You're the only owner. Promote another member to owner first, or delete the workspace."
+ : "Remove yourself from this workspace."}
@@ -248,7 +258,7 @@ export function WorkspaceTab() {