From 397cbb34b327a0addfdf8e36f859b456956b66fe Mon Sep 17 00:00:00 2001
From: 苏向夜 <fu050409@163.com>
Date: Sun, 29 Mar 2026 01:19:25 +0800
Subject: fix(lint): apply ui code lint

---
 packages/ui/src/components/bottom-bar.tsx | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

(limited to 'packages/ui/src/components/bottom-bar.tsx')

diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx
index 8f70985..2746e00 100644
--- a/packages/ui/src/components/bottom-bar.tsx
+++ b/packages/ui/src/components/bottom-bar.tsx
@@ -21,13 +21,17 @@ export function BottomBar() {
   const account = useAuthStore((state) => state.account);
   const instances = useInstanceStore((state) => state.instances);
   const activeInstance = useInstanceStore((state) => state.activeInstance);
-  const setActiveInstance = useInstanceStore((state) => state.setActiveInstance);
+  const setActiveInstance = useInstanceStore(
+    (state) => state.setActiveInstance,
+  );
   const selectedVersion = useGameStore((state) => state.selectedVersion);
   const setSelectedVersion = useGameStore((state) => state.setSelectedVersion);
   const startGame = useGameStore((state) => state.startGame);
   const stopGame = useGameStore((state) => state.stopGame);
   const runningInstanceId = useGameStore((state) => state.runningInstanceId);
-  const launchingInstanceId = useGameStore((state) => state.launchingInstanceId);
+  const launchingInstanceId = useGameStore(
+    (state) => state.launchingInstanceId,
+  );
   const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId);
 
   const [showLoginModal, setShowLoginModal] = useState(false);
@@ -39,7 +43,7 @@ export function BottomBar() {
     }
 
     setSelectedVersion(nextVersion);
-  }, [activeInstance?.id, activeInstance?.versionId, selectedVersion, setSelectedVersion]);
+  }, [activeInstance?.versionId, selectedVersion, setSelectedVersion]);
 
   const handleInstanceChange = useCallback(
     async (instanceId: string) => {
@@ -47,7 +51,9 @@ export function BottomBar() {
         return;
       }
 
-      const nextInstance = instances.find((instance) => instance.id === instanceId);
+      const nextInstance = instances.find(
+        (instance) => instance.id === instanceId,
+      );
       if (!nextInstance) {
         return;
       }
-- 
cgit v1.2.3-70-g09d2


From ffbfce895c37e8e8306d426a2e59e73647ed6a86 Mon Sep 17 00:00:00 2001
From: 苏向夜 <fu050409@163.com>
Date: Sun, 29 Mar 2026 02:46:51 +0800
Subject: refactor(ui): rewrite game store

---
 .changes/game-store.md                    |   5 +
 packages/ui/src/components/bottom-bar.tsx |  31 +-
 packages/ui/src/models/game.ts            | 113 ++++++++
 packages/ui/src/pages/home-view.tsx       | 102 -------
 packages/ui/src/pages/home.tsx            | 102 +++++++
 packages/ui/src/pages/index.tsx           |  12 +-
 packages/ui/src/pages/instances-view.tsx  | 457 ------------------------------
 packages/ui/src/pages/routes.ts           |   6 +-
 packages/ui/src/stores/game-store.ts      | 184 ------------
 9 files changed, 236 insertions(+), 776 deletions(-)
 create mode 100644 .changes/game-store.md
 create mode 100644 packages/ui/src/models/game.ts
 delete mode 100644 packages/ui/src/pages/home-view.tsx
 create mode 100644 packages/ui/src/pages/home.tsx
 delete mode 100644 packages/ui/src/pages/instances-view.tsx
 delete mode 100644 packages/ui/src/stores/game-store.ts

(limited to 'packages/ui/src/components/bottom-bar.tsx')

diff --git a/.changes/game-store.md b/.changes/game-store.md
new file mode 100644
index 0000000..0b2f7f6
--- /dev/null
+++ b/.changes/game-store.md
@@ -0,0 +1,5 @@
+---
+"@dropout/ui": "patch:refactor"
+---
+
+Refactor game store and rename `HomePage` component.
diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx
index 2746e00..fd4a681 100644
--- a/packages/ui/src/components/bottom-bar.tsx
+++ b/packages/ui/src/components/bottom-bar.tsx
@@ -3,8 +3,8 @@ import { useCallback, useEffect, useState } from "react";
 import { toast } from "sonner";
 import { cn } from "@/lib/utils";
 import { useAuthStore } from "@/models/auth";
+import { useGameStore } from "@/models/game";
 import { useInstanceStore } from "@/models/instance";
-import { useGameStore } from "@/stores/game-store";
 import { LoginModal } from "./login-modal";
 import { Button } from "./ui/button";
 import {
@@ -19,21 +19,17 @@ import { Spinner } from "./ui/spinner";
 
 export function BottomBar() {
   const account = useAuthStore((state) => state.account);
-  const instances = useInstanceStore((state) => state.instances);
-  const activeInstance = useInstanceStore((state) => state.activeInstance);
-  const setActiveInstance = useInstanceStore(
-    (state) => state.setActiveInstance,
-  );
-  const selectedVersion = useGameStore((state) => state.selectedVersion);
-  const setSelectedVersion = useGameStore((state) => state.setSelectedVersion);
-  const startGame = useGameStore((state) => state.startGame);
-  const stopGame = useGameStore((state) => state.stopGame);
-  const runningInstanceId = useGameStore((state) => state.runningInstanceId);
-  const launchingInstanceId = useGameStore(
-    (state) => state.launchingInstanceId,
-  );
-  const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId);
 
+  const { instances, activeInstance, setActiveInstance } = useInstanceStore();
+  const {
+    runningInstanceId,
+    launchingInstanceId,
+    stoppingInstanceId,
+    startGame,
+    stopGame,
+  } = useGameStore();
+
+  const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
   const [showLoginModal, setShowLoginModal] = useState(false);
 
   useEffect(() => {
@@ -43,7 +39,7 @@ export function BottomBar() {
     }
 
     setSelectedVersion(nextVersion);
-  }, [activeInstance?.versionId, selectedVersion, setSelectedVersion]);
+  }, [activeInstance?.versionId, selectedVersion]);
 
   const handleInstanceChange = useCallback(
     async (instanceId: string) => {
@@ -75,11 +71,8 @@ export function BottomBar() {
     }
 
     await startGame(
-      account,
-      () => setShowLoginModal(true),
       activeInstance.id,
       selectedVersion || activeInstance.versionId,
-      () => undefined,
     );
   };
 
diff --git a/packages/ui/src/models/game.ts b/packages/ui/src/models/game.ts
new file mode 100644
index 0000000..5342078
--- /dev/null
+++ b/packages/ui/src/models/game.ts
@@ -0,0 +1,113 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+  startGame as startGameCommand,
+  stopGame as stopGameCommand,
+} from "@/client";
+import type { GameExitedEvent } from "@/types/bindings/core";
+
+interface GameState {
+  runningInstanceId: string | null;
+  runningVersionId: string | null;
+  launchingInstanceId: string | null;
+  stoppingInstanceId: string | null;
+  lifecycleUnlisten: UnlistenFn | null;
+
+  isGameRunning: boolean;
+  startGame: (instanceId: string, versionId: string) => Promise<string | null>;
+  stopGame: (instanceId?: string | null) => Promise<string | null>;
+}
+
+export const useGameStore = create<GameState>((set, get) => ({
+  runningInstanceId: null,
+  runningVersionId: null,
+  launchingInstanceId: null,
+  stoppingInstanceId: null,
+  lifecycleUnlisten: null,
+
+  get isGameRunning() {
+    return get().runningInstanceId !== null;
+  },
+
+  startGame: async (instanceId, versionId) => {
+    const { isGameRunning, lifecycleUnlisten } = get();
+
+    if (isGameRunning) {
+      toast.info("A game is already running");
+      return null;
+    } else {
+      lifecycleUnlisten?.();
+    }
+
+    set({
+      launchingInstanceId: instanceId,
+    });
+    toast.info(`Preparing to launch ${versionId}...`);
+
+    const unlisten = await listen<GameExitedEvent>("game-exited", (event) => {
+      const { instanceId, versionId, wasStopped, exitCode } = event.payload;
+
+      set({
+        runningInstanceId: null,
+        runningVersionId: null,
+        launchingInstanceId: null,
+        stoppingInstanceId: null,
+      });
+
+      if (wasStopped) {
+        toast.success(
+          `Stopped Minecraft ${versionId} for instance ${instanceId}`,
+        );
+      } else {
+        toast.info(
+          `Minecraft ${versionId} exited with code ${exitCode} for instance ${instanceId}`,
+        );
+      }
+    });
+
+    set({ lifecycleUnlisten: unlisten });
+
+    try {
+      const message = await startGameCommand(instanceId, versionId);
+      set({
+        launchingInstanceId: null,
+        runningInstanceId: instanceId,
+        runningVersionId: versionId,
+      });
+      toast.success(message);
+      return message;
+    } catch (e) {
+      console.error(e);
+      set({ launchingInstanceId: null });
+      toast.error(`Error: ${e}`);
+      return null;
+    }
+  },
+
+  stopGame: async (instanceId) => {
+    const { runningInstanceId } = get();
+
+    if (!runningInstanceId) {
+      toast.info("No running game found");
+      return null;
+    }
+
+    if (instanceId !== runningInstanceId) {
+      toast.info("That instance is not the one currently running");
+      return null;
+    }
+
+    set({ stoppingInstanceId: runningInstanceId });
+
+    try {
+      return await stopGameCommand();
+    } catch (e) {
+      console.error("Failed to stop game:", e);
+      toast.error(`Failed to stop game: ${e}`);
+      return null;
+    } finally {
+      set({ stoppingInstanceId: null });
+    }
+  },
+}));
diff --git a/packages/ui/src/pages/home-view.tsx b/packages/ui/src/pages/home-view.tsx
deleted file mode 100644
index da7238f..0000000
--- a/packages/ui/src/pages/home-view.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useState } from "react";
-import { BottomBar } from "@/components/bottom-bar";
-import { useSaturnEffect } from "@/components/particle-background";
-
-export function HomeView() {
-  const [mouseX, setMouseX] = useState(0);
-  const [mouseY, setMouseY] = useState(0);
-  const saturn = useSaturnEffect();
-
-  const handleMouseMove = (e: React.MouseEvent) => {
-    const x = (e.clientX / window.innerWidth) * 2 - 1;
-    const y = (e.clientY / window.innerHeight) * 2 - 1;
-    setMouseX(x);
-    setMouseY(y);
-
-    // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions
-    saturn?.handleMouseMove(e.clientX);
-  };
-
-  const handleSaturnMouseDown = (e: React.MouseEvent) => {
-    saturn?.handleMouseDown(e.clientX);
-  };
-
-  const handleSaturnMouseUp = () => {
-    saturn?.handleMouseUp();
-  };
-
-  const handleSaturnMouseLeave = () => {
-    // Treat leaving the area as mouse-up for the effect
-    saturn?.handleMouseUp();
-  };
-
-  const handleSaturnTouchStart = (e: React.TouchEvent) => {
-    if (e.touches && e.touches.length === 1) {
-      const clientX = e.touches[0].clientX;
-      saturn?.handleTouchStart(clientX);
-    }
-  };
-
-  const handleSaturnTouchMove = (e: React.TouchEvent) => {
-    if (e.touches && e.touches.length === 1) {
-      const clientX = e.touches[0].clientX;
-      saturn?.handleTouchMove(clientX);
-    }
-  };
-
-  const handleSaturnTouchEnd = () => {
-    saturn?.handleTouchEnd();
-  };
-
-  return (
-    <div className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth">
-      {/* Hero Section (Full Height) - Interactive area */}
-      <div
-        role="tab"
-        className="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
-        onMouseDown={handleSaturnMouseDown}
-        onMouseMove={handleMouseMove}
-        onMouseUp={handleSaturnMouseUp}
-        onMouseLeave={handleSaturnMouseLeave}
-        onTouchStart={handleSaturnTouchStart}
-        onTouchMove={handleSaturnTouchMove}
-        onTouchEnd={handleSaturnTouchEnd}
-        tabIndex={0}
-      >
-        {/* 3D Floating Hero Text */}
-        <div
-          className="transition-transform duration-200 ease-out origin-bottom-left"
-          style={{
-            transform: `perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`,
-          }}
-        >
-          <div className="flex items-center gap-3 mb-6">
-            <div className="h-px w-12 bg-white/50"></div>
-            <span className="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">
-              Launcher Active
-            </span>
-          </div>
-
-          <h1 className="text-8xl font-black tracking-tighter text-white mb-6 leading-none">
-            MINECRAFT
-          </h1>
-
-          <div className="flex items-center gap-4">
-            <div className="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm">
-              Java Edition
-            </div>
-          </div>
-        </div>
-
-        {/* Action Area */}
-        <div className="mt-8 flex gap-4">
-          <div className="text-zinc-500 text-sm font-mono">
-            &gt; Ready to launch session.
-          </div>
-        </div>
-
-        <BottomBar />
-      </div>
-    </div>
-  );
-}
diff --git a/packages/ui/src/pages/home.tsx b/packages/ui/src/pages/home.tsx
new file mode 100644
index 0000000..dc1413d
--- /dev/null
+++ b/packages/ui/src/pages/home.tsx
@@ -0,0 +1,102 @@
+import { useState } from "react";
+import { BottomBar } from "@/components/bottom-bar";
+import { useSaturnEffect } from "@/components/particle-background";
+
+export function HomePage() {
+  const [mouseX, setMouseX] = useState(0);
+  const [mouseY, setMouseY] = useState(0);
+  const saturn = useSaturnEffect();
+
+  const handleMouseMove = (e: React.MouseEvent) => {
+    const x = (e.clientX / window.innerWidth) * 2 - 1;
+    const y = (e.clientY / window.innerHeight) * 2 - 1;
+    setMouseX(x);
+    setMouseY(y);
+
+    // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions
+    saturn?.handleMouseMove(e.clientX);
+  };
+
+  const handleSaturnMouseDown = (e: React.MouseEvent) => {
+    saturn?.handleMouseDown(e.clientX);
+  };
+
+  const handleSaturnMouseUp = () => {
+    saturn?.handleMouseUp();
+  };
+
+  const handleSaturnMouseLeave = () => {
+    // Treat leaving the area as mouse-up for the effect
+    saturn?.handleMouseUp();
+  };
+
+  const handleSaturnTouchStart = (e: React.TouchEvent) => {
+    if (e.touches && e.touches.length === 1) {
+      const clientX = e.touches[0].clientX;
+      saturn?.handleTouchStart(clientX);
+    }
+  };
+
+  const handleSaturnTouchMove = (e: React.TouchEvent) => {
+    if (e.touches && e.touches.length === 1) {
+      const clientX = e.touches[0].clientX;
+      saturn?.handleTouchMove(clientX);
+    }
+  };
+
+  const handleSaturnTouchEnd = () => {
+    saturn?.handleTouchEnd();
+  };
+
+  return (
+    <div className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth">
+      {/* Hero Section (Full Height) - Interactive area */}
+      <div
+        role="tab"
+        className="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
+        onMouseDown={handleSaturnMouseDown}
+        onMouseMove={handleMouseMove}
+        onMouseUp={handleSaturnMouseUp}
+        onMouseLeave={handleSaturnMouseLeave}
+        onTouchStart={handleSaturnTouchStart}
+        onTouchMove={handleSaturnTouchMove}
+        onTouchEnd={handleSaturnTouchEnd}
+        tabIndex={0}
+      >
+        {/* 3D Floating Hero Text */}
+        <div
+          className="transition-transform duration-200 ease-out origin-bottom-left"
+          style={{
+            transform: `perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`,
+          }}
+        >
+          <div className="flex items-center gap-3 mb-6">
+            <div className="h-px w-12 bg-white/50"></div>
+            <span className="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">
+              Launcher Active
+            </span>
+          </div>
+
+          <h1 className="text-8xl font-black tracking-tighter text-white mb-6 leading-none">
+            MINECRAFT
+          </h1>
+
+          <div className="flex items-center gap-4">
+            <div className="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm">
+              Java Edition
+            </div>
+          </div>
+        </div>
+
+        {/* Action Area */}
+        <div className="mt-8 flex gap-4">
+          <div className="text-zinc-500 text-sm font-mono">
+            &gt; Ready to launch session.
+          </div>
+        </div>
+
+        <BottomBar />
+      </div>
+    </div>
+  );
+}
diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx
index 2acd377..d12646b 100644
--- a/packages/ui/src/pages/index.tsx
+++ b/packages/ui/src/pages/index.tsx
@@ -5,13 +5,11 @@ import { Sidebar } from "@/components/sidebar";
 import { useAuthStore } from "@/models/auth";
 import { useInstanceStore } from "@/models/instance";
 import { useSettingsStore } from "@/models/settings";
-import { useGameStore } from "@/stores/game-store";
 
 export function IndexPage() {
   const authStore = useAuthStore();
   const settingsStore = useSettingsStore();
   const instanceStore = useInstanceStore();
-  const initGameLifecycle = useGameStore((state) => state.initLifecycle);
 
   const location = useLocation();
 
@@ -19,15 +17,7 @@ export function IndexPage() {
     authStore.init();
     settingsStore.refresh();
     instanceStore.refresh();
-    void initGameLifecycle().catch((error) => {
-      console.error("Failed to initialize game lifecycle:", error);
-    });
-  }, [
-    authStore.init,
-    settingsStore.refresh,
-    instanceStore.refresh,
-    initGameLifecycle,
-  ]);
+  }, [authStore.init, settingsStore.refresh, instanceStore.refresh]);
 
   return (
     <div className="relative h-screen w-full overflow-hidden bg-background font-sans">
diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx
deleted file mode 100644
index 7bb3302..0000000
--- a/packages/ui/src/pages/instances-view.tsx
+++ /dev/null
@@ -1,457 +0,0 @@
-import { open, save } from "@tauri-apps/plugin-dialog";
-import {
-  CopyIcon,
-  EditIcon,
-  FolderOpenIcon,
-  Plus,
-  RocketIcon,
-  Trash2Icon,
-  XIcon,
-} from "lucide-react";
-import { useEffect, useState } from "react";
-import { toast } from "sonner";
-import { openFileExplorer } from "@/client";
-import InstanceCreationModal from "@/components/instance-creation-modal";
-import InstanceEditorModal from "@/components/instance-editor-modal";
-import { Button } from "@/components/ui/button";
-import {
-  Dialog,
-  DialogContent,
-  DialogDescription,
-  DialogFooter,
-  DialogHeader,
-  DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { cn } from "@/lib/utils";
-import { useAuthStore } from "@/models/auth";
-import { useInstanceStore } from "@/models/instance";
-import { useGameStore } from "@/stores/game-store";
-import type { Instance } from "@/types";
-
-export function InstancesView() {
-  const account = useAuthStore((state) => state.account);
-  const instancesStore = useInstanceStore();
-  const startGame = useGameStore((state) => state.startGame);
-  const stopGame = useGameStore((state) => state.stopGame);
-  const runningInstanceId = useGameStore((state) => state.runningInstanceId);
-  const launchingInstanceId = useGameStore(
-    (state) => state.launchingInstanceId,
-  );
-  const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId);
-  const [isImporting, setIsImporting] = useState(false);
-  const [repairing, setRepairing] = useState(false);
-  const [exportingId, setExportingId] = useState<string | null>(null);
-
-  // Modal / UI state
-  const [showCreateModal, setShowCreateModal] = useState(false);
-  const [showEditModal, setShowEditModal] = useState(false);
-  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
-  const [showDuplicateModal, setShowDuplicateModal] = useState(false);
-
-  // Selected / editing instance state
-  const [selectedInstance, setSelectedInstance] = useState<Instance | null>(
-    null,
-  );
-  const [editingInstance, setEditingInstance] = useState<Instance | null>(null);
-
-  // Form fields
-  const [duplicateName, setDuplicateName] = useState("");
-
-  useEffect(() => {
-    instancesStore.refresh();
-  }, [instancesStore.refresh]);
-
-  // Handlers to open modals
-  const openCreate = () => {
-    setShowCreateModal(true);
-  };
-
-  const openEdit = (instance: Instance) => {
-    setEditingInstance({ ...instance });
-    setShowEditModal(true);
-  };
-
-  const openDelete = (instance: Instance) => {
-    setSelectedInstance(instance);
-    setShowDeleteConfirm(true);
-  };
-
-  const openDuplicate = (instance: Instance) => {
-    setSelectedInstance(instance);
-    setDuplicateName(`${instance.name} (Copy)`);
-    setShowDuplicateModal(true);
-  };
-
-  const confirmDelete = async () => {
-    if (!selectedInstance) return;
-    await instancesStore.delete(selectedInstance.id);
-    setSelectedInstance(null);
-    setShowDeleteConfirm(false);
-  };
-
-  const confirmDuplicate = async () => {
-    if (!selectedInstance) return;
-    const name = duplicateName.trim();
-    if (!name) return;
-    await instancesStore.duplicate(selectedInstance.id, name);
-    setSelectedInstance(null);
-    setDuplicateName("");
-    setShowDuplicateModal(false);
-  };
-
-  const handleImport = async () => {
-    setIsImporting(true);
-    try {
-      const selected = await open({
-        multiple: false,
-        filters: [{ name: "Zip Archive", extensions: ["zip"] }],
-      });
-
-      if (typeof selected !== "string") {
-        return;
-      }
-
-      await instancesStore.importArchive(selected);
-    } finally {
-      setIsImporting(false);
-    }
-  };
-
-  const handleRepair = async () => {
-    setRepairing(true);
-    try {
-      await instancesStore.repair();
-    } finally {
-      setRepairing(false);
-    }
-  };
-
-  const handleExport = async (instance: Instance) => {
-    setExportingId(instance.id);
-    try {
-      const filePath = await save({
-        defaultPath: `${instance.name.replace(/[\\/:*?"<>|]/g, "_")}.zip`,
-        filters: [{ name: "Zip Archive", extensions: ["zip"] }],
-      });
-
-      if (!filePath) {
-        return;
-      }
-
-      await instancesStore.exportArchive(instance.id, filePath);
-    } finally {
-      setExportingId(null);
-    }
-  };
-
-  return (
-    <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto">
-      <div className="flex items-center justify-between">
-        <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
-          Instances
-        </h1>
-        <div className="flex items-center gap-2">
-          <Button
-            type="button"
-            variant="outline"
-            onClick={handleImport}
-            disabled={isImporting}
-          >
-            {isImporting ? "Importing..." : "Import"}
-          </Button>
-          <Button
-            type="button"
-            variant="outline"
-            onClick={handleRepair}
-            disabled={repairing}
-          >
-            {repairing ? "Repairing..." : "Repair Index"}
-          </Button>
-          <Button
-            type="button"
-            onClick={openCreate}
-            className="px-4 py-2 transition-colors"
-          >
-            <Plus size={18} />
-            Create Instance
-          </Button>
-        </div>
-      </div>
-
-      {instancesStore.instances.length === 0 ? (
-        <div className="flex-1 flex items-center justify-center">
-          <div className="text-center text-gray-500 dark:text-gray-400">
-            <p className="text-lg mb-2">No instances yet</p>
-            <p className="text-sm">Create your first instance to get started</p>
-          </div>
-        </div>
-      ) : (
-        <ul className="flex flex-col space-y-3">
-          {instancesStore.instances.map((instance) => {
-            const isActive = instancesStore.activeInstance?.id === instance.id;
-            const isRunning = runningInstanceId === instance.id;
-            const isLaunching = launchingInstanceId === instance.id;
-            const isStopping = stoppingInstanceId === instance.id;
-            const otherInstanceRunning =
-              runningInstanceId !== null && !isRunning;
-
-            return (
-              <li
-                key={instance.id}
-                onClick={() => instancesStore.setActiveInstance(instance)}
-                onKeyDown={async (e) => {
-                  if (e.key === "Enter") {
-                    try {
-                      await instancesStore.setActiveInstance(instance);
-                    } catch (e) {
-                      console.error("Failed to set active instance:", e);
-                      toast.error("Error setting active instance");
-                    }
-                  }
-                }}
-                className="cursor-pointer"
-              >
-                <div
-                  className={cn(
-                    "flex flex-row space-x-3 p-3 justify-between",
-                    "border bg-card/5 backdrop-blur-xl",
-                    "hover:bg-accent/50 transition-colors",
-                    isActive && "border-primary",
-                  )}
-                >
-                  <div className="flex flex-row space-x-4">
-                    {instance.iconPath ? (
-                      <div className="w-12 h-12 rounded overflow-hidden">
-                        <img
-                          src={instance.iconPath}
-                          alt={instance.name}
-                          className="w-full h-full object-cover"
-                        />
-                      </div>
-                    ) : (
-                      <div className="w-12 h-12 rounded bg-linear-to-br from-blue-500 to-purple-600 flex items-center justify-center">
-                        <span className="text-white font-bold text-lg">
-                          {instance.name.charAt(0).toUpperCase()}
-                        </span>
-                      </div>
-                    )}
-
-                    <div className="flex flex-col">
-                      <h3 className="text-lg font-semibold">{instance.name}</h3>
-                      {instance.versionId ? (
-                        <p className="text-sm text-muted-foreground">
-                          {instance.versionId}
-                        </p>
-                      ) : (
-                        <p className="text-sm text-muted-foreground">
-                          No version selected
-                        </p>
-                      )}
-                    </div>
-                  </div>
-
-                  <div className="flex items-center">
-                    <div className="flex flex-row space-x-2">
-                      <Button
-                        variant={isRunning ? "destructive" : "ghost"}
-                        size="icon"
-                        onClick={async (e) => {
-                          e.stopPropagation();
-
-                          try {
-                            await instancesStore.setActiveInstance(instance);
-                          } catch (error) {
-                            console.error(
-                              "Failed to set active instance:",
-                              error,
-                            );
-                            toast.error("Error setting active instance");
-                            return;
-                          }
-
-                          if (isRunning) {
-                            await stopGame(instance.id);
-                            return;
-                          }
-
-                          if (!instance.versionId) {
-                            toast.error("No version selected or installed");
-                            return;
-                          }
-
-                          await startGame(
-                            account,
-                            () => {
-                              toast.info("Please login first");
-                            },
-                            instance.id,
-                            instance.versionId,
-                            () => undefined,
-                          );
-                        }}
-                        disabled={
-                          otherInstanceRunning || isLaunching || isStopping
-                        }
-                      >
-                        {isLaunching || isStopping ? (
-                          <span className="text-xs">...</span>
-                        ) : isRunning ? (
-                          <XIcon />
-                        ) : (
-                          <RocketIcon />
-                        )}
-                      </Button>
-                      <Button
-                        variant="ghost"
-                        size="icon"
-                        onClick={(e) => {
-                          e.stopPropagation();
-                          void openFileExplorer(instance.gameDir);
-                        }}
-                      >
-                        <FolderOpenIcon />
-                      </Button>
-                      <Button
-                        variant="ghost"
-                        size="icon"
-                        onClick={(e) => {
-                          e.stopPropagation();
-                          void handleExport(instance);
-                        }}
-                        disabled={exportingId === instance.id}
-                      >
-                        <span className="text-xs">
-                          {exportingId === instance.id ? "..." : "ZIP"}
-                        </span>
-                      </Button>
-                      <Button
-                        variant="ghost"
-                        size="icon"
-                        onClick={(e) => {
-                          e.stopPropagation();
-                          openDuplicate(instance);
-                        }}
-                      >
-                        <CopyIcon />
-                      </Button>
-                      <Button
-                        variant="ghost"
-                        size="icon"
-                        onClick={(e) => {
-                          e.stopPropagation();
-                          openEdit(instance);
-                        }}
-                      >
-                        <EditIcon />
-                      </Button>
-                      <Button
-                        variant="destructive"
-                        size="icon"
-                        onClick={(e) => {
-                          e.stopPropagation();
-                          openDelete(instance);
-                        }}
-                      >
-                        <Trash2Icon />
-                      </Button>
-                    </div>
-                  </div>
-                </div>
-              </li>
-            );
-          })}
-        </ul>
-      )}
-
-      <InstanceCreationModal
-        open={showCreateModal}
-        onOpenChange={setShowCreateModal}
-      />
-
-      <InstanceEditorModal
-        open={showEditModal}
-        instance={editingInstance}
-        onOpenChange={(open) => {
-          setShowEditModal(open);
-          if (!open) setEditingInstance(null);
-        }}
-      />
-
-      {/* Delete Confirmation */}
-      <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
-        <DialogContent>
-          <DialogHeader>
-            <DialogTitle>Delete Instance</DialogTitle>
-            <DialogDescription>
-              Are you sure you want to delete "{selectedInstance?.name}"? This
-              action cannot be undone.
-            </DialogDescription>
-          </DialogHeader>
-
-          <DialogFooter>
-            <Button
-              type="button"
-              variant="outline"
-              onClick={() => {
-                setShowDeleteConfirm(false);
-                setSelectedInstance(null);
-              }}
-            >
-              Cancel
-            </Button>
-            <Button
-              type="button"
-              onClick={confirmDelete}
-              className="bg-red-600 text-white hover:bg-red-500"
-            >
-              Delete
-            </Button>
-          </DialogFooter>
-        </DialogContent>
-      </Dialog>
-
-      {/* Duplicate Modal */}
-      <Dialog open={showDuplicateModal} onOpenChange={setShowDuplicateModal}>
-        <DialogContent>
-          <DialogHeader>
-            <DialogTitle>Duplicate Instance</DialogTitle>
-            <DialogDescription>
-              Provide a name for the duplicated instance.
-            </DialogDescription>
-          </DialogHeader>
-
-          <div className="mt-4">
-            <Input
-              value={duplicateName}
-              onChange={(e) => setDuplicateName(e.target.value)}
-              placeholder="New instance name"
-              onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()}
-            />
-          </div>
-
-          <DialogFooter>
-            <Button
-              type="button"
-              variant="outline"
-              onClick={() => {
-                setShowDuplicateModal(false);
-                setSelectedInstance(null);
-                setDuplicateName("");
-              }}
-            >
-              Cancel
-            </Button>
-            <Button
-              type="button"
-              onClick={confirmDuplicate}
-              disabled={!duplicateName.trim()}
-            >
-              Duplicate
-            </Button>
-          </DialogFooter>
-        </DialogContent>
-      </Dialog>
-    </div>
-  );
-}
diff --git a/packages/ui/src/pages/routes.ts b/packages/ui/src/pages/routes.ts
index 8d105d4..55eb8fd 100644
--- a/packages/ui/src/pages/routes.ts
+++ b/packages/ui/src/pages/routes.ts
@@ -1,6 +1,6 @@
 import { createHashRouter } from "react-router";
-import { IndexPage } from ".";
-import { HomeView } from "./home-view";
+import { HomePage } from "./home";
+import { IndexPage } from "./index";
 import instanceRoute from "./instances/routes";
 import { SettingsPage } from "./settings";
 
@@ -11,7 +11,7 @@ const router = createHashRouter([
     children: [
       {
         index: true,
-        Component: HomeView,
+        Component: HomePage,
       },
       {
         path: "settings",
diff --git a/packages/ui/src/stores/game-store.ts b/packages/ui/src/stores/game-store.ts
deleted file mode 100644
index 7b6e746..0000000
--- a/packages/ui/src/stores/game-store.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import { toast } from "sonner";
-import { create } from "zustand";
-import {
-  getVersions,
-  getVersionsOfInstance,
-  startGame as startGameCommand,
-  stopGame as stopGameCommand,
-} from "@/client";
-import type { Account } from "@/types/bindings/auth";
-import type { GameExitedEvent } from "@/types/bindings/core";
-import type { Version } from "@/types/bindings/manifest";
-
-interface GameState {
-  versions: Version[];
-  selectedVersion: string;
-  runningInstanceId: string | null;
-  runningVersionId: string | null;
-  launchingInstanceId: string | null;
-  stoppingInstanceId: string | null;
-  lifecycleUnlisten: UnlistenFn | null;
-
-  latestRelease: Version | undefined;
-  isGameRunning: boolean;
-
-  initLifecycle: () => Promise<void>;
-  loadVersions: (instanceId?: string) => Promise<void>;
-  startGame: (
-    currentAccount: Account | null,
-    openLoginModal: () => void,
-    activeInstanceId: string | null,
-    versionId: string | null,
-    setView: (view: string) => void,
-  ) => Promise<string | null>;
-  stopGame: (instanceId?: string | null) => Promise<string | null>;
-  setSelectedVersion: (version: string) => void;
-  setVersions: (versions: Version[]) => void;
-}
-
-export const useGameStore = create<GameState>((set, get) => ({
-  versions: [],
-  selectedVersion: "",
-  runningInstanceId: null,
-  runningVersionId: null,
-  launchingInstanceId: null,
-  stoppingInstanceId: null,
-  lifecycleUnlisten: null,
-
-  get latestRelease() {
-    return get().versions.find((v) => v.type === "release");
-  },
-
-  get isGameRunning() {
-    return get().runningInstanceId !== null;
-  },
-
-  initLifecycle: async () => {
-    if (get().lifecycleUnlisten) {
-      return;
-    }
-
-    const unlisten = await listen<GameExitedEvent>("game-exited", (event) => {
-      const { instanceId, versionId, wasStopped } = event.payload;
-
-      set({
-        runningInstanceId: null,
-        runningVersionId: null,
-        launchingInstanceId: null,
-        stoppingInstanceId: null,
-      });
-
-      if (wasStopped) {
-        toast.success(
-          `Stopped Minecraft ${versionId} for instance ${instanceId}`,
-        );
-      } else {
-        toast.info(`Minecraft ${versionId} exited for instance ${instanceId}`);
-      }
-    });
-
-    set({ lifecycleUnlisten: unlisten });
-  },
-
-  loadVersions: async (instanceId?: string) => {
-    try {
-      const versions = instanceId
-        ? await getVersionsOfInstance(instanceId)
-        : await getVersions();
-      set({ versions: versions ?? [] });
-    } catch (e) {
-      console.error("Failed to load versions:", e);
-      set({ versions: [] });
-    }
-  },
-
-  startGame: async (
-    currentAccount,
-    openLoginModal,
-    activeInstanceId,
-    versionId,
-    setView,
-  ) => {
-    const { isGameRunning } = get();
-    const targetVersion = versionId ?? get().selectedVersion;
-
-    if (!currentAccount) {
-      toast.info("Please login first");
-      openLoginModal();
-      return null;
-    }
-
-    if (!targetVersion) {
-      toast.info("Please select a version first");
-      return null;
-    }
-
-    if (!activeInstanceId) {
-      toast.info("Please select an instance first");
-      setView("instances");
-      return null;
-    }
-
-    if (isGameRunning) {
-      toast.info("A game is already running");
-      return null;
-    }
-
-    set({
-      launchingInstanceId: activeInstanceId,
-      selectedVersion: targetVersion,
-    });
-    toast.info(`Preparing to launch ${targetVersion}...`);
-
-    try {
-      const message = await startGameCommand(activeInstanceId, targetVersion);
-      set({
-        launchingInstanceId: null,
-        runningInstanceId: activeInstanceId,
-        runningVersionId: targetVersion,
-      });
-      toast.success(message);
-      return message;
-    } catch (e) {
-      console.error(e);
-      set({ launchingInstanceId: null });
-      toast.error(`Error: ${e}`);
-      return null;
-    }
-  },
-
-  stopGame: async (instanceId) => {
-    const { runningInstanceId } = get();
-
-    if (!runningInstanceId) {
-      toast.info("No running game found");
-      return null;
-    }
-
-    if (instanceId && instanceId !== runningInstanceId) {
-      toast.info("That instance is not the one currently running");
-      return null;
-    }
-
-    set({ stoppingInstanceId: runningInstanceId });
-
-    try {
-      return await stopGameCommand();
-    } catch (e) {
-      console.error("Failed to stop game:", e);
-      toast.error(`Failed to stop game: ${e}`);
-      return null;
-    } finally {
-      set({ stoppingInstanceId: null });
-    }
-  },
-
-  setSelectedVersion: (version: string) => {
-    set({ selectedVersion: version });
-  },
-
-  setVersions: (versions: Version[]) => {
-    set({ versions });
-  },
-}));
-- 
cgit v1.2.3-70-g09d2


From 5b799a125a970e5e56f29a08b3c86450855fb6c4 Mon Sep 17 00:00:00 2001
From: 苏向夜 <fu050409@163.com>
Date: Sun, 29 Mar 2026 21:32:54 +0800
Subject: refactor(ui): rewrite instance create

---
 .changes/rewrite-instance-create.md                |   5 +
 packages/ui/src/components/bottom-bar.tsx          |  17 +-
 .../ui/src/components/instance-creation-modal.tsx  | 544 ---------------
 packages/ui/src/components/ui/accordion.tsx        |  77 +++
 packages/ui/src/components/ui/button.tsx           |   2 +-
 packages/ui/src/components/ui/field.tsx            |  12 +-
 packages/ui/src/models/instance.ts                 |  18 +-
 packages/ui/src/pages/instances/create.tsx         | 746 +++++++++++++++++++++
 packages/ui/src/pages/instances/index.tsx          | 462 +++++++++++++
 packages/ui/src/pages/instances/routes.ts          |  19 +
 10 files changed, 1328 insertions(+), 574 deletions(-)
 create mode 100644 .changes/rewrite-instance-create.md
 delete mode 100644 packages/ui/src/components/instance-creation-modal.tsx
 create mode 100644 packages/ui/src/components/ui/accordion.tsx
 create mode 100644 packages/ui/src/pages/instances/create.tsx
 create mode 100644 packages/ui/src/pages/instances/index.tsx
 create mode 100644 packages/ui/src/pages/instances/routes.ts

(limited to 'packages/ui/src/components/bottom-bar.tsx')

diff --git a/.changes/rewrite-instance-create.md b/.changes/rewrite-instance-create.md
new file mode 100644
index 0000000..5443389
--- /dev/null
+++ b/.changes/rewrite-instance-create.md
@@ -0,0 +1,5 @@
+---
+"@dropout/ui": "patch:refactor"
+---
+
+Full rewrite instance create with stepper page instead of modal.
diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx
index fd4a681..f73ace4 100644
--- a/packages/ui/src/components/bottom-bar.tsx
+++ b/packages/ui/src/components/bottom-bar.tsx
@@ -1,5 +1,5 @@
 import { Play, User, XIcon } from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useState } from "react";
 import { toast } from "sonner";
 import { cn } from "@/lib/utils";
 import { useAuthStore } from "@/models/auth";
@@ -29,18 +29,8 @@ export function BottomBar() {
     stopGame,
   } = useGameStore();
 
-  const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
   const [showLoginModal, setShowLoginModal] = useState(false);
 
-  useEffect(() => {
-    const nextVersion = activeInstance?.versionId ?? "";
-    if (selectedVersion === nextVersion) {
-      return;
-    }
-
-    setSelectedVersion(nextVersion);
-  }, [activeInstance?.versionId, selectedVersion]);
-
   const handleInstanceChange = useCallback(
     async (instanceId: string) => {
       if (activeInstance?.id === instanceId) {
@@ -70,10 +60,7 @@ export function BottomBar() {
       return;
     }
 
-    await startGame(
-      activeInstance.id,
-      selectedVersion || activeInstance.versionId,
-    );
+    await startGame(activeInstance.id, activeInstance.versionId ?? "");
   };
 
   const handleStopGame = async () => {
diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx
deleted file mode 100644
index 7c46d0f..0000000
--- a/packages/ui/src/components/instance-creation-modal.tsx
+++ /dev/null
@@ -1,544 +0,0 @@
-import { Loader2, Search } from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { toast } from "sonner";
-import {
-  getFabricLoadersForVersion,
-  getForgeVersionsForGame,
-  installFabric,
-  installForge,
-  installVersion,
-} from "@/client";
-import { Button } from "@/components/ui/button";
-import {
-  Dialog,
-  DialogContent,
-  DialogDescription,
-  DialogFooter,
-  DialogHeader,
-  DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { useInstanceStore } from "@/models/instance";
-import { useGameStore } from "@/stores/game-store";
-import type {
-  FabricLoaderEntry,
-  ForgeVersion as ForgeVersionEntry,
-  Version,
-} from "@/types";
-
-interface Props {
-  open: boolean;
-  onOpenChange: (open: boolean) => void;
-}
-
-export function InstanceCreationModal({ open, onOpenChange }: Props) {
-  const gameStore = useGameStore();
-  const instancesStore = useInstanceStore();
-
-  // Steps: 1 = name, 2 = version, 3 = mod loader
-  const [step, setStep] = useState<number>(1);
-
-  // Step 1
-  const [instanceName, setInstanceName] = useState<string>("");
-
-  // Step 2
-  const [versionSearch, setVersionSearch] = useState<string>("");
-  const [versionFilter, setVersionFilter] = useState<
-    "all" | "release" | "snapshot"
-  >("release");
-  const [selectedVersionUI, setSelectedVersionUI] = useState<Version | null>(
-    null,
-  );
-
-  // Step 3
-  const [modLoaderType, setModLoaderType] = useState<
-    "vanilla" | "fabric" | "forge"
-  >("vanilla");
-  const [fabricLoaders, setFabricLoaders] = useState<FabricLoaderEntry[]>([]);
-  const [forgeVersions, setForgeVersions] = useState<ForgeVersionEntry[]>([]);
-  const [selectedFabricLoader, setSelectedFabricLoader] = useState<string>("");
-  const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>("");
-  const [loadingLoaders, setLoadingLoaders] = useState(false);
-
-  const loadModLoaders = useCallback(async () => {
-    if (!selectedVersionUI) return;
-    setLoadingLoaders(true);
-    setFabricLoaders([]);
-    setForgeVersions([]);
-    try {
-      if (modLoaderType === "fabric") {
-        const loaders = await getFabricLoadersForVersion(selectedVersionUI.id);
-        setFabricLoaders(loaders || []);
-        if (loaders && loaders.length > 0) {
-          setSelectedFabricLoader(loaders[0].loader.version);
-        } else {
-          setSelectedFabricLoader("");
-        }
-      } else if (modLoaderType === "forge") {
-        const versions = await getForgeVersionsForGame(selectedVersionUI.id);
-        setForgeVersions(versions || []);
-        if (versions && versions.length > 0) {
-          // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here.
-          setSelectedForgeLoader(versions[0].version);
-        } else {
-          setSelectedForgeLoader("");
-        }
-      }
-    } catch (e) {
-      console.error("Failed to load mod loaders:", e);
-      toast.error("Failed to fetch mod loader versions");
-    } finally {
-      setLoadingLoaders(false);
-    }
-  }, [modLoaderType, selectedVersionUI]);
-
-  // When entering step 3 and a base version exists, fetch loaders if needed
-  useEffect(() => {
-    if (step === 3 && modLoaderType !== "vanilla" && selectedVersionUI) {
-      loadModLoaders();
-    }
-  }, [step, modLoaderType, selectedVersionUI, loadModLoaders]);
-
-  // Creating state
-  const [creating, setCreating] = useState(false);
-  const [errorMessage, setErrorMessage] = useState<string>("");
-
-  // Derived filtered versions
-  const filteredVersions = useMemo(() => {
-    const all = gameStore.versions || [];
-    let list = all.slice();
-    if (versionFilter !== "all") {
-      list = list.filter((v) => v.type === versionFilter);
-    }
-    if (versionSearch.trim()) {
-      const q = versionSearch.trim().toLowerCase().replace(/。/g, ".");
-      list = list.filter((v) => v.id.toLowerCase().includes(q));
-    }
-    return list;
-  }, [gameStore.versions, versionFilter, versionSearch]);
-
-  // Reset when opened/closed
-  useEffect(() => {
-    if (open) {
-      // ensure versions are loaded
-      gameStore.loadVersions();
-      setStep(1);
-      setInstanceName("");
-      setVersionSearch("");
-      setVersionFilter("release");
-      setSelectedVersionUI(null);
-      setModLoaderType("vanilla");
-      setFabricLoaders([]);
-      setForgeVersions([]);
-      setSelectedFabricLoader("");
-      setSelectedForgeLoader("");
-      setErrorMessage("");
-      setCreating(false);
-    }
-  }, [open, gameStore.loadVersions]);
-
-  function validateStep1(): boolean {
-    if (!instanceName.trim()) {
-      setErrorMessage("Please enter an instance name");
-      return false;
-    }
-    setErrorMessage("");
-    return true;
-  }
-
-  function validateStep2(): boolean {
-    if (!selectedVersionUI) {
-      setErrorMessage("Please select a Minecraft version");
-      return false;
-    }
-    setErrorMessage("");
-    return true;
-  }
-
-  async function handleNext() {
-    setErrorMessage("");
-    if (step === 1) {
-      if (!validateStep1()) return;
-      setStep(2);
-    } else if (step === 2) {
-      if (!validateStep2()) return;
-      setStep(3);
-    }
-  }
-
-  function handleBack() {
-    setErrorMessage("");
-    setStep((s) => Math.max(1, s - 1));
-  }
-
-  async function handleCreate() {
-    if (!validateStep1() || !validateStep2()) return;
-    setCreating(true);
-    setErrorMessage("");
-
-    try {
-      // Step 1: create instance
-      const instance = await instancesStore.create(instanceName.trim());
-
-      // If selectedVersion provided, install it
-      if (selectedVersionUI && instance) {
-        try {
-          await installVersion(instance?.id, selectedVersionUI.id);
-        } catch (err) {
-          console.error("Failed to install base version:", err);
-          // continue - instance created but version install failed
-          toast.error(
-            `Failed to install version ${selectedVersionUI.id}: ${String(err)}`,
-          );
-        }
-      }
-
-      // If mod loader selected, install it
-      if (modLoaderType === "fabric" && selectedFabricLoader && instance) {
-        try {
-          await installFabric(
-            instance?.id,
-            selectedVersionUI?.id ?? "",
-            selectedFabricLoader,
-          );
-        } catch (err) {
-          console.error("Failed to install Fabric:", err);
-          toast.error(`Failed to install Fabric: ${String(err)}`);
-        }
-      } else if (modLoaderType === "forge" && selectedForgeLoader && instance) {
-        try {
-          await installForge(
-            instance?.id,
-            selectedVersionUI?.id ?? "",
-            selectedForgeLoader,
-          );
-        } catch (err) {
-          console.error("Failed to install Forge:", err);
-          toast.error(`Failed to install Forge: ${String(err)}`);
-        }
-      }
-
-      // Refresh instances list
-      await instancesStore.refresh();
-
-      toast.success("Instance created successfully");
-      onOpenChange(false);
-    } catch (e) {
-      console.error("Failed to create instance:", e);
-      setErrorMessage(String(e));
-      toast.error(`Failed to create instance: ${e}`);
-    } finally {
-      setCreating(false);
-    }
-  }
-
-  // UI pieces
-  const StepIndicator = () => (
-    <div className="flex gap-2 w-full">
-      <div
-        className={`flex-1 h-1 rounded ${step >= 1 ? "bg-indigo-500" : "bg-zinc-700"}`}
-      />
-      <div
-        className={`flex-1 h-1 rounded ${step >= 2 ? "bg-indigo-500" : "bg-zinc-700"}`}
-      />
-      <div
-        className={`flex-1 h-1 rounded ${step >= 3 ? "bg-indigo-500" : "bg-zinc-700"}`}
-      />
-    </div>
-  );
-
-  return (
-    <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-hidden">
-        <DialogHeader>
-          <DialogTitle>Create New Instance</DialogTitle>
-          <DialogDescription>
-            Multi-step wizard — create an instance and optionally install a
-            version or mod loader.
-          </DialogDescription>
-        </DialogHeader>
-
-        <div className="px-6">
-          <div className="pt-4 pb-6">
-            <StepIndicator />
-          </div>
-
-          {/* Step 1 - Name */}
-          {step === 1 && (
-            <div className="space-y-4">
-              <div>
-                <label
-                  htmlFor="instance-name"
-                  className="block text-sm font-medium mb-2"
-                >
-                  Instance Name
-                </label>
-                <Input
-                  id="instance-name"
-                  placeholder="My Minecraft Instance"
-                  value={instanceName}
-                  onChange={(e) => setInstanceName(e.target.value)}
-                  disabled={creating}
-                />
-              </div>
-              <p className="text-xs text-muted-foreground">
-                Give your instance a memorable name.
-              </p>
-            </div>
-          )}
-
-          {/* Step 2 - Version selection */}
-          {step === 2 && (
-            <div className="space-y-4">
-              <div className="flex gap-3">
-                <div className="relative flex-1">
-                  <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
-                  <Input
-                    value={versionSearch}
-                    onChange={(e) => setVersionSearch(e.target.value)}
-                    placeholder="Search versions..."
-                    className="pl-9"
-                  />
-                </div>
-
-                <div className="flex gap-2">
-                  <Button
-                    type="button"
-                    variant={versionFilter === "all" ? "default" : "outline"}
-                    onClick={() => setVersionFilter("all")}
-                  >
-                    All
-                  </Button>
-                  <Button
-                    type="button"
-                    variant={
-                      versionFilter === "release" ? "default" : "outline"
-                    }
-                    onClick={() => setVersionFilter("release")}
-                  >
-                    Release
-                  </Button>
-                  <Button
-                    type="button"
-                    variant={
-                      versionFilter === "snapshot" ? "default" : "outline"
-                    }
-                    onClick={() => setVersionFilter("snapshot")}
-                  >
-                    Snapshot
-                  </Button>
-                </div>
-              </div>
-
-              <ScrollArea className="max-h-[36vh]">
-                <div className="space-y-2 py-2">
-                  {gameStore.versions.length === 0 ? (
-                    <div className="flex items-center justify-center py-8 text-muted-foreground">
-                      <Loader2 className="animate-spin mr-2" />
-                      Loading versions...
-                    </div>
-                  ) : filteredVersions.length === 0 ? (
-                    <div className="text-center py-8 text-muted-foreground">
-                      No matching versions found
-                    </div>
-                  ) : (
-                    filteredVersions.map((v) => {
-                      const isSelected = selectedVersionUI?.id === v.id;
-                      return (
-                        <button
-                          key={v.id}
-                          type="button"
-                          onClick={() => setSelectedVersionUI(v)}
-                          className={`w-full text-left p-3 rounded-lg border transition-colors ${
-                            isSelected
-                              ? "bg-indigo-50 dark:bg-indigo-600/20 border-indigo-200"
-                              : "bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60"
-                          }`}
-                        >
-                          <div className="flex items-center justify-between">
-                            <div>
-                              <div className="font-mono font-bold">{v.id}</div>
-                              <div className="text-xs text-muted-foreground mt-1">
-                                {v.type}{" "}
-                                {v.releaseTime
-                                  ? ` • ${new Date(v.releaseTime).toLocaleDateString()}`
-                                  : ""}
-                              </div>
-                            </div>
-                            {v.javaVersion && (
-                              <div className="text-sm">
-                                Java {v.javaVersion}
-                              </div>
-                            )}
-                          </div>
-                        </button>
-                      );
-                    })
-                  )}
-                </div>
-              </ScrollArea>
-            </div>
-          )}
-
-          {/* Step 3 - Mod loader */}
-          {step === 3 && (
-            <div className="space-y-4">
-              <div>
-                <div className="text-sm font-medium mb-2">Mod Loader Type</div>
-                <div className="flex gap-3">
-                  <Button
-                    type="button"
-                    variant={
-                      modLoaderType === "vanilla" ? "default" : "outline"
-                    }
-                    onClick={() => setModLoaderType("vanilla")}
-                  >
-                    Vanilla
-                  </Button>
-                  <Button
-                    type="button"
-                    variant={modLoaderType === "fabric" ? "default" : "outline"}
-                    onClick={() => setModLoaderType("fabric")}
-                  >
-                    Fabric
-                  </Button>
-                  <Button
-                    type="button"
-                    variant={modLoaderType === "forge" ? "default" : "outline"}
-                    onClick={() => setModLoaderType("forge")}
-                  >
-                    Forge
-                  </Button>
-                </div>
-              </div>
-
-              {modLoaderType === "fabric" && (
-                <div>
-                  {loadingLoaders ? (
-                    <div className="flex items-center gap-2">
-                      <Loader2 className="animate-spin" />
-                      Loading Fabric versions...
-                    </div>
-                  ) : fabricLoaders.length > 0 ? (
-                    <div className="space-y-2">
-                      <select
-                        value={selectedFabricLoader}
-                        onChange={(e) =>
-                          setSelectedFabricLoader(e.target.value)
-                        }
-                        className="w-full px-3 py-2 rounded border bg-transparent"
-                      >
-                        {fabricLoaders.map((f) => (
-                          <option
-                            key={f.loader.version}
-                            value={f.loader.version}
-                          >
-                            {f.loader.version}{" "}
-                            {f.loader.stable ? "(Stable)" : "(Beta)"}
-                          </option>
-                        ))}
-                      </select>
-                    </div>
-                  ) : (
-                    <p className="text-sm text-muted-foreground">
-                      No Fabric loaders available for this version
-                    </p>
-                  )}
-                </div>
-              )}
-
-              {modLoaderType === "forge" && (
-                <div>
-                  {loadingLoaders ? (
-                    <div className="flex items-center gap-2">
-                      <Loader2 className="animate-spin" />
-                      Loading Forge versions...
-                    </div>
-                  ) : forgeVersions.length > 0 ? (
-                    <div className="space-y-2">
-                      <select
-                        value={selectedForgeLoader}
-                        onChange={(e) => setSelectedForgeLoader(e.target.value)}
-                        className="w-full px-3 py-2 rounded border bg-transparent"
-                      >
-                        {forgeVersions.map((f) => (
-                          // binding ForgeVersion uses `version` as the identifier
-                          <option key={f.version} value={f.version}>
-                            {f.version}
-                          </option>
-                        ))}
-                      </select>
-                    </div>
-                  ) : (
-                    <p className="text-sm text-muted-foreground">
-                      No Forge versions available for this version
-                    </p>
-                  )}
-                </div>
-              )}
-            </div>
-          )}
-
-          {errorMessage && (
-            <div className="text-sm text-red-400 mt-3">{errorMessage}</div>
-          )}
-        </div>
-
-        <DialogFooter>
-          <div className="w-full flex justify-between items-center">
-            <div>
-              <Button
-                type="button"
-                variant="ghost"
-                onClick={() => {
-                  // cancel
-                  onOpenChange(false);
-                }}
-                disabled={creating}
-              >
-                Cancel
-              </Button>
-            </div>
-
-            <div className="flex gap-2">
-              {step > 1 && (
-                <Button
-                  type="button"
-                  variant="outline"
-                  onClick={handleBack}
-                  disabled={creating}
-                >
-                  Back
-                </Button>
-              )}
-
-              {step < 3 ? (
-                <Button type="button" onClick={handleNext} disabled={creating}>
-                  Next
-                </Button>
-              ) : (
-                <Button
-                  type="button"
-                  onClick={handleCreate}
-                  disabled={creating}
-                >
-                  {creating ? (
-                    <>
-                      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-                      Creating...
-                    </>
-                  ) : (
-                    "Create"
-                  )}
-                </Button>
-              )}
-            </div>
-          </div>
-        </DialogFooter>
-      </DialogContent>
-    </Dialog>
-  );
-}
-
-export default InstanceCreationModal;
diff --git a/packages/ui/src/components/ui/accordion.tsx b/packages/ui/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..02ba45c
--- /dev/null
+++ b/packages/ui/src/components/ui/accordion.tsx
@@ -0,0 +1,77 @@
+import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
+import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
+  return (
+    <AccordionPrimitive.Root
+      data-slot="accordion"
+      className={cn("flex w-full flex-col", className)}
+      {...props}
+    />
+  );
+}
+
+function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
+  return (
+    <AccordionPrimitive.Item
+      data-slot="accordion-item"
+      className={cn("not-last:border-b", className)}
+      {...props}
+    />
+  );
+}
+
+function AccordionTrigger({
+  className,
+  children,
+  ...props
+}: AccordionPrimitive.Trigger.Props) {
+  return (
+    <AccordionPrimitive.Header className="flex">
+      <AccordionPrimitive.Trigger
+        data-slot="accordion-trigger"
+        className={cn(
+          "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-none border border-transparent py-2.5 text-left text-xs font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
+          className,
+        )}
+        {...props}
+      >
+        {children}
+        <ChevronDownIcon
+          data-slot="accordion-trigger-icon"
+          className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
+        />
+        <ChevronUpIcon
+          data-slot="accordion-trigger-icon"
+          className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
+        />
+      </AccordionPrimitive.Trigger>
+    </AccordionPrimitive.Header>
+  );
+}
+
+function AccordionContent({
+  className,
+  children,
+  ...props
+}: AccordionPrimitive.Panel.Props) {
+  return (
+    <AccordionPrimitive.Panel
+      data-slot="accordion-content"
+      className="overflow-hidden text-xs data-open:animate-accordion-down data-closed:animate-accordion-up"
+      {...props}
+    >
+      <div
+        className={cn(
+          "h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
+          className,
+        )}
+      >
+        {children}
+      </div>
+    </AccordionPrimitive.Panel>
+  );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx
index 7dee494..60ad9ca 100644
--- a/packages/ui/src/components/ui/button.tsx
+++ b/packages/ui/src/components/ui/button.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
 import { cn } from "@/lib/utils";
 
 const buttonVariants = cva(
-  "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
+  "cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
   {
     variants: {
       variant: {
diff --git a/packages/ui/src/components/ui/field.tsx b/packages/ui/src/components/ui/field.tsx
index 10bd9c9..d6937c4 100644
--- a/packages/ui/src/components/ui/field.tsx
+++ b/packages/ui/src/components/ui/field.tsx
@@ -98,8 +98,12 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
 
 function FieldLabel({
   className,
+  required,
+  children,
   ...props
-}: React.ComponentProps<typeof Label>) {
+}: React.ComponentProps<typeof Label> & {
+  required?: boolean;
+}) {
   return (
     <Label
       data-slot="field-label"
@@ -108,8 +112,12 @@ function FieldLabel({
         "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
         className,
       )}
+      aria-required={!!required}
       {...props}
-    />
+    >
+      {children}
+      {required && <span className="text-red-700 dark:text-red-500">*</span>}
+    </Label>
   );
 }
 
diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts
index 2f338b5..8c108c1 100644
--- a/packages/ui/src/models/instance.ts
+++ b/packages/ui/src/models/instance.ts
@@ -20,7 +20,7 @@ interface InstanceState {
   activeInstance: Instance | null;
 
   refresh: () => Promise<void>;
-  create: (name: string) => Promise<Instance | null>;
+  create: (name: string) => Promise<Instance>;
   delete: (id: string) => Promise<void>;
   update: (instance: Instance) => Promise<void>;
   setActiveInstance: (instance: Instance) => Promise<void>;
@@ -64,17 +64,11 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
 
   create: async (name) => {
     const { refresh } = get();
-    try {
-      const instance = await createInstance(name);
-      await setActiveInstanceCommand(instance.id);
-      await refresh();
-      toast.success(`Instance "${name}" created successfully`);
-      return instance;
-    } catch (e) {
-      console.error("Failed to create instance:", e);
-      toast.error(String(e));
-      return null;
-    }
+    const instance = await createInstance(name);
+    await setActiveInstanceCommand(instance.id);
+    await refresh();
+    toast.success(`Instance "${name}" created successfully`);
+    return instance;
   },
 
   delete: async (id) => {
diff --git a/packages/ui/src/pages/instances/create.tsx b/packages/ui/src/pages/instances/create.tsx
new file mode 100644
index 0000000..57efea2
--- /dev/null
+++ b/packages/ui/src/pages/instances/create.tsx
@@ -0,0 +1,746 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { defineStepper } from "@stepperize/react";
+import { open } from "@tauri-apps/plugin-shell";
+import { ArrowLeftIcon, Link2Icon, XIcon } from "lucide-react";
+import React, {
+  createContext,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
+import {
+  Controller,
+  FormProvider,
+  useForm,
+  useFormContext,
+  Watch,
+} from "react-hook-form";
+import { useNavigate } from "react-router";
+import { toast } from "sonner";
+import z from "zod";
+import {
+  getFabricLoadersForVersion,
+  getForgeVersionsForGame,
+  getVersions,
+  installFabric,
+  installForge,
+  installVersion,
+  updateInstance,
+} from "@/client";
+import {
+  Accordion,
+  AccordionContent,
+  AccordionItem,
+  AccordionTrigger,
+} from "@/components/ui/accordion";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+  Field,
+  FieldContent,
+  FieldDescription,
+  FieldError,
+  FieldLabel,
+  FieldSet,
+  FieldTitle,
+} from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Spinner } from "@/components/ui/spinner";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+import { useInstanceStore } from "@/models/instance";
+import type { FabricLoaderEntry, ForgeVersion, Version } from "@/types";
+
+const versionSchema = z.object({
+  versionId: z.string("Version is required"),
+});
+
+function VersionComponent() {
+  const {
+    control,
+    formState: { errors },
+  } = useFormContext<z.infer<typeof versionSchema>>();
+
+  const [versionSearch, setVersionSearch] = useState<string>("");
+  const [versionFilter, setVersionFilter] = useState<
+    "all" | "release" | "snapshot" | "old_alpha" | "old_beta" | null
+  >("release");
+
+  const [versions, setVersions] = useState<Version[] | null>(null);
+  const [isLoading, setIsLoading] = useState(false);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const loadVersions = useCallback(async () => {
+    setErrorMessage(null);
+    setIsLoading(true);
+    try {
+      const versions = await getVersions();
+      setVersions(versions);
+    } catch (e) {
+      console.error("Failed to load versions:", e);
+      setErrorMessage(`Failed to load versions: ${String(e)}`);
+      return;
+    } finally {
+      setIsLoading(false);
+    }
+  }, []);
+  useEffect(() => {
+    if (!versions) loadVersions();
+  }, [versions, loadVersions]);
+
+  const filteredVersions = useMemo(() => {
+    if (!versions) return null;
+    const all = versions;
+    let list = all.slice();
+    if (versionFilter !== "all") {
+      list = list.filter((v) => v.type === versionFilter);
+    }
+    if (versionSearch.trim()) {
+      const q = versionSearch.trim().toLowerCase().replace(/。/g, ".");
+      list = list.filter((v) => v.id.toLowerCase().includes(q));
+    }
+    return list;
+  }, [versions, versionFilter, versionSearch]);
+
+  return (
+    <div className="flex flex-col min-h-0 h-full overflow-hidden">
+      <div className="flex flex-row items-center mb-4 space-x-2">
+        <div className="flex flex-row space-x-2 w-full">
+          <FieldLabel className="text-nowrap">Versions</FieldLabel>
+          <Input
+            placeholder="Search versions..."
+            value={versionSearch}
+            onChange={(e) => setVersionSearch(e.target.value)}
+          />
+        </div>
+        <div className="flex flex-row space-x-2">
+          <FieldLabel className="text-nowrap">Type</FieldLabel>
+          <Select
+            value={versionFilter}
+            onValueChange={(value) => setVersionFilter(value)}
+          >
+            <SelectTrigger>
+              <SelectValue placeholder="Filter by type" />
+            </SelectTrigger>
+            <SelectContent alignItemWithTrigger={false}>
+              <SelectItem value="all">All Versions</SelectItem>
+              <SelectItem value="release">Release Versions</SelectItem>
+              <SelectItem value="snapshot">Snapshot Versions</SelectItem>
+              <SelectItem value="old_alpha">Old Alpha Versions</SelectItem>
+              <SelectItem value="old_beta">Old Beta Versions</SelectItem>
+            </SelectContent>
+          </Select>
+        </div>
+        <Button onClick={loadVersions} disabled={isLoading}>
+          Refresh
+        </Button>
+      </div>
+      {errorMessage && (
+        <div className="size-full flex flex-col items-center justify-center space-y-2">
+          <p className="text-red-500">{errorMessage}</p>
+          <Button variant="outline" onClick={loadVersions}>
+            Retry
+          </Button>
+        </div>
+      )}
+      {isLoading && !errorMessage ? (
+        <div className="size-full flex flex-col items-center justify-center">
+          <Spinner />
+          <p>Loading versions...</p>
+        </div>
+      ) : (
+        <div className="flex-1 overflow-hidden">
+          <ScrollArea className="size-full pr-2">
+            <Controller
+              name="versionId"
+              control={control}
+              render={({ field }) => (
+                <RadioGroup
+                  {...field}
+                  value={field.value || ""}
+                  className="space-y-2"
+                >
+                  {filteredVersions?.map((version) => (
+                    <FieldLabel key={version.id} htmlFor={version.id}>
+                      <Field orientation="horizontal" className="py-2">
+                        <FieldContent>
+                          <FieldTitle>
+                            {version.id}
+                            <Badge variant="outline">{version.type}</Badge>
+                          </FieldTitle>
+                          <FieldDescription>
+                            {new Date(version.releaseTime).toLocaleString()}
+                          </FieldDescription>
+                        </FieldContent>
+                        <div className="flex flex-row space-x-2 items-center">
+                          <Button
+                            size="icon"
+                            variant="ghost"
+                            onClick={() => {
+                              open(
+                                `https://zh.minecraft.wiki/w/Java%E7%89%88${version.id}`,
+                              );
+                            }}
+                          >
+                            <Link2Icon />
+                          </Button>
+                          <RadioGroupItem value={version.id} id={version.id} />
+                        </div>
+                      </Field>
+                    </FieldLabel>
+                  ))}
+                </RadioGroup>
+              )}
+            ></Controller>
+          </ScrollArea>
+        </div>
+      )}
+      {errors.versionId && <FieldError errors={[errors.versionId]} />}
+    </div>
+  );
+}
+
+const instanceSchema = z.object({
+  name: z.string().min(1, "Instance name is required"),
+  notes: z.string().max(100, "Notes must be at most 100 characters").optional(),
+  modLoader: z.enum(["fabric", "forge"]).optional(),
+  modLoaderVersion: z.string().optional(),
+});
+
+function InstanceComponent() {
+  const {
+    control,
+    register,
+    formState: { errors },
+  } = useFormContext<z.infer<typeof instanceSchema>>();
+
+  const versionId = useVersionId();
+
+  const [forgeVersions, setForgeVersions] = useState<ForgeVersion[] | null>(
+    null,
+  );
+  const [fabricVersions, setFabricVersions] = useState<
+    FabricLoaderEntry[] | null
+  >(null);
+
+  const [isLoadingForge, setIsLoadingForge] = useState(false);
+  const [isLoadingFabric, setIsLoadingFabric] = useState(false);
+  const loadForgeVersions = useCallback(async () => {
+    if (forgeVersions) return;
+    if (!versionId) return toast.error("Version ID is not set");
+    setIsLoadingForge(true);
+    try {
+      const versions = await getForgeVersionsForGame(versionId);
+      setForgeVersions(versions);
+    } catch (e) {
+      console.error("Failed to load Forge versions:", e);
+      toast.error(`Failed to load Forge versions: ${String(e)}`);
+    } finally {
+      setIsLoadingForge(false);
+    }
+  }, [versionId, forgeVersions]);
+  const loadFabricVersions = useCallback(async () => {
+    if (fabricVersions) return;
+    if (!versionId) return toast.error("Version ID is not set");
+    setIsLoadingFabric(true);
+    try {
+      const versions = await getFabricLoadersForVersion(versionId);
+      setFabricVersions(versions);
+    } catch (e) {
+      console.error("Failed to load Fabric versions:", e);
+      toast.error(`Failed to load Fabric versions: ${String(e)}`);
+    } finally {
+      setIsLoadingFabric(false);
+    }
+  }, [versionId, fabricVersions]);
+
+  const modLoaderField = register("modLoader");
+  const modLoaderVersionField = register("modLoaderVersion");
+
+  return (
+    <ScrollArea className="size-full pr-2">
+      <div className="h-full flex flex-col space-y-4">
+        <div className="bg-card w-full p-6 shadow shrink-0">
+          <FieldSet className="w-full">
+            <Field orientation="horizontal">
+              <FieldLabel htmlFor="name" className="text-nowrap" required>
+                Instance Name
+              </FieldLabel>
+              <Input {...register("name")} aria-invalid={!!errors.name} />
+              {errors.name && <FieldError errors={[errors.name]} />}
+            </Field>
+            <Field>
+              <FieldLabel htmlFor="notes" className="text-nowrap">
+                Instance Notes
+              </FieldLabel>
+              <Textarea
+                className="resize-none min-h-0"
+                {...register("notes")}
+                rows={1}
+              />
+              {errors.notes && <FieldError errors={[errors.notes]} />}
+            </Field>
+          </FieldSet>
+        </div>
+
+        <Accordion className="border">
+          <AccordionItem
+            value="forge"
+            onOpenChange={(open) => {
+              if (open) loadForgeVersions();
+            }}
+          >
+            <Watch
+              control={control}
+              render={({ modLoader, modLoaderVersion }) => (
+                <AccordionTrigger
+                  className="border-b px-4 py-3"
+                  disabled={modLoader && modLoader !== "forge"}
+                >
+                  <div className="flex flex-row w-full items-center space-x-4">
+                    <span className="font-bold">Forge</span>
+                    {modLoader === "forge" && (
+                      <>
+                        <span className="text-nowrap font-bold">
+                          {modLoaderVersion}
+                        </span>
+                        <Button
+                          size="icon"
+                          variant="ghost"
+                          nativeButton={false}
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            modLoaderField.onChange({
+                              target: {
+                                name: modLoaderField.name,
+                                value: null,
+                              },
+                            });
+                            modLoaderVersionField.onChange({
+                              target: {
+                                name: modLoaderVersionField.name,
+                                value: null,
+                              },
+                            });
+                          }}
+                          render={(domProps) => (
+                            <div {...domProps}>
+                              <XIcon />
+                            </div>
+                          )}
+                        />
+                      </>
+                    )}
+                  </div>
+                </AccordionTrigger>
+              )}
+            />
+            <AccordionContent>
+              {isLoadingForge ? (
+                <div className="h-full flex flex-col items-center justify-center">
+                  <Spinner />
+                  <p>Loading Forge versions...</p>
+                </div>
+              ) : (
+                <div className="h-full flex flex-col">
+                  {forgeVersions?.map((version, idx) => (
+                    <React.Fragment
+                      key={`forge-${version.version}-${version.minecraftVersion}`}
+                    >
+                      <Button
+                        variant="ghost"
+                        className="p-3 py-6 border-b justify-start"
+                        onClick={() => {
+                          modLoaderField.onChange({
+                            target: {
+                              name: modLoaderField.name,
+                              value: "forge",
+                            },
+                          });
+                          modLoaderVersionField.onChange({
+                            target: {
+                              name: modLoaderVersionField.name,
+                              value: version.version,
+                            },
+                          });
+                        }}
+                      >
+                        Forge {version.version} for Minecraft{" "}
+                        {version.minecraftVersion}
+                      </Button>
+                      {idx !== forgeVersions.length - 1 && <Separator />}
+                    </React.Fragment>
+                  ))}
+                </div>
+              )}
+            </AccordionContent>
+          </AccordionItem>
+          <AccordionItem
+            value="fabric"
+            onOpenChange={(open) => {
+              if (open) loadFabricVersions();
+            }}
+          >
+            <Watch
+              control={control}
+              render={({ modLoader, modLoaderVersion }) => (
+                <AccordionTrigger
+                  className="border-b px-4 py-3"
+                  disabled={modLoader && modLoader !== "fabric"}
+                >
+                  <div className="flex flex-row w-full items-center space-x-4">
+                    <span className="font-bold">Fabric</span>
+                    {modLoader === "fabric" && (
+                      <>
+                        <span className="text-nowrap font-bold">
+                          {modLoaderVersion}
+                        </span>
+                        <Button
+                          size="icon"
+                          variant="ghost"
+                          nativeButton={false}
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            modLoaderField.onChange({
+                              target: {
+                                name: modLoaderField.name,
+                                value: null,
+                              },
+                            });
+                            modLoaderVersionField.onChange({
+                              target: {
+                                name: modLoaderVersionField.name,
+                                value: null,
+                              },
+                            });
+                          }}
+                          render={(domProps) => (
+                            <div {...domProps}>
+                              <XIcon />
+                            </div>
+                          )}
+                        />
+                      </>
+                    )}
+                  </div>
+                </AccordionTrigger>
+              )}
+            />
+
+            <AccordionContent>
+              {isLoadingFabric ? (
+                <div className="h-full flex flex-col items-center justify-center">
+                  <Spinner />
+                  <p>Loading Fabric versions...</p>
+                </div>
+              ) : (
+                <div className="h-full flex flex-col">
+                  {fabricVersions?.map((version, idx) => (
+                    <React.Fragment
+                      key={`fabric-${version.loader.version}-${version.intermediary.version}`}
+                    >
+                      <Button
+                        variant="ghost"
+                        className="p-3 py-6 border-b justify-start"
+                        onClick={() => {
+                          modLoaderField.onChange({
+                            target: {
+                              name: modLoaderField.name,
+                              value: "fabric",
+                            },
+                          });
+                          modLoaderVersionField.onChange({
+                            target: {
+                              name: modLoaderVersionField.name,
+                              value: version.loader.version,
+                            },
+                          });
+                        }}
+                      >
+                        Fabric {version.loader.version} for Minecraft{" "}
+                        {version.intermediary.version}
+                      </Button>
+                      {idx !== fabricVersions.length - 1 && <Separator />}
+                    </React.Fragment>
+                  ))}
+                </div>
+              )}
+            </AccordionContent>
+          </AccordionItem>
+        </Accordion>
+      </div>
+    </ScrollArea>
+  );
+}
+
+const VersionIdContext = createContext<string | null>(null);
+export const useVersionId = () => useContext(VersionIdContext);
+
+const { useStepper, Stepper } = defineStepper(
+  {
+    id: "version",
+    title: "Version",
+    Component: VersionComponent,
+    schema: versionSchema,
+  },
+  {
+    id: "instance",
+    title: "Instance",
+    Component: InstanceComponent,
+    schema: instanceSchema,
+  },
+);
+
+export function CreateInstancePage() {
+  const stepper = useStepper();
+  const schema = stepper.state.current.data.schema;
+  const form = useForm<z.infer<typeof schema>>({
+    resolver: zodResolver(schema),
+  });
+  const navigate = useNavigate();
+
+  const instanceStore = useInstanceStore();
+
+  const [versions, setVersions] = useState<Version[] | null>(null);
+  useEffect(() => {
+    const loadVersions = async () => {
+      const versions = await getVersions();
+      setVersions(versions);
+    };
+    if (!versions) loadVersions();
+  }, [versions]);
+
+  // Step 2
+  const [versionId, setVersionId] = useState<string | null>(null);
+
+  // Step 2
+  // 这里不要动，后面会做一个download页面，需要迁移到download-models
+  const [_instanceMeta, setInstanceMeta] = useState<z.infer<
+    typeof instanceSchema
+  > | null>(null);
+
+  const [isCreating, setIsCreating] = useState(false);
+  const handleSubmit = useCallback(
+    async (data: z.infer<typeof schema>) => {
+      switch (stepper.state.current.data.id) {
+        case "version":
+          setVersionId((data as z.infer<typeof versionSchema>).versionId);
+          return await stepper.navigation.next();
+        case "instance":
+          setInstanceMeta(data as z.infer<typeof instanceSchema>);
+      }
+
+      if (!versionId) return toast.error("Please select a version first");
+
+      setIsCreating(true);
+
+      // 这里不要动，React数据是异步更新，直接用的数据才是实时的
+      const instanceMeta = data as z.infer<typeof instanceSchema>;
+
+      try {
+        const instance = await instanceStore.create(instanceMeta.name);
+        instance.notes = instanceMeta.notes ?? null;
+        await updateInstance(instance);
+
+        await installVersion(instance.id, versionId);
+        switch (instanceMeta.modLoader) {
+          case "fabric":
+            if (!instanceMeta.modLoaderVersion) {
+              toast.error("Please select a Fabric loader version");
+              return;
+            }
+            await installFabric(
+              instance.id,
+              versionId,
+              instanceMeta.modLoaderVersion,
+            );
+            break;
+          case "forge":
+            if (!instanceMeta.modLoaderVersion) {
+              toast.error("Please select a Forge loader version");
+              return;
+            }
+            await installForge(
+              instance.id,
+              versionId,
+              instanceMeta.modLoaderVersion,
+            );
+            break;
+          default:
+            toast.error("Unsupported mod loader");
+            break;
+        }
+
+        navigate("/instances");
+      } catch (error) {
+        console.error(error);
+        toast.error("Failed to create instance");
+      } finally {
+        setIsCreating(false);
+      }
+    },
+    [stepper, instanceStore.create, versionId, navigate],
+  );
+
+  return (
+    <FormProvider {...form}>
+      <Stepper.List className="w-full flex list-none flex-row items-center justify-center px-6 mb-6">
+        {stepper.state.all.map((step, idx) => {
+          const current = stepper.state.current;
+          const isInactive = stepper.state.current.data.id !== step.id;
+          const isLast = stepper.lookup.getLast().id === step.id;
+          return (
+            <React.Fragment key={`stepper-item-${step.id}`}>
+              <Stepper.Item step={step.id}>
+                <Stepper.Trigger
+                  render={(domProps) => (
+                    <Button
+                      className="rounded-full"
+                      variant={isInactive ? "secondary" : "default"}
+                      size="icon"
+                      disabled={isInactive}
+                      {...domProps}
+                    >
+                      <Stepper.Indicator>{idx + 1}</Stepper.Indicator>
+                    </Button>
+                  )}
+                />
+              </Stepper.Item>
+              {!isLast && (
+                <Stepper.Separator
+                  orientation="horizontal"
+                  data-status={current.status}
+                  className={cn(
+                    "w-full h-0.5 mx-2",
+                    "bg-muted data-[status=success]:bg-primary data-disabled:opacity-50",
+                    "transition-all duration-300 ease-in-out",
+                  )}
+                />
+              )}
+            </React.Fragment>
+          );
+        })}
+      </Stepper.List>
+      <form
+        className="flex flex-col flex-1 min-h-0 space-y-4 px-6"
+        onSubmit={form.handleSubmit(handleSubmit)}
+      >
+        <div className="flex-1 overflow-hidden w-full max-w-xl mx-auto">
+          <VersionIdContext.Provider value={versionId}>
+            {stepper.flow.switch({
+              version: ({ Component }) => <Component />,
+              instance: ({ Component }) => <Component />,
+            })}
+          </VersionIdContext.Provider>
+        </div>
+        <div className="w-full flex flex-row justify-between">
+          <Stepper.Prev
+            render={(domProps) => (
+              <Button
+                type="button"
+                variant="secondary"
+                disabled={isCreating}
+                {...domProps}
+              >
+                Previous
+              </Button>
+            )}
+          />
+          {stepper.state.isLast ? (
+            <Button type="submit" disabled={isCreating}>
+              {isCreating ? (
+                <>
+                  <Spinner />
+                  Creating
+                </>
+              ) : (
+                "Create"
+              )}
+            </Button>
+          ) : (
+            <Button type="submit">Next</Button>
+          )}
+        </div>
+      </form>
+    </FormProvider>
+  );
+}
+
+function PageWrapper() {
+  const navigate = useNavigate();
+  const [showCancelDialog, setShowCancelDialog] = useState(false);
+
+  return (
+    <div className="flex size-full overflow-hidden px-6 py-8">
+      <Stepper.Root
+        className="flex flex-col flex-1 space-y-4"
+        orientation="horizontal"
+      >
+        {({ stepper }) => (
+          <>
+            <div className="flex flex-row space-x-4">
+              <Button
+                variant="secondary"
+                size="icon"
+                onClick={() => {
+                  if (stepper.state.isFirst) return navigate(-1);
+                  setShowCancelDialog(true);
+                }}
+              >
+                <ArrowLeftIcon />
+              </Button>
+              <h1 className="text-2xl font-bold">Create Instance</h1>
+            </div>
+            <p className="text-sm text-muted-foreground">
+              Create a new Minecraft instance.
+            </p>
+            <CreateInstancePage />
+          </>
+        )}
+      </Stepper.Root>
+
+      <AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
+            <AlertDialogDescription>
+              All your progress will be lost.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>Cancel</AlertDialogCancel>
+            <AlertDialogAction
+              variant="destructive"
+              onClick={() => navigate(-1)}
+            >
+              Continue
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </div>
+  );
+}
+
+export default PageWrapper;
diff --git a/packages/ui/src/pages/instances/index.tsx b/packages/ui/src/pages/instances/index.tsx
new file mode 100644
index 0000000..e6cd734
--- /dev/null
+++ b/packages/ui/src/pages/instances/index.tsx
@@ -0,0 +1,462 @@
+import { open, save } from "@tauri-apps/plugin-dialog";
+import {
+  CopyIcon,
+  EditIcon,
+  EllipsisIcon,
+  FolderOpenIcon,
+  Plus,
+  RocketIcon,
+  Trash2Icon,
+  XIcon,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router";
+import { toast } from "sonner";
+import { openFileExplorer } from "@/client";
+import InstanceEditorModal from "@/components/instance-editor-modal";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/models/auth";
+import { useGameStore } from "@/models/game";
+import { useInstanceStore } from "@/models/instance";
+import type { Instance } from "@/types";
+
+export function InstancesPage() {
+  const instancesStore = useInstanceStore();
+  const navigate = useNavigate();
+
+  const account = useAuthStore((state) => state.account);
+  const {
+    startGame,
+    runningInstanceId,
+    stoppingInstanceId,
+    launchingInstanceId,
+    stopGame,
+  } = useGameStore();
+
+  const [showEditModal, setShowEditModal] = useState(false);
+  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [showDuplicateModal, setShowDuplicateModal] = useState(false);
+
+  const [isImporting, setIsImporting] = useState(false);
+  const [repairing, setRepairing] = useState(false);
+  const [exportingId, setExportingId] = useState<string | null>(null);
+
+  // Selected / editing instance state
+  const [selectedInstance, setSelectedInstance] = useState<Instance | null>(
+    null,
+  );
+  const [editingInstance, setEditingInstance] = useState<Instance | null>(null);
+
+  // Form fields
+  const [duplicateName, setDuplicateName] = useState("");
+
+  useEffect(() => {
+    instancesStore.refresh();
+  }, [instancesStore.refresh]);
+
+  // Handlers to open modals
+  const openCreate = () => {
+    navigate("/instances/create");
+  };
+
+  const openEdit = (instance: Instance) => {
+    setEditingInstance({ ...instance });
+    setShowEditModal(true);
+  };
+
+  const openDelete = (instance: Instance) => {
+    setSelectedInstance(instance);
+    setShowDeleteConfirm(true);
+  };
+
+  const openDuplicate = (instance: Instance) => {
+    setSelectedInstance(instance);
+    setDuplicateName(`${instance.name} (Copy)`);
+    setShowDuplicateModal(true);
+  };
+
+  const confirmDelete = async () => {
+    if (!selectedInstance) return;
+    await instancesStore.delete(selectedInstance.id);
+    setSelectedInstance(null);
+    setShowDeleteConfirm(false);
+  };
+
+  const confirmDuplicate = async () => {
+    if (!selectedInstance) return;
+    const name = duplicateName.trim();
+    if (!name) return;
+    await instancesStore.duplicate(selectedInstance.id, name);
+    setSelectedInstance(null);
+    setDuplicateName("");
+    setShowDuplicateModal(false);
+  };
+
+  const handleImport = async () => {
+    setIsImporting(true);
+    try {
+      const selected = await open({
+        multiple: false,
+        filters: [{ name: "Zip Archive", extensions: ["zip"] }],
+      });
+
+      if (typeof selected !== "string") {
+        return;
+      }
+
+      await instancesStore.importArchive(selected);
+    } finally {
+      setIsImporting(false);
+    }
+  };
+
+  const handleRepair = async () => {
+    setRepairing(true);
+    try {
+      await instancesStore.repair();
+    } finally {
+      setRepairing(false);
+    }
+  };
+
+  const handleExport = async (instance: Instance) => {
+    setExportingId(instance.id);
+    try {
+      const filePath = await save({
+        defaultPath: `${instance.name.replace(/[\\/:*?"<>|]/g, "_")}.zip`,
+        filters: [{ name: "Zip Archive", extensions: ["zip"] }],
+      });
+
+      if (!filePath) {
+        return;
+      }
+
+      await instancesStore.exportArchive(instance.id, filePath);
+    } finally {
+      setExportingId(null);
+    }
+  };
+
+  return (
+    <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto">
+      <div className="flex items-center justify-between">
+        <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
+          Instances
+        </h1>
+        <div className="flex flex-row space-x-2">
+          <Button
+            type="button"
+            variant="outline"
+            onClick={handleImport}
+            disabled={isImporting}
+          >
+            {isImporting ? "Importing..." : "Import"}
+          </Button>
+          <Button
+            type="button"
+            variant="outline"
+            onClick={handleRepair}
+            disabled={repairing}
+          >
+            {repairing ? "Repairing..." : "Repair Index"}
+          </Button>
+          <Button
+            type="button"
+            onClick={openCreate}
+            className="px-4 py-2 transition-colors"
+          >
+            <Plus size={18} />
+            Create Instance
+          </Button>
+        </div>
+      </div>
+
+      {instancesStore.instances.length === 0 ? (
+        <div className="flex-1 flex items-center justify-center">
+          <div className="text-center text-gray-500 dark:text-gray-400">
+            <p className="text-lg mb-2">No instances yet</p>
+            <p className="text-sm">Create your first instance to get started</p>
+          </div>
+        </div>
+      ) : (
+        <ul className="flex flex-col space-y-3">
+          {instancesStore.instances.map((instance) => {
+            const isActive = instancesStore.activeInstance?.id === instance.id;
+            const isLaunching = launchingInstanceId === instance.id;
+            const isStopping = stoppingInstanceId === instance.id;
+            const isRunning = runningInstanceId === instance.id;
+
+            return (
+              <li
+                key={instance.id}
+                onClick={() => instancesStore.setActiveInstance(instance)}
+                onKeyDown={async (e) => {
+                  if (e.key === "Enter") {
+                    try {
+                      await instancesStore.setActiveInstance(instance);
+                    } catch (e) {
+                      console.error("Failed to set active instance:", e);
+                      toast.error("Error setting active instance");
+                    }
+                  }
+                }}
+                className="cursor-pointer"
+              >
+                <div
+                  className={cn(
+                    "flex flex-row space-x-3 p-3 justify-between",
+                    "border bg-card/5 backdrop-blur-xl",
+                    "hover:bg-accent/50 transition-colors",
+                    isActive && "border-primary",
+                  )}
+                >
+                  <div className="flex flex-row space-x-4">
+                    {instance.iconPath ? (
+                      <div className="w-12 h-12 rounded overflow-hidden">
+                        <img
+                          src={instance.iconPath}
+                          alt={instance.name}
+                          className="w-full h-full object-cover"
+                        />
+                      </div>
+                    ) : (
+                      <div className="w-12 h-12 rounded bg-linear-to-br from-blue-500 to-purple-600 flex items-center justify-center">
+                        <span className="text-white font-bold text-lg">
+                          {instance.name.charAt(0).toUpperCase()}
+                        </span>
+                      </div>
+                    )}
+
+                    <div className="flex flex-col">
+                      <h3 className="text-lg font-semibold">{instance.name}</h3>
+                      {instance.versionId ? (
+                        <p className="text-sm text-muted-foreground">
+                          {instance.versionId}
+                        </p>
+                      ) : (
+                        <p className="text-sm text-muted-foreground">
+                          No version selected
+                        </p>
+                      )}
+                    </div>
+                  </div>
+
+                  <div className="flex items-center">
+                    <div className="flex flex-row space-x-2">
+                      <Button
+                        variant={isRunning ? "destructive" : "ghost"}
+                        size="icon"
+                        onClick={async (e) => {
+                          e.stopPropagation();
+
+                          try {
+                            await instancesStore.setActiveInstance(instance);
+                          } catch (error) {
+                            console.error(
+                              "Failed to set active instance:",
+                              error,
+                            );
+                            toast.error("Error setting active instance");
+                            return;
+                          }
+
+                          if (isRunning) {
+                            await stopGame(instance.id);
+                            return;
+                          }
+
+                          if (!instance.versionId) {
+                            toast.error("No version selected or installed");
+                            return;
+                          }
+
+                          if (!account) {
+                            toast.info("Please login first");
+                            return;
+                          }
+
+                          try {
+                            await startGame(instance.id, instance.versionId);
+                          } catch (error) {
+                            console.error("Failed to start game:", error);
+                            toast.error("Error starting game");
+                          }
+                        }}
+                        disabled={
+                          (!!runningInstanceId &&
+                            runningInstanceId !== instance.id) ||
+                          isLaunching ||
+                          isStopping
+                        }
+                      >
+                        {isLaunching || isStopping ? (
+                          <EllipsisIcon />
+                        ) : isRunning ? (
+                          <XIcon />
+                        ) : (
+                          <RocketIcon />
+                        )}
+                      </Button>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          void openFileExplorer(instance.gameDir);
+                        }}
+                      >
+                        <FolderOpenIcon />
+                      </Button>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          void handleExport(instance);
+                        }}
+                        disabled={exportingId === instance.id}
+                      >
+                        <span className="text-xs">
+                          {exportingId === instance.id ? "..." : "ZIP"}
+                        </span>
+                      </Button>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          openDuplicate(instance);
+                        }}
+                      >
+                        <CopyIcon />
+                      </Button>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          openEdit(instance);
+                        }}
+                      >
+                        <EditIcon />
+                      </Button>
+                      <Button
+                        variant="destructive"
+                        size="icon"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          openDelete(instance);
+                        }}
+                      >
+                        <Trash2Icon />
+                      </Button>
+                    </div>
+                  </div>
+                </div>
+              </li>
+            );
+          })}
+        </ul>
+      )}
+
+      {/*<InstanceCreationModal
+        open={showCreateModal}
+        onOpenChange={setShowCreateModal}
+      />*/}
+
+      <InstanceEditorModal
+        open={showEditModal}
+        instance={editingInstance}
+        onOpenChange={(open) => {
+          setShowEditModal(open);
+          if (!open) setEditingInstance(null);
+        }}
+      />
+
+      {/* Delete Confirmation */}
+      <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>Delete Instance</DialogTitle>
+            <DialogDescription>
+              Are you sure you want to delete "{selectedInstance?.name}"? This
+              action cannot be undone.
+            </DialogDescription>
+          </DialogHeader>
+
+          <DialogFooter>
+            <Button
+              type="button"
+              variant="outline"
+              onClick={() => {
+                setShowDeleteConfirm(false);
+                setSelectedInstance(null);
+              }}
+            >
+              Cancel
+            </Button>
+            <Button
+              type="button"
+              onClick={confirmDelete}
+              className="bg-red-600 text-white hover:bg-red-500"
+            >
+              Delete
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* Duplicate Modal */}
+      <Dialog open={showDuplicateModal} onOpenChange={setShowDuplicateModal}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>Duplicate Instance</DialogTitle>
+            <DialogDescription>
+              Provide a name for the duplicated instance.
+            </DialogDescription>
+          </DialogHeader>
+
+          <div className="mt-4">
+            <Input
+              value={duplicateName}
+              onChange={(e) => setDuplicateName(e.target.value)}
+              placeholder="New instance name"
+              onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()}
+            />
+          </div>
+
+          <DialogFooter>
+            <Button
+              type="button"
+              variant="outline"
+              onClick={() => {
+                setShowDuplicateModal(false);
+                setSelectedInstance(null);
+                setDuplicateName("");
+              }}
+            >
+              Cancel
+            </Button>
+            <Button
+              type="button"
+              onClick={confirmDuplicate}
+              disabled={!duplicateName.trim()}
+            >
+              Duplicate
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+}
diff --git a/packages/ui/src/pages/instances/routes.ts b/packages/ui/src/pages/instances/routes.ts
new file mode 100644
index 0000000..cd1255d
--- /dev/null
+++ b/packages/ui/src/pages/instances/routes.ts
@@ -0,0 +1,19 @@
+import type { RouteObject } from "react-router";
+import CreateInstancePage from "./create";
+import { InstancesPage } from "./index";
+
+const routes = {
+  path: "/instances",
+  children: [
+    {
+      index: true,
+      Component: InstancesPage,
+    },
+    {
+      path: "create",
+      Component: CreateInstancePage,
+    },
+  ],
+} satisfies RouteObject;
+
+export default routes;
-- 
cgit v1.2.3-70-g09d2

