[object Object]

← back to Exo

treefmt: add Svelte file formatting

a7e205e48958b85739820ef1f7de12d0731216f7 · 2026-01-19 13:07:39 +0000 · Jake Hillion

Package prettier with Svelte support and add it to treefmt-nix to format
the dashboard. This change is brutal, I spent a long time trying to get
it nicer but it doesn't seem there's a good way to make this minimal.
Sorry for the noise!

This will make it easier for new contributors to get the formatting
right first time.

Also removes the `.prettierrc` because it turns out treefmt-nix was
ignoring it.

Test plan:
- CI

Files touched

Diff

commit a7e205e48958b85739820ef1f7de12d0731216f7
Author: Jake Hillion <jake@hillion.co.uk>
Date:   Mon Jan 19 13:07:39 2026 +0000

    treefmt: add Svelte file formatting
    
    Package prettier with Svelte support and add it to treefmt-nix to format
    the dashboard. This change is brutal, I spent a long time trying to get
    it nicer but it doesn't seem there's a good way to make this minimal.
    Sorry for the noise!
    
    This will make it easier for new contributors to get the formatting
    right first time.
    
    Also removes the `.prettierrc` because it turns out treefmt-nix was
    ignoring it.
    
    Test plan:
    - CI
---
 .prettierrc                                        |    3 -
 dashboard/package-lock.json                        |   38 +-
 dashboard/package.json                             |    2 +
 dashboard/parts.nix                                |   13 +-
 dashboard/src/app.d.ts                             |   14 +-
 .../src/lib/components/ChatAttachments.svelte      |  151 +-
 dashboard/src/lib/components/ChatForm.svelte       | 1368 +++---
 dashboard/src/lib/components/ChatMessages.svelte   | 1259 ++---
 dashboard/src/lib/components/ChatSidebar.svelte    | 1032 ++--
 dashboard/src/lib/components/HeaderNav.svelte      |  196 +-
 .../src/lib/components/ImageParamsPanel.svelte     | 1042 ++--
 .../src/lib/components/MarkdownContent.svelte      | 1922 ++++----
 dashboard/src/lib/components/ModelCard.svelte      | 1721 ++++---
 dashboard/src/lib/components/TopologyGraph.svelte  | 2087 ++++----
 dashboard/src/lib/stores/app.svelte.ts             | 3924 +++++++--------
 dashboard/src/lib/types/files.ts                   |  348 +-
 dashboard/src/routes/+layout.svelte                |   13 +-
 dashboard/src/routes/+page.svelte                  | 5020 ++++++++++++--------
 dashboard/src/routes/downloads/+page.svelte        | 1041 ++--
 dashboard/vite.config.ts                           |   18 +-
 flake.nix                                          |    3 +-
 21 files changed, 11673 insertions(+), 9542 deletions(-)

diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index 661f2536..00000000
--- a/.prettierrc
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "useTabs": true
-}
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index fddf99de..345c73d2 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -21,6 +21,8 @@
 				"@types/d3": "^7.4.3",
 				"@types/node": "^22",
 				"d3": "^7.9.0",
+				"prettier": "^3.4.2",
+				"prettier-plugin-svelte": "^3.3.3",
 				"svelte": "^5.0.0",
 				"svelte-check": "^4.0.0",
 				"tailwindcss": "^4.0.0",
@@ -863,7 +865,6 @@
 			"integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==",
 			"dev": true,
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"@standard-schema/spec": "^1.0.0",
 				"@sveltejs/acorn-typescript": "^1.0.5",
@@ -903,7 +904,6 @@
 			"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
 			"dev": true,
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
 				"debug": "^4.4.1",
@@ -1520,7 +1520,6 @@
 			"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
 			"dev": true,
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"undici-types": "~6.21.0"
 			}
@@ -1530,7 +1529,6 @@
 			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
 			"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
 			"license": "MIT",
-			"peer": true,
 			"bin": {
 				"acorn": "bin/acorn"
 			},
@@ -1943,7 +1941,6 @@
 			"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
 			"dev": true,
 			"license": "ISC",
-			"peer": true,
 			"engines": {
 				"node": ">=12"
 			}
@@ -2651,7 +2648,6 @@
 			"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
 			"dev": true,
 			"license": "MIT",
-			"peer": true,
 			"engines": {
 				"node": ">=12"
 			},
@@ -2688,6 +2684,33 @@
 				"node": "^10 || ^12 || >=14"
 			}
 		},
+		"node_modules/prettier": {
+			"version": "3.8.1",
+			"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+			"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+			"dev": true,
+			"license": "MIT",
+			"bin": {
+				"prettier": "bin/prettier.cjs"
+			},
+			"engines": {
+				"node": ">=14"
+			},
+			"funding": {
+				"url": "https://github.com/prettier/prettier?sponsor=1"
+			}
+		},
+		"node_modules/prettier-plugin-svelte": {
+			"version": "3.4.1",
+			"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz",
+			"integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==",
+			"dev": true,
+			"license": "MIT",
+			"peerDependencies": {
+				"prettier": "^3.0.0",
+				"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
+			}
+		},
 		"node_modules/readdirp": {
 			"version": "4.1.2",
 			"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2839,7 +2862,6 @@
 			"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.3.tgz",
 			"integrity": "sha512-ngKXNhNvwPzF43QqEhDOue7TQTrG09em1sd4HBxVF0Wr2gopAmdEWan+rgbdgK4fhBtSOTJO8bYU4chUG7VXZQ==",
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"@jridgewell/remapping": "^2.3.4",
 				"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2984,7 +3006,6 @@
 			"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
 			"dev": true,
 			"license": "Apache-2.0",
-			"peer": true,
 			"bin": {
 				"tsc": "bin/tsc",
 				"tsserver": "bin/tsserver"
@@ -3006,7 +3027,6 @@
 			"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
 			"dev": true,
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"esbuild": "^0.25.0",
 				"fdir": "^6.4.4",
diff --git a/dashboard/package.json b/dashboard/package.json
index 57e4be28..45ec739c 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -11,6 +11,8 @@
 		"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
 	},
 	"devDependencies": {
+		"prettier": "^3.4.2",
+		"prettier-plugin-svelte": "^3.3.3",
 		"@sveltejs/adapter-static": "^3.0.10",
 		"@sveltejs/kit": "^2.48.4",
 		"@sveltejs/vite-plugin-svelte": "^5.0.0",
diff --git a/dashboard/parts.nix b/dashboard/parts.nix
index 80fbb156..487078d5 100644
--- a/dashboard/parts.nix
+++ b/dashboard/parts.nix
@@ -4,7 +4,7 @@
     { pkgs, lib, ... }:
     let
       # Filter source to only include dashboard directory
-      src = lib.cleanSourceWith {
+      dashboardSrc = lib.cleanSourceWith {
         src = inputs.self;
         filter =
           path: type:
@@ -30,7 +30,7 @@
           }
           # Inject the filtered source
           {
-            deps.dashboardSrc = lib.mkForce "${src}/dashboard";
+            deps.dashboardSrc = lib.mkForce "${dashboardSrc}/dashboard";
           }
         ];
       };
@@ -40,5 +40,14 @@
       packages.dashboard = pkgs.runCommand "exo-dashboard" { } ''
         cp -r ${dashboardFull}/build $out
       '';
+
+      # Prettier with svelte plugin for treefmt
+      packages.prettier-svelte = pkgs.writeShellScriptBin "prettier-svelte" ''
+        export NODE_PATH="${dashboardFull}/lib/node_modules/exo-dashboard/node_modules"
+        exec ${pkgs.nodejs}/bin/node \
+          ${dashboardFull}/lib/node_modules/exo-dashboard/node_modules/prettier/bin/prettier.cjs \
+          --plugin "${dashboardFull}/lib/node_modules/exo-dashboard/node_modules/prettier-plugin-svelte/plugin.js" \
+          "$@"
+      '';
     };
 }
diff --git a/dashboard/src/app.d.ts b/dashboard/src/app.d.ts
index da08e6da..520c4217 100644
--- a/dashboard/src/app.d.ts
+++ b/dashboard/src/app.d.ts
@@ -1,13 +1,13 @@
 // See https://svelte.dev/docs/kit/types#app.d.ts
 // for information about these interfaces
 declare global {
-	namespace App {
-		// interface Error {}
-		// interface Locals {}
-		// interface PageData {}
-		// interface PageState {}
-		// interface Platform {}
-	}
+  namespace App {
+    // interface Error {}
+    // interface Locals {}
+    // interface PageData {}
+    // interface PageState {}
+    // interface Platform {}
+  }
 }
 
 export {};
diff --git a/dashboard/src/lib/components/ChatAttachments.svelte b/dashboard/src/lib/components/ChatAttachments.svelte
index f56e23e3..7c579a23 100644
--- a/dashboard/src/lib/components/ChatAttachments.svelte
+++ b/dashboard/src/lib/components/ChatAttachments.svelte
@@ -1,75 +1,94 @@
 <script lang="ts">
-	import type { ChatUploadedFile } from '$lib/types/files';
-	import { formatFileSize, getFileCategory } from '$lib/types/files';
+  import type { ChatUploadedFile } from "$lib/types/files";
+  import { formatFileSize, getFileCategory } from "$lib/types/files";
 
-	interface Props {
-		files: ChatUploadedFile[];
-		readonly?: boolean;
-		onRemove?: (fileId: string) => void;
-	}
+  interface Props {
+    files: ChatUploadedFile[];
+    readonly?: boolean;
+    onRemove?: (fileId: string) => void;
+  }
 
-	let { files, readonly = false, onRemove }: Props = $props();
+  let { files, readonly = false, onRemove }: Props = $props();
 
-	function getFileIcon(file: ChatUploadedFile): string {
-		const category = getFileCategory(file.type, file.name);
-		switch (category) {
-			case 'image': return '🖼';
-			case 'text': return '📄';
-			case 'pdf': return '📑';
-			case 'audio': return '🎵';
-			default: return '📎';
-		}
-	}
+  function getFileIcon(file: ChatUploadedFile): string {
+    const category = getFileCategory(file.type, file.name);
+    switch (category) {
+      case "image":
+        return "🖼";
+      case "text":
+        return "📄";
+      case "pdf":
+        return "📑";
+      case "audio":
+        return "🎵";
+      default:
+        return "📎";
+    }
+  }
 
-	function truncateName(name: string, maxLen: number = 20): string {
-		if (name.length <= maxLen) return name;
-		const ext = name.slice(name.lastIndexOf('.'));
-		const base = name.slice(0, name.lastIndexOf('.'));
-		const available = maxLen - ext.length - 3;
-		return base.slice(0, available) + '...' + ext;
-	}
+  function truncateName(name: string, maxLen: number = 20): string {
+    if (name.length <= maxLen) return name;
+    const ext = name.slice(name.lastIndexOf("."));
+    const base = name.slice(0, name.lastIndexOf("."));
+    const available = maxLen - ext.length - 3;
+    return base.slice(0, available) + "..." + ext;
+  }
 </script>
 
 {#if files.length > 0}
-	<div class="flex flex-wrap gap-2 mb-3 px-1">
-		{#each files as file (file.id)}
-			<div class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_rgba(255,215,0,0.1)]">
-				<!-- File preview or icon -->
-				{#if file.preview && getFileCategory(file.type, file.name) === 'image'}
-					<img 
-						src={file.preview} 
-						alt={file.name}
-						class="w-8 h-8 object-cover rounded border border-exo-yellow/20"
-					/>
-				{:else}
-					<span class="text-base">{getFileIcon(file)}</span>
-				{/if}
-				
-				<!-- File info -->
-				<div class="flex flex-col min-w-0">
-					<span class="text-exo-yellow truncate max-w-[120px]" title={file.name}>
-						{truncateName(file.name)}
-					</span>
-					<span class="text-exo-light-gray text-xs">
-						{formatFileSize(file.size)}
-					</span>
-				</div>
-				
-				<!-- Remove button -->
-				{#if !readonly && onRemove}
-					<button
-						type="button"
-						onclick={() => onRemove?.(file.id)}
-						class="ml-1 w-4 h-4 flex items-center justify-center text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
-						title="Remove file"
-					>
-						<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-							<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
-						</svg>
-					</button>
-				{/if}
-			</div>
-		{/each}
-	</div>
-{/if}
+  <div class="flex flex-wrap gap-2 mb-3 px-1">
+    {#each files as file (file.id)}
+      <div
+        class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_rgba(255,215,0,0.1)]"
+      >
+        <!-- File preview or icon -->
+        {#if file.preview && getFileCategory(file.type, file.name) === "image"}
+          <img
+            src={file.preview}
+            alt={file.name}
+            class="w-8 h-8 object-cover rounded border border-exo-yellow/20"
+          />
+        {:else}
+          <span class="text-base">{getFileIcon(file)}</span>
+        {/if}
+
+        <!-- File info -->
+        <div class="flex flex-col min-w-0">
+          <span
+            class="text-exo-yellow truncate max-w-[120px]"
+            title={file.name}
+          >
+            {truncateName(file.name)}
+          </span>
+          <span class="text-exo-light-gray text-xs">
+            {formatFileSize(file.size)}
+          </span>
+        </div>
 
+        <!-- Remove button -->
+        {#if !readonly && onRemove}
+          <button
+            type="button"
+            onclick={() => onRemove?.(file.id)}
+            class="ml-1 w-4 h-4 flex items-center justify-center text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
+            title="Remove file"
+          >
+            <svg
+              class="w-3 h-3"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+            >
+              <path
+                stroke-linecap="round"
+                stroke-linejoin="round"
+                stroke-width="2"
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          </button>
+        {/if}
+      </div>
+    {/each}
+  </div>
+{/if}
diff --git a/dashboard/src/lib/components/ChatForm.svelte b/dashboard/src/lib/components/ChatForm.svelte
index d1648480..42a9bb4c 100644
--- a/dashboard/src/lib/components/ChatForm.svelte
+++ b/dashboard/src/lib/components/ChatForm.svelte
@@ -1,695 +1,695 @@
 <script lang="ts">
-	import {
-		isLoading,
-		sendMessage,
-		generateImage,
-		editImage,
-		editingImage,
-		clearEditingImage,
-		selectedChatModel,
-		setSelectedChatModel,
-		instances,
-		ttftMs,
-		tps,
-		totalTokens,
-	} from "$lib/stores/app.svelte";
-	import ChatAttachments from "./ChatAttachments.svelte";
-	import ImageParamsPanel from "./ImageParamsPanel.svelte";
-	import type { ChatUploadedFile } from "$lib/types/files";
-	import { processUploadedFiles, getAcceptString } from "$lib/types/files";
-
-	interface Props {
-		class?: string;
-		placeholder?: string;
-		showHelperText?: boolean;
-		autofocus?: boolean;
-		showModelSelector?: boolean;
-		modelTasks?: Record<string, string[]>;
-	}
-
-	let {
-		class: className = "",
-		placeholder = "Ask anything",
-		showHelperText = false,
-		autofocus = true,
-		showModelSelector = false,
-		modelTasks = {},
-	}: Props = $props();
-
-	let message = $state("");
-	let textareaRef: HTMLTextAreaElement | undefined = $state();
-	let fileInputRef: HTMLInputElement | undefined = $state();
-	let uploadedFiles = $state<ChatUploadedFile[]>([]);
-	let isDragOver = $state(false);
-	let loading = $derived(isLoading());
-	const currentModel = $derived(selectedChatModel());
-	const instanceData = $derived(instances());
-	const currentTtft = $derived(ttftMs());
-	const currentTps = $derived(tps());
-	const currentTokens = $derived(totalTokens());
-	const currentEditingImage = $derived(editingImage());
-	const isEditMode = $derived(currentEditingImage !== null);
-
-	// Custom dropdown state
-	let isModelDropdownOpen = $state(false);
-	let dropdownButtonRef: HTMLButtonElement | undefined = $state();
-	let dropdownPosition = $derived(() => {
-		if (!dropdownButtonRef || !isModelDropdownOpen)
-			return { top: 0, left: 0, width: 0 };
-		const rect = dropdownButtonRef.getBoundingClientRect();
-		return {
-			top: rect.top,
-			left: rect.left,
-			width: rect.width,
-		};
-	});
-
-	// Accept all supported file types
-	const acceptString = getAcceptString(["image", "text", "pdf"]);
-
-	function modelSupportsImageGeneration(modelId: string): boolean {
-		const tasks = modelTasks[modelId] || [];
-		return tasks.includes("TextToImage") || tasks.includes("ImageToImage");
-	}
-
-	function modelSupportsTextToImage(modelId: string): boolean {
-		const tasks = modelTasks[modelId] || [];
-		return tasks.includes("TextToImage");
-	}
-
-	function modelSupportsOnlyImageEditing(modelId: string): boolean {
-		const tasks = modelTasks[modelId] || [];
-		return tasks.includes("ImageToImage") && !tasks.includes("TextToImage");
-	}
-
-	function modelSupportsImageEditing(modelId: string): boolean {
-		const tasks = modelTasks[modelId] || [];
-		return tasks.includes("ImageToImage");
-	}
-
-	const isImageModel = $derived(() => {
-		if (!currentModel) return false;
-		return modelSupportsTextToImage(currentModel);
-	});
-
-	const isEditOnlyWithoutImage = $derived(
-		currentModel !== null &&
-			modelSupportsOnlyImageEditing(currentModel) &&
-			!isEditMode &&
-			uploadedFiles.length === 0,
-	);
-
-	// Show edit mode when: explicit edit mode OR (model supports ImageToImage AND files attached)
-	const shouldShowEditMode = $derived(
-		isEditMode ||
-			(currentModel &&
-				modelSupportsImageEditing(currentModel) &&
-				uploadedFiles.length > 0),
-	);
-
-	// Extract available models from running instances
-	const availableModels = $derived(() => {
-		const models: Array<{ id: string; label: string; isImageModel: boolean }> =
-			[];
-		for (const [, instance] of Object.entries(instanceData)) {
-			const modelId = getInstanceModelId(instance);
-			if (
-				modelId &&
-				modelId !== "Unknown" &&
-				!models.some((m) => m.id === modelId)
-			) {
-				models.push({
-					id: modelId,
-					label: modelId.split("/").pop() || modelId,
-					isImageModel: modelSupportsImageGeneration(modelId),
-				});
-			}
-		}
-		return models;
-	});
-
-	// Track previous model IDs to detect newly added models (plain variable to avoid reactive loop)
-	let previousModelIds: Set<string> = new Set();
-
-	// Auto-select the first available model if none is selected, if current selection is stale, or if a new model is added
-	$effect(() => {
-		const models = availableModels();
-		const currentModelIds = new Set(models.map((m) => m.id));
-
-		if (models.length > 0) {
-			// Find newly added models (in current but not in previous)
-			const newModels = models.filter((m) => !previousModelIds.has(m.id));
-
-			// If no model selected, select the first available
-			if (!currentModel) {
-				setSelectedChatModel(models[0].id);
-			}
-			// If current model is stale (no longer has a running instance), reset to first available
-			else if (!models.some((m) => m.id === currentModel)) {
-				setSelectedChatModel(models[0].id);
-			}
-			// If a new model was just added, select it
-			else if (newModels.length > 0 && previousModelIds.size > 0) {
-				setSelectedChatModel(newModels[0].id);
-			}
-		} else {
-			// No instances running - clear the selected model
-			if (currentModel) {
-				setSelectedChatModel("");
-			}
-		}
-
-		// Update previous model IDs for next comparison
-		previousModelIds = currentModelIds;
-	});
-
-	function getInstanceModelId(instanceWrapped: unknown): string {
-		if (!instanceWrapped || typeof instanceWrapped !== "object") return "";
-		const keys = Object.keys(instanceWrapped as Record<string, unknown>);
-		if (keys.length === 1) {
-			const instance = (instanceWrapped as Record<string, unknown>)[
-				keys[0]
-			] as { shardAssignments?: { modelId?: string } };
-			return instance?.shardAssignments?.modelId || "";
-		}
-		return "";
-	}
-
-	async function handleFiles(files: File[]) {
-		if (files.length === 0) return;
-		const processed = await processUploadedFiles(files);
-		uploadedFiles = [...uploadedFiles, ...processed];
-	}
-
-	function handleFileInputChange(event: Event) {
-		const input = event.target as HTMLInputElement;
-		if (input.files && input.files.length > 0) {
-			handleFiles(Array.from(input.files));
-			input.value = ""; // Reset for next selection
-		}
-	}
-
-	function handleFileRemove(fileId: string) {
-		uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
-	}
-
-	function handlePaste(event: ClipboardEvent) {
-		if (!event.clipboardData) return;
-
-		const files = Array.from(event.clipboardData.items)
-			.filter((item) => item.kind === "file")
-			.map((item) => item.getAsFile())
-			.filter((file): file is File => file !== null);
-
-		if (files.length > 0) {
-			event.preventDefault();
-			handleFiles(files);
-			return;
-		}
-
-		// Handle long text paste as file
-		const text = event.clipboardData.getData("text/plain");
-		if (text.length > 2500) {
-			event.preventDefault();
-			const textFile = new File([text], "pasted-text.txt", {
-				type: "text/plain",
-			});
-			handleFiles([textFile]);
-		}
-	}
-
-	function handleDragOver(event: DragEvent) {
-		event.preventDefault();
-		isDragOver = true;
-	}
-
-	function handleDragLeave(event: DragEvent) {
-		event.preventDefault();
-		isDragOver = false;
-	}
-
-	function handleDrop(event: DragEvent) {
-		event.preventDefault();
-		isDragOver = false;
-
-		if (event.dataTransfer?.files) {
-			handleFiles(Array.from(event.dataTransfer.files));
-		}
-	}
-
-	function handleKeydown(event: KeyboardEvent) {
-		// Prevent form submission during IME composition (e.g., Chinese, Japanese, Korean input)
-		if (event.isComposing || event.keyCode === 229) {
-			return;
-		}
-
-		if (event.key === "Enter" && !event.shiftKey) {
-			event.preventDefault();
-			handleSubmit();
-		}
-	}
-
-	function handleSubmit() {
-		if ((!message.trim() && uploadedFiles.length === 0) || loading) return;
-
-		const content = message.trim();
-		const files = [...uploadedFiles];
-
-		message = "";
-		uploadedFiles = [];
-		resetTextareaHeight();
-
-		// Use image editing if in edit mode
-		if (isEditMode && currentEditingImage && content) {
-			editImage(content, currentEditingImage.imageDataUrl);
-		}
-		// If user attached an image with an ImageToImage model, use edit endpoint
-		else if (
-			currentModel &&
-			modelSupportsImageEditing(currentModel) &&
-			files.length > 0 &&
-			content
-		) {
-			// Use the first attached image for editing
-			const imageFile = files[0];
-			if (imageFile.preview) {
-				editImage(content, imageFile.preview);
-			}
-		} else if (isImageModel() && content) {
-			// Use image generation for text-to-image models
-			generateImage(content);
-		} else {
-			sendMessage(content, files);
-		}
-
-		// Refocus the textarea after sending
-		setTimeout(() => textareaRef?.focus(), 10);
-	}
-
-	function handleInput() {
-		if (!textareaRef) return;
-		textareaRef.style.height = "auto";
-		textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
-	}
-
-	function resetTextareaHeight() {
-		if (textareaRef) {
-			textareaRef.style.height = "auto";
-		}
-	}
-
-	function openFilePicker() {
-		fileInputRef?.click();
-	}
-
-	// Track previous loading state to detect when loading completes
-	let wasLoading = $state(false);
-
-	$effect(() => {
-		if (autofocus && textareaRef) {
-			setTimeout(() => textareaRef?.focus(), 10);
-		}
-	});
-
-	// Refocus after loading completes (AI response finished)
-	$effect(() => {
-		if (wasLoading && !loading && textareaRef) {
-			setTimeout(() => textareaRef?.focus(), 50);
-		}
-		wasLoading = loading;
-	});
-
-	const canSend = $derived(
-		message.trim().length > 0 || uploadedFiles.length > 0,
-	);
+  import {
+    isLoading,
+    sendMessage,
+    generateImage,
+    editImage,
+    editingImage,
+    clearEditingImage,
+    selectedChatModel,
+    setSelectedChatModel,
+    instances,
+    ttftMs,
+    tps,
+    totalTokens,
+  } from "$lib/stores/app.svelte";
+  import ChatAttachments from "./ChatAttachments.svelte";
+  import ImageParamsPanel from "./ImageParamsPanel.svelte";
+  import type { ChatUploadedFile } from "$lib/types/files";
+  import { processUploadedFiles, getAcceptString } from "$lib/types/files";
+
+  interface Props {
+    class?: string;
+    placeholder?: string;
+    showHelperText?: boolean;
+    autofocus?: boolean;
+    showModelSelector?: boolean;
+    modelTasks?: Record<string, string[]>;
+  }
+
+  let {
+    class: className = "",
+    placeholder = "Ask anything",
+    showHelperText = false,
+    autofocus = true,
+    showModelSelector = false,
+    modelTasks = {},
+  }: Props = $props();
+
+  let message = $state("");
+  let textareaRef: HTMLTextAreaElement | undefined = $state();
+  let fileInputRef: HTMLInputElement | undefined = $state();
+  let uploadedFiles = $state<ChatUploadedFile[]>([]);
+  let isDragOver = $state(false);
+  let loading = $derived(isLoading());
+  const currentModel = $derived(selectedChatModel());
+  const instanceData = $derived(instances());
+  const currentTtft = $derived(ttftMs());
+  const currentTps = $derived(tps());
+  const currentTokens = $derived(totalTokens());
+  const currentEditingImage = $derived(editingImage());
+  const isEditMode = $derived(currentEditingImage !== null);
+
+  // Custom dropdown state
+  let isModelDropdownOpen = $state(false);
+  let dropdownButtonRef: HTMLButtonElement | undefined = $state();
+  let dropdownPosition = $derived(() => {
+    if (!dropdownButtonRef || !isModelDropdownOpen)
+      return { top: 0, left: 0, width: 0 };
+    const rect = dropdownButtonRef.getBoundingClientRect();
+    return {
+      top: rect.top,
+      left: rect.left,
+      width: rect.width,
+    };
+  });
+
+  // Accept all supported file types
+  const acceptString = getAcceptString(["image", "text", "pdf"]);
+
+  function modelSupportsImageGeneration(modelId: string): boolean {
+    const tasks = modelTasks[modelId] || [];
+    return tasks.includes("TextToImage") || tasks.includes("ImageToImage");
+  }
+
+  function modelSupportsTextToImage(modelId: string): boolean {
+    const tasks = modelTasks[modelId] || [];
+    return tasks.includes("TextToImage");
+  }
+
+  function modelSupportsOnlyImageEditing(modelId: string): boolean {
+    const tasks = modelTasks[modelId] || [];
+    return tasks.includes("ImageToImage") && !tasks.includes("TextToImage");
+  }
+
+  function modelSupportsImageEditing(modelId: string): boolean {
+    const tasks = modelTasks[modelId] || [];
+    return tasks.includes("ImageToImage");
+  }
+
+  const isImageModel = $derived(() => {
+    if (!currentModel) return false;
+    return modelSupportsTextToImage(currentModel);
+  });
+
+  const isEditOnlyWithoutImage = $derived(
+    currentModel !== null &&
+      modelSupportsOnlyImageEditing(currentModel) &&
+      !isEditMode &&
+      uploadedFiles.length === 0,
+  );
+
+  // Show edit mode when: explicit edit mode OR (model supports ImageToImage AND files attached)
+  const shouldShowEditMode = $derived(
+    isEditMode ||
+      (currentModel &&
+        modelSupportsImageEditing(currentModel) &&
+        uploadedFiles.length > 0),
+  );
+
+  // Extract available models from running instances
+  const availableModels = $derived(() => {
+    const models: Array<{ id: string; label: string; isImageModel: boolean }> =
+      [];
+    for (const [, instance] of Object.entries(instanceData)) {
+      const modelId = getInstanceModelId(instance);
+      if (
+        modelId &&
+        modelId !== "Unknown" &&
+        !models.some((m) => m.id === modelId)
+      ) {
+        models.push({
+          id: modelId,
+          label: modelId.split("/").pop() || modelId,
+          isImageModel: modelSupportsImageGeneration(modelId),
+        });
+      }
+    }
+    return models;
+  });
+
+  // Track previous model IDs to detect newly added models (plain variable to avoid reactive loop)
+  let previousModelIds: Set<string> = new Set();
+
+  // Auto-select the first available model if none is selected, if current selection is stale, or if a new model is added
+  $effect(() => {
+    const models = availableModels();
+    const currentModelIds = new Set(models.map((m) => m.id));
+
+    if (models.length > 0) {
+      // Find newly added models (in current but not in previous)
+      const newModels = models.filter((m) => !previousModelIds.has(m.id));
+
+      // If no model selected, select the first available
+      if (!currentModel) {
+        setSelectedChatModel(models[0].id);
+      }
+      // If current model is stale (no longer has a running instance), reset to first available
+      else if (!models.some((m) => m.id === currentModel)) {
+        setSelectedChatModel(models[0].id);
+      }
+      // If a new model was just added, select it
+      else if (newModels.length > 0 && previousModelIds.size > 0) {
+        setSelectedChatModel(newModels[0].id);
+      }
+    } else {
+      // No instances running - clear the selected model
+      if (currentModel) {
+        setSelectedChatModel("");
+      }
+    }
+
+    // Update previous model IDs for next comparison
+    previousModelIds = currentModelIds;
+  });
+
+  function getInstanceModelId(instanceWrapped: unknown): string {
+    if (!instanceWrapped || typeof instanceWrapped !== "object") return "";
+    const keys = Object.keys(instanceWrapped as Record<string, unknown>);
+    if (keys.length === 1) {
+      const instance = (instanceWrapped as Record<string, unknown>)[
+        keys[0]
+      ] as { shardAssignments?: { modelId?: string } };
+      return instance?.shardAssignments?.modelId || "";
+    }
+    return "";
+  }
+
+  async function handleFiles(files: File[]) {
+    if (files.length === 0) return;
+    const processed = await processUploadedFiles(files);
+    uploadedFiles = [...uploadedFiles, ...processed];
+  }
+
+  function handleFileInputChange(event: Event) {
+    const input = event.target as HTMLInputElement;
+    if (input.files && input.files.length > 0) {
+      handleFiles(Array.from(input.files));
+      input.value = ""; // Reset for next selection
+    }
+  }
+
+  function handleFileRemove(fileId: string) {
+    uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
+  }
+
+  function handlePaste(event: ClipboardEvent) {
+    if (!event.clipboardData) return;
+
+    const files = Array.from(event.clipboardData.items)
+      .filter((item) => item.kind === "file")
+      .map((item) => item.getAsFile())
+      .filter((file): file is File => file !== null);
+
+    if (files.length > 0) {
+      event.preventDefault();
+      handleFiles(files);
+      return;
+    }
+
+    // Handle long text paste as file
+    const text = event.clipboardData.getData("text/plain");
+    if (text.length > 2500) {
+      event.preventDefault();
+      const textFile = new File([text], "pasted-text.txt", {
+        type: "text/plain",
+      });
+      handleFiles([textFile]);
+    }
+  }
+
+  function handleDragOver(event: DragEvent) {
+    event.preventDefault();
+    isDragOver = true;
+  }
+
+  function handleDragLeave(event: DragEvent) {
+    event.preventDefault();
+    isDragOver = false;
+  }
+
+  function handleDrop(event: DragEvent) {
+    event.preventDefault();
+    isDragOver = false;
+
+    if (event.dataTransfer?.files) {
+      handleFiles(Array.from(event.dataTransfer.files));
+    }
+  }
+
+  function handleKeydown(event: KeyboardEvent) {
+    // Prevent form submission during IME composition (e.g., Chinese, Japanese, Korean input)
+    if (event.isComposing || event.keyCode === 229) {
+      return;
+    }
+
+    if (event.key === "Enter" && !event.shiftKey) {
+      event.preventDefault();
+      handleSubmit();
+    }
+  }
+
+  function handleSubmit() {
+    if ((!message.trim() && uploadedFiles.length === 0) || loading) return;
+
+    const content = message.trim();
+    const files = [...uploadedFiles];
+
+    message = "";
+    uploadedFiles = [];
+    resetTextareaHeight();
+
+    // Use image editing if in edit mode
+    if (isEditMode && currentEditingImage && content) {
+      editImage(content, currentEditingImage.imageDataUrl);
+    }
+    // If user attached an image with an ImageToImage model, use edit endpoint
+    else if (
+      currentModel &&
+      modelSupportsImageEditing(currentModel) &&
+      files.length > 0 &&
+      content
+    ) {
+      // Use the first attached image for editing
+      const imageFile = files[0];
+      if (imageFile.preview) {
+        editImage(content, imageFile.preview);
+      }
+    } else if (isImageModel() && content) {
+      // Use image generation for text-to-image models
+      generateImage(content);
+    } else {
+      sendMessage(content, files);
+    }
+
+    // Refocus the textarea after sending
+    setTimeout(() => textareaRef?.focus(), 10);
+  }
+
+  function handleInput() {
+    if (!textareaRef) return;
+    textareaRef.style.height = "auto";
+    textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
+  }
+
+  function resetTextareaHeight() {
+    if (textareaRef) {
+      textareaRef.style.height = "auto";
+    }
+  }
+
+  function openFilePicker() {
+    fileInputRef?.click();
+  }
+
+  // Track previous loading state to detect when loading completes
+  let wasLoading = $state(false);
+
+  $effect(() => {
+    if (autofocus && textareaRef) {
+      setTimeout(() => textareaRef?.focus(), 10);
+    }
+  });
+
+  // Refocus after loading completes (AI response finished)
+  $effect(() => {
+    if (wasLoading && !loading && textareaRef) {
+      setTimeout(() => textareaRef?.focus(), 50);
+    }
+    wasLoading = loading;
+  });
+
+  const canSend = $derived(
+    message.trim().length > 0 || uploadedFiles.length > 0,
+  );
 </script>
 
 <!-- Hidden file input -->
 <input
-	bind:this={fileInputRef}
-	type="file"
-	accept={acceptString}
-	multiple
-	class="hidden"
-	onchange={handleFileInputChange}
+  bind:this={fileInputRef}
+  type="file"
+  accept={acceptString}
+  multiple
+  class="hidden"
+  onchange={handleFileInputChange}
 />
 
 <form
-	onsubmit={(e) => {
-		e.preventDefault();
-		handleSubmit();
-	}}
-	class="w-full {className}"
-	ondragover={handleDragOver}
-	ondragleave={handleDragLeave}
-	ondrop={handleDrop}
+  onsubmit={(e) => {
+    e.preventDefault();
+    handleSubmit();
+  }}
+  class="w-full {className}"
+  ondragover={handleDragOver}
+  ondragleave={handleDragLeave}
+  ondrop={handleDrop}
 >
-	<div
-		class="relative command-panel rounded overflow-hidden transition-all duration-200 {isDragOver
-			? 'ring-2 ring-exo-yellow ring-opacity-50'
-			: ''}"
-	>
-		<!-- Top accent line -->
-		<div
-			class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/50 to-transparent"
-		></div>
-
-		<!-- Drag overlay -->
-		{#if isDragOver}
-			<div
-				class="absolute inset-0 bg-exo-dark-gray/80 z-10 flex items-center justify-center"
-			>
-				<div class="text-exo-yellow text-sm font-mono tracking-wider uppercase">
-					DROP FILES HERE
-				</div>
-			</div>
-		{/if}
-
-		<!-- Edit mode banner -->
-		{#if isEditMode && currentEditingImage}
-			<div
-				class="flex items-center gap-3 px-3 py-2 bg-exo-yellow/10 border-b border-exo-yellow/30"
-			>
-				<img
-					src={currentEditingImage.imageDataUrl}
-					alt="Source for editing"
-					class="w-10 h-10 object-cover rounded border border-exo-yellow/30"
-				/>
-				<div class="flex-1">
-					<span
-						class="text-xs font-mono tracking-wider uppercase text-exo-yellow"
-						>EDITING IMAGE</span
-					>
-				</div>
-				<button
-					type="button"
-					onclick={() => clearEditingImage()}
-					class="px-2 py-1 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50 rounded hover:bg-exo-medium-gray/50 hover:text-exo-yellow transition-colors cursor-pointer"
-				>
-					CANCEL
-				</button>
-			</div>
-		{/if}
-
-		<!-- Model selector (when enabled) -->
-		{#if showModelSelector && availableModels().length > 0}
-			<div
-				class="flex items-center justify-between gap-2 px-3 py-2 border-b border-exo-medium-gray/30"
-			>
-				<div class="flex items-center gap-2 flex-1">
-					<span
-						class="text-xs text-exo-light-gray uppercase tracking-wider flex-shrink-0"
-						>MODEL:</span
-					>
-					<!-- Custom dropdown -->
-					<div class="relative flex-1 max-w-xs">
-						<button
-							bind:this={dropdownButtonRef}
-							type="button"
-							onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
-							class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-1.5 text-xs font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen
-								? 'border-exo-yellow/70'
-								: ''}"
-						>
-							{#if availableModels().find((m) => m.id === currentModel)}
-								<span class="text-exo-yellow truncate"
-									>{availableModels().find((m) => m.id === currentModel)
-										?.label}</span
-								>
-							{:else if availableModels().length > 0}
-								<span class="text-exo-yellow truncate"
-									>{availableModels()[0].label}</span
-								>
-							{:else}
-								<span class="text-exo-light-gray/50">— SELECT MODEL —</span>
-							{/if}
-						</button>
-						<div
-							class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen
-								? 'rotate-180'
-								: ''}"
-						>
-							<svg
-								class="w-3 h-3 text-exo-yellow/60"
-								fill="none"
-								viewBox="0 0 24 24"
-								stroke="currentColor"
-							>
-								<path
-									stroke-linecap="round"
-									stroke-linejoin="round"
-									stroke-width="2"
-									d="M19 9l-7 7-7-7"
-								/>
-							</svg>
-						</div>
-					</div>
-
-					{#if isModelDropdownOpen}
-						<!-- Backdrop to close dropdown -->
-						<button
-							type="button"
-							class="fixed inset-0 z-[9998] cursor-default"
-							onclick={() => (isModelDropdownOpen = false)}
-							aria-label="Close dropdown"
-						></button>
-
-						<!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
-						<div
-							class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto"
-							style="bottom: calc(100vh - {dropdownPosition()
-								.top}px + 4px); left: {dropdownPosition()
-								.left}px; width: {dropdownPosition().width}px;"
-						>
-							<div class="py-1">
-								{#each availableModels() as model}
-									<button
-										type="button"
-										onclick={() => {
-											setSelectedChatModel(model.id);
-											isModelDropdownOpen = false;
-										}}
-										class="w-full px-3 py-2 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {currentModel ===
-										model.id
-											? 'bg-transparent text-exo-yellow'
-											: 'text-exo-light-gray hover:text-exo-yellow'}"
-									>
-										{#if currentModel === model.id}
-											<svg
-												class="w-3 h-3 flex-shrink-0"
-												fill="currentColor"
-												viewBox="0 0 20 20"
-											>
-												<path
-													fill-rule="evenodd"
-													d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
-													clip-rule="evenodd"
-												/>
-											</svg>
-										{:else}
-											<span class="w-3"></span>
-										{/if}
-										{#if model.isImageModel}
-											<svg
-												class="w-3.5 h-3.5 flex-shrink-0 text-exo-yellow"
-												fill="none"
-												viewBox="0 0 24 24"
-												stroke="currentColor"
-												stroke-width="2"
-												aria-label="Image generation model"
-											>
-												<rect
-													x="3"
-													y="3"
-													width="18"
-													height="18"
-													rx="2"
-													ry="2"
-												/>
-												<circle cx="8.5" cy="8.5" r="1.5" />
-												<polyline points="21 15 16 10 5 21" />
-											</svg>
-										{/if}
-										<span class="truncate flex-1">{model.label}</span>
-									</button>
-								{/each}
-							</div>
-						</div>
-					{/if}
-				</div>
-				<!-- Performance stats -->
-				{#if currentTtft !== null || currentTps !== null}
-					<div class="flex items-center gap-4 text-xs font-mono flex-shrink-0">
-						{#if currentTtft !== null}
-							<span class="text-exo-light-gray">
-								<span class="text-white/70">TTFT</span>
-								<span class="text-exo-yellow">{currentTtft.toFixed(1)}ms</span>
-							</span>
-						{/if}
-						{#if currentTps !== null}
-							<span class="text-exo-light-gray">
-								<span class="text-white/70">TPS</span>
-								<span class="text-exo-yellow">{currentTps.toFixed(1)}</span>
-								<span class="text-white/60">tok/s</span>
-								<span class="text-white/50"
-									>({(1000 / currentTps).toFixed(1)} ms/tok)</span
-								>
-							</span>
-						{/if}
-					</div>
-				{/if}
-			</div>
-		{/if}
-
-		<!-- Image params panel (shown for image models or edit mode) -->
-		{#if showModelSelector && (isImageModel() || isEditMode)}
-			<ImageParamsPanel {isEditMode} />
-		{/if}
-
-		<!-- Attached files preview -->
-		{#if uploadedFiles.length > 0}
-			<div class="px-3 pt-3">
-				<ChatAttachments files={uploadedFiles} onRemove={handleFileRemove} />
-			</div>
-		{/if}
-
-		<!-- Input area -->
-		<div class="flex items-start gap-2 sm:gap-3 py-3 px-3 sm:px-4">
-			<!-- Attach file button -->
-			<button
-				type="button"
-				onclick={openFilePicker}
-				disabled={loading}
-				class="flex items-center justify-center w-7 h-7 rounded text-exo-light-gray hover:text-exo-yellow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 cursor-pointer"
-				title="Attach file"
-			>
-				<svg
-					class="w-4 h-4"
-					fill="none"
-					viewBox="0 0 24 24"
-					stroke="currentColor"
-				>
-					<path
-						stroke-linecap="round"
-						stroke-linejoin="round"
-						stroke-width="2"
-						d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
-					/>
-				</svg>
-			</button>
-
-			<!-- Terminal prompt -->
-			<span class="text-exo-yellow text-sm font-bold flex-shrink-0 leading-7"
-				>▶</span
-			>
-
-			<textarea
-				bind:this={textareaRef}
-				bind:value={message}
-				onkeydown={handleKeydown}
-				oninput={handleInput}
-				onpaste={handlePaste}
-				placeholder={isEditOnlyWithoutImage
-					? "Attach an image to edit..."
-					: isEditMode
-						? "Describe how to edit this image..."
-						: isImageModel()
-							? "Describe the image you want to generate..."
-							: placeholder}
-				disabled={loading}
-				rows={1}
-				class="flex-1 resize-none bg-transparent text-foreground placeholder:text-exo-light-gray/60 placeholder:text-sm placeholder:tracking-[0.15em] placeholder:leading-7 focus:outline-none focus:ring-0 focus:border-none disabled:opacity-50 text-sm leading-7 font-mono"
-				style="min-height: 28px; max-height: 150px;"
-			></textarea>
-
-			<button
-				type="submit"
-				disabled={!canSend || loading || isEditOnlyWithoutImage}
-				class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
+  <div
+    class="relative command-panel rounded overflow-hidden transition-all duration-200 {isDragOver
+      ? 'ring-2 ring-exo-yellow ring-opacity-50'
+      : ''}"
+  >
+    <!-- Top accent line -->
+    <div
+      class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/50 to-transparent"
+    ></div>
+
+    <!-- Drag overlay -->
+    {#if isDragOver}
+      <div
+        class="absolute inset-0 bg-exo-dark-gray/80 z-10 flex items-center justify-center"
+      >
+        <div class="text-exo-yellow text-sm font-mono tracking-wider uppercase">
+          DROP FILES HERE
+        </div>
+      </div>
+    {/if}
+
+    <!-- Edit mode banner -->
+    {#if isEditMode && currentEditingImage}
+      <div
+        class="flex items-center gap-3 px-3 py-2 bg-exo-yellow/10 border-b border-exo-yellow/30"
+      >
+        <img
+          src={currentEditingImage.imageDataUrl}
+          alt="Source for editing"
+          class="w-10 h-10 object-cover rounded border border-exo-yellow/30"
+        />
+        <div class="flex-1">
+          <span
+            class="text-xs font-mono tracking-wider uppercase text-exo-yellow"
+            >EDITING IMAGE</span
+          >
+        </div>
+        <button
+          type="button"
+          onclick={() => clearEditingImage()}
+          class="px-2 py-1 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50 rounded hover:bg-exo-medium-gray/50 hover:text-exo-yellow transition-colors cursor-pointer"
+        >
+          CANCEL
+        </button>
+      </div>
+    {/if}
+
+    <!-- Model selector (when enabled) -->
+    {#if showModelSelector && availableModels().length > 0}
+      <div
+        class="flex items-center justify-between gap-2 px-3 py-2 border-b border-exo-medium-gray/30"
+      >
+        <div class="flex items-center gap-2 flex-1">
+          <span
+            class="text-xs text-exo-light-gray uppercase tracking-wider flex-shrink-0"
+            >MODEL:</span
+          >
+          <!-- Custom dropdown -->
+          <div class="relative flex-1 max-w-xs">
+            <button
+              bind:this={dropdownButtonRef}
+              type="button"
+              onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
+              class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-1.5 text-xs font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen
+                ? 'border-exo-yellow/70'
+                : ''}"
+            >
+              {#if availableModels().find((m) => m.id === currentModel)}
+                <span class="text-exo-yellow truncate"
+                  >{availableModels().find((m) => m.id === currentModel)
+                    ?.label}</span
+                >
+              {:else if availableModels().length > 0}
+                <span class="text-exo-yellow truncate"
+                  >{availableModels()[0].label}</span
+                >
+              {:else}
+                <span class="text-exo-light-gray/50">— SELECT MODEL —</span>
+              {/if}
+            </button>
+            <div
+              class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen
+                ? 'rotate-180'
+                : ''}"
+            >
+              <svg
+                class="w-3 h-3 text-exo-yellow/60"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  stroke-width="2"
+                  d="M19 9l-7 7-7-7"
+                />
+              </svg>
+            </div>
+          </div>
+
+          {#if isModelDropdownOpen}
+            <!-- Backdrop to close dropdown -->
+            <button
+              type="button"
+              class="fixed inset-0 z-[9998] cursor-default"
+              onclick={() => (isModelDropdownOpen = false)}
+              aria-label="Close dropdown"
+            ></button>
+
+            <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+            <div
+              class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto"
+              style="bottom: calc(100vh - {dropdownPosition()
+                .top}px + 4px); left: {dropdownPosition()
+                .left}px; width: {dropdownPosition().width}px;"
+            >
+              <div class="py-1">
+                {#each availableModels() as model}
+                  <button
+                    type="button"
+                    onclick={() => {
+                      setSelectedChatModel(model.id);
+                      isModelDropdownOpen = false;
+                    }}
+                    class="w-full px-3 py-2 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {currentModel ===
+                    model.id
+                      ? 'bg-transparent text-exo-yellow'
+                      : 'text-exo-light-gray hover:text-exo-yellow'}"
+                  >
+                    {#if currentModel === model.id}
+                      <svg
+                        class="w-3 h-3 flex-shrink-0"
+                        fill="currentColor"
+                        viewBox="0 0 20 20"
+                      >
+                        <path
+                          fill-rule="evenodd"
+                          d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+                          clip-rule="evenodd"
+                        />
+                      </svg>
+                    {:else}
+                      <span class="w-3"></span>
+                    {/if}
+                    {#if model.isImageModel}
+                      <svg
+                        class="w-3.5 h-3.5 flex-shrink-0 text-exo-yellow"
+                        fill="none"
+                        viewBox="0 0 24 24"
+                        stroke="currentColor"
+                        stroke-width="2"
+                        aria-label="Image generation model"
+                      >
+                        <rect
+                          x="3"
+                          y="3"
+                          width="18"
+                          height="18"
+                          rx="2"
+                          ry="2"
+                        />
+                        <circle cx="8.5" cy="8.5" r="1.5" />
+                        <polyline points="21 15 16 10 5 21" />
+                      </svg>
+                    {/if}
+                    <span class="truncate flex-1">{model.label}</span>
+                  </button>
+                {/each}
+              </div>
+            </div>
+          {/if}
+        </div>
+        <!-- Performance stats -->
+        {#if currentTtft !== null || currentTps !== null}
+          <div class="flex items-center gap-4 text-xs font-mono flex-shrink-0">
+            {#if currentTtft !== null}
+              <span class="text-exo-light-gray">
+                <span class="text-white/70">TTFT</span>
+                <span class="text-exo-yellow">{currentTtft.toFixed(1)}ms</span>
+              </span>
+            {/if}
+            {#if currentTps !== null}
+              <span class="text-exo-light-gray">
+                <span class="text-white/70">TPS</span>
+                <span class="text-exo-yellow">{currentTps.toFixed(1)}</span>
+                <span class="text-white/60">tok/s</span>
+                <span class="text-white/50"
+                  >({(1000 / currentTps).toFixed(1)} ms/tok)</span
+                >
+              </span>
+            {/if}
+          </div>
+        {/if}
+      </div>
+    {/if}
+
+    <!-- Image params panel (shown for image models or edit mode) -->
+    {#if showModelSelector && (isImageModel() || isEditMode)}
+      <ImageParamsPanel {isEditMode} />
+    {/if}
+
+    <!-- Attached files preview -->
+    {#if uploadedFiles.length > 0}
+      <div class="px-3 pt-3">
+        <ChatAttachments files={uploadedFiles} onRemove={handleFileRemove} />
+      </div>
+    {/if}
+
+    <!-- Input area -->
+    <div class="flex items-start gap-2 sm:gap-3 py-3 px-3 sm:px-4">
+      <!-- Attach file button -->
+      <button
+        type="button"
+        onclick={openFilePicker}
+        disabled={loading}
+        class="flex items-center justify-center w-7 h-7 rounded text-exo-light-gray hover:text-exo-yellow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 cursor-pointer"
+        title="Attach file"
+      >
+        <svg
+          class="w-4 h-4"
+          fill="none"
+          viewBox="0 0 24 24"
+          stroke="currentColor"
+        >
+          <path
+            stroke-linecap="round"
+            stroke-linejoin="round"
+            stroke-width="2"
+            d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
+          />
+        </svg>
+      </button>
+
+      <!-- Terminal prompt -->
+      <span class="text-exo-yellow text-sm font-bold flex-shrink-0 leading-7"
+        >▶</span
+      >
+
+      <textarea
+        bind:this={textareaRef}
+        bind:value={message}
+        onkeydown={handleKeydown}
+        oninput={handleInput}
+        onpaste={handlePaste}
+        placeholder={isEditOnlyWithoutImage
+          ? "Attach an image to edit..."
+          : isEditMode
+            ? "Describe how to edit this image..."
+            : isImageModel()
+              ? "Describe the image you want to generate..."
+              : placeholder}
+        disabled={loading}
+        rows={1}
+        class="flex-1 resize-none bg-transparent text-foreground placeholder:text-exo-light-gray/60 placeholder:text-sm placeholder:tracking-[0.15em] placeholder:leading-7 focus:outline-none focus:ring-0 focus:border-none disabled:opacity-50 text-sm leading-7 font-mono"
+        style="min-height: 28px; max-height: 150px;"
+      ></textarea>
+
+      <button
+        type="submit"
+        disabled={!canSend || loading || isEditOnlyWithoutImage}
+        class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
 					{!canSend || loading || isEditOnlyWithoutImage
-					? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed'
-					: 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
-				aria-label={shouldShowEditMode
-					? "Edit image"
-					: isImageModel()
-						? "Generate image"
-						: "Send message"}
-			>
-				{#if loading}
-					<span class="inline-flex items-center gap-1 sm:gap-2">
-						<span
-							class="w-2.5 h-2.5 sm:w-3 sm:h-3 border-2 border-current border-t-transparent rounded-full animate-spin"
-						></span>
-						<span class="hidden sm:inline"
-							>{shouldShowEditMode
-								? "EDITING"
-								: isImageModel()
-									? "GENERATING"
-									: "PROCESSING"}</span
-						>
-						<span class="sm:hidden">...</span>
-					</span>
-				{:else if shouldShowEditMode}
-					<span class="inline-flex items-center gap-1.5">
-						<svg
-							class="w-3.5 h-3.5"
-							fill="none"
-							viewBox="0 0 24 24"
-							stroke="currentColor"
-							stroke-width="2"
-						>
-							<path
-								stroke-linecap="round"
-								stroke-linejoin="round"
-								d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
-							/>
-						</svg>
-						<span>EDIT</span>
-					</span>
-				{:else if isImageModel()}
-					<span class="inline-flex items-center gap-1.5">
-						<svg
-							class="w-3.5 h-3.5"
-							fill="none"
-							viewBox="0 0 24 24"
-							stroke="currentColor"
-							stroke-width="2"
-						>
-							<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
-							<circle cx="8.5" cy="8.5" r="1.5" />
-							<polyline points="21 15 16 10 5 21" />
-						</svg>
-						<span>GENERATE</span>
-					</span>
-				{:else}
-					SEND
-				{/if}
-			</button>
-		</div>
-
-		<!-- Bottom accent line -->
-		<div
-			class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/30 to-transparent"
-		></div>
-	</div>
-
-	{#if showHelperText}
-		<p
-			class="mt-2 sm:mt-3 text-center text-xs sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase"
-		>
-			<kbd
-				class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
-				>ENTER</kbd
-			>
-			<span class="mx-0.5 sm:mx-1">TO SEND</span>
-			<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
-			<kbd
-				class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
-				>SHIFT+ENTER</kbd
-			>
-			<span class="mx-0.5 sm:mx-1">NEW LINE</span>
-			<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
-			<span class="text-exo-light-gray">DRAG & DROP OR PASTE FILES</span>
-		</p>
-	{/if}
+          ? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed'
+          : 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
+        aria-label={shouldShowEditMode
+          ? "Edit image"
+          : isImageModel()
+            ? "Generate image"
+            : "Send message"}
+      >
+        {#if loading}
+          <span class="inline-flex items-center gap-1 sm:gap-2">
+            <span
+              class="w-2.5 h-2.5 sm:w-3 sm:h-3 border-2 border-current border-t-transparent rounded-full animate-spin"
+            ></span>
+            <span class="hidden sm:inline"
+              >{shouldShowEditMode
+                ? "EDITING"
+                : isImageModel()
+                  ? "GENERATING"
+                  : "PROCESSING"}</span
+            >
+            <span class="sm:hidden">...</span>
+          </span>
+        {:else if shouldShowEditMode}
+          <span class="inline-flex items-center gap-1.5">
+            <svg
+              class="w-3.5 h-3.5"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+              stroke-width="2"
+            >
+              <path
+                stroke-linecap="round"
+                stroke-linejoin="round"
+                d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+              />
+            </svg>
+            <span>EDIT</span>
+          </span>
+        {:else if isImageModel()}
+          <span class="inline-flex items-center gap-1.5">
+            <svg
+              class="w-3.5 h-3.5"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+              stroke-width="2"
+            >
+              <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
+              <circle cx="8.5" cy="8.5" r="1.5" />
+              <polyline points="21 15 16 10 5 21" />
+            </svg>
+            <span>GENERATE</span>
+          </span>
+        {:else}
+          SEND
+        {/if}
+      </button>
+    </div>
+
+    <!-- Bottom accent line -->
+    <div
+      class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/30 to-transparent"
+    ></div>
+  </div>
+
+  {#if showHelperText}
+    <p
+      class="mt-2 sm:mt-3 text-center text-xs sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase"
+    >
+      <kbd
+        class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
+        >ENTER</kbd
+      >
+      <span class="mx-0.5 sm:mx-1">TO SEND</span>
+      <span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
+      <kbd
+        class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
+        >SHIFT+ENTER</kbd
+      >
+      <span class="mx-0.5 sm:mx-1">NEW LINE</span>
+      <span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
+      <span class="text-exo-light-gray">DRAG & DROP OR PASTE FILES</span>
+    </p>
+  {/if}
 </form>
diff --git a/dashboard/src/lib/components/ChatMessages.svelte b/dashboard/src/lib/components/ChatMessages.svelte
index 90e03128..15ea088d 100644
--- a/dashboard/src/lib/components/ChatMessages.svelte
+++ b/dashboard/src/lib/components/ChatMessages.svelte
@@ -1,536 +1,733 @@
 <script lang="ts">
-	import {
-		messages,
-		currentResponse,
-		isLoading,
-		deleteMessage,
-		editAndRegenerate,
-		regenerateLastResponse,
-		setEditingImage
-	} from '$lib/stores/app.svelte';
-	import type { Message } from '$lib/stores/app.svelte';
-	import type { MessageAttachment } from '$lib/stores/app.svelte';
-	import MarkdownContent from './MarkdownContent.svelte';
-
-	interface Props {
-		class?: string;
-		scrollParent?: HTMLElement | null;
-	}
-
-	let { class: className = '', scrollParent = null }: Props = $props();
-
-	const messageList = $derived(messages());
-	const response = $derived(currentResponse());
-	const loading = $derived(isLoading());
-
-	// Scroll management - user controls scroll, show button when not at bottom
-	const SCROLL_THRESHOLD = 100;
-	let showScrollButton = $state(false);
-	let lastMessageCount = 0;
-	let containerRef: HTMLDivElement | undefined = $state();
-
-	function getScrollContainer(): HTMLElement | null {
-		if (scrollParent) return scrollParent;
-		return containerRef?.parentElement ?? null;
-	}
-
-	function isNearBottom(el: HTMLElement): boolean {
-		return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
-	}
-
-	function scrollToBottom() {
-		const el = getScrollContainer();
-		if (el) {
-			el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
-		}
-	}
-
-	function updateScrollButtonVisibility() {
-		const el = getScrollContainer();
-		if (!el) return;
-		showScrollButton = !isNearBottom(el);
-	}
-
-	// Attach scroll listener
-	$effect(() => {
-		const el = scrollParent ?? containerRef?.parentElement;
-		if (!el) return;
-		
-		el.addEventListener('scroll', updateScrollButtonVisibility, { passive: true });
-		// Initial check
-		updateScrollButtonVisibility();
-		return () => el.removeEventListener('scroll', updateScrollButtonVisibility);
-	});
-
-	// Auto-scroll when user sends a new message
-	$effect(() => {
-		const count = messageList.length;
-		if (count > lastMessageCount) {
-			const el = getScrollContainer();
-			if (el) {
-				requestAnimationFrame(() => {
-					el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
-				});
-			}
-		}
-		lastMessageCount = count;
-	});
-
-	// Update scroll button visibility when content changes
-	$effect(() => {
-		// Track response to trigger re-check during streaming
-		const _ = response;
-		
-		// Small delay to let DOM update
-		requestAnimationFrame(() => updateScrollButtonVisibility());
-	});
-
-	// Edit state
-	let editingMessageId = $state<string | null>(null);
-	let editContent = $state('');
-	let editTextareaRef: HTMLTextAreaElement | undefined = $state();
-
-	// Delete confirmation state
-	let deleteConfirmId = $state<string | null>(null);
-
-// Copied state for feedback
-let copiedMessageId = $state<string | null>(null);
-let expandedThinkingMessageIds = $state<Set<string>>(new Set());
-
-	function formatTimestamp(timestamp: number): string {
-		return new Date(timestamp).toLocaleTimeString('en-US', { 
-			hour12: false,
-			hour: '2-digit', 
-			minute: '2-digit',
-			second: '2-digit'
-		});
-	}
-
-	function getAttachmentIcon(attachment: MessageAttachment): string {
-		switch (attachment.type) {
-			case 'image': return '🖼';
-			case 'text': return '📄';
-			default: return '📎';
-		}
-	}
-
-	function truncateName(name: string, maxLen: number = 25): string {
-		if (name.length <= maxLen) return name;
-		const ext = name.slice(name.lastIndexOf('.'));
-		const base = name.slice(0, name.lastIndexOf('.'));
-		const available = maxLen - ext.length - 3;
-		return base.slice(0, available) + '...' + ext;
-	}
-
-	async function handleCopy(content: string, messageId: string) {
-		try {
-			await navigator.clipboard.writeText(content);
-			copiedMessageId = messageId;
-			setTimeout(() => {
-				copiedMessageId = null;
-			}, 2000);
-		} catch (error) {
-			console.error('Failed to copy:', error);
-		}
-	}
-
-function toggleThinkingVisibility(messageId: string) {
-	const next = new Set(expandedThinkingMessageIds);
-	if (next.has(messageId)) {
-		next.delete(messageId);
-	} else {
-		next.add(messageId);
-	}
-	expandedThinkingMessageIds = next;
-}
-
-function isThinkingExpanded(messageId: string): boolean {
-	return expandedThinkingMessageIds.has(messageId);
-}
-
-	function handleStartEdit(messageId: string, content: string) {
-		editingMessageId = messageId;
-		editContent = content;
-		setTimeout(() => {
-			if (editTextareaRef) {
-				editTextareaRef.focus();
-				editTextareaRef.setSelectionRange(editTextareaRef.value.length, editTextareaRef.value.length);
-				// Auto-resize
-				editTextareaRef.style.height = 'auto';
-				editTextareaRef.style.height = Math.min(editTextareaRef.scrollHeight, 200) + 'px';
-			}
-		}, 10);
-	}
-
-	function handleCancelEdit() {
-		editingMessageId = null;
-		editContent = '';
-	}
-
-	function handleSaveEdit() {
-		if (editingMessageId && editContent.trim()) {
-			editAndRegenerate(editingMessageId, editContent.trim());
-		}
-		editingMessageId = null;
-		editContent = '';
-	}
-
-	function handleEditKeydown(event: KeyboardEvent) {
-		if (event.key === 'Enter' && !event.shiftKey) {
-			event.preventDefault();
-			handleSaveEdit();
-		} else if (event.key === 'Escape') {
-			handleCancelEdit();
-		}
-	}
-
-	function handleEditInput() {
-		if (editTextareaRef) {
-			editTextareaRef.style.height = 'auto';
-			editTextareaRef.style.height = Math.min(editTextareaRef.scrollHeight, 200) + 'px';
-		}
-	}
-
-	function handleDeleteClick(messageId: string) {
-		deleteConfirmId = messageId;
-	}
-
-	function handleConfirmDelete() {
-		if (deleteConfirmId) {
-			deleteMessage(deleteConfirmId);
-			deleteConfirmId = null;
-		}
-	}
-
-	function handleCancelDelete() {
-		deleteConfirmId = null;
-	}
-
-	function handleRegenerate() {
-		regenerateLastResponse();
-	}
-
-	// Check if a message is the last assistant message
-	function isLastAssistantMessage(messageId: string): boolean {
-		for (let i = messageList.length - 1; i >= 0; i--) {
-			if (messageList[i].role === 'assistant') {
-				return messageList[i].id === messageId;
-			}
-		}
-		return false;
-	}
+  import {
+    messages,
+    currentResponse,
+    isLoading,
+    deleteMessage,
+    editAndRegenerate,
+    regenerateLastResponse,
+    setEditingImage,
+  } from "$lib/stores/app.svelte";
+  import type { Message } from "$lib/stores/app.svelte";
+  import type { MessageAttachment } from "$lib/stores/app.svelte";
+  import MarkdownContent from "./MarkdownContent.svelte";
+
+  interface Props {
+    class?: string;
+    scrollParent?: HTMLElement | null;
+  }
+
+  let { class: className = "", scrollParent = null }: Props = $props();
+
+  const messageList = $derived(messages());
+  const response = $derived(currentResponse());
+  const loading = $derived(isLoading());
+
+  // Scroll management - user controls scroll, show button when not at bottom
+  const SCROLL_THRESHOLD = 100;
+  let showScrollButton = $state(false);
+  let lastMessageCount = 0;
+  let containerRef: HTMLDivElement | undefined = $state();
+
+  function getScrollContainer(): HTMLElement | null {
+    if (scrollParent) return scrollParent;
+    return containerRef?.parentElement ?? null;
+  }
+
+  function isNearBottom(el: HTMLElement): boolean {
+    return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
+  }
+
+  function scrollToBottom() {
+    const el = getScrollContainer();
+    if (el) {
+      el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+    }
+  }
+
+  function updateScrollButtonVisibility() {
+    const el = getScrollContainer();
+    if (!el) return;
+    showScrollButton = !isNearBottom(el);
+  }
+
+  // Attach scroll listener
+  $effect(() => {
+    const el = scrollParent ?? containerRef?.parentElement;
+    if (!el) return;
+
+    el.addEventListener("scroll", updateScrollButtonVisibility, {
+      passive: true,
+    });
+    // Initial check
+    updateScrollButtonVisibility();
+    return () => el.removeEventListener("scroll", updateScrollButtonVisibility);
+  });
+
+  // Auto-scroll when user sends a new message
+  $effect(() => {
+    const count = messageList.length;
+    if (count > lastMessageCount) {
+      const el = getScrollContainer();
+      if (el) {
+        requestAnimationFrame(() => {
+          el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+        });
+      }
+    }
+    lastMessageCount = count;
+  });
+
+  // Update scroll button visibility when content changes
+  $effect(() => {
+    // Track response to trigger re-check during streaming
+    const _ = response;
+
+    // Small delay to let DOM update
+    requestAnimationFrame(() => updateScrollButtonVisibility());
+  });
+
+  // Edit state
+  let editingMessageId = $state<string | null>(null);
+  let editContent = $state("");
+  let editTextareaRef: HTMLTextAreaElement | undefined = $state();
+
+  // Delete confirmation state
+  let deleteConfirmId = $state<string | null>(null);
+
+  // Copied state for feedback
+  let copiedMessageId = $state<string | null>(null);
+  let expandedThinkingMessageIds = $state<Set<string>>(new Set());
+
+  function formatTimestamp(timestamp: number): string {
+    return new Date(timestamp).toLocaleTimeString("en-US", {
+      hour12: false,
+      hour: "2-digit",
+      minute: "2-digit",
+      second: "2-digit",
+    });
+  }
+
+  function getAttachmentIcon(attachment: MessageAttachment): string {
+    switch (attachment.type) {
+      case "image":
+        return "🖼";
+      case "text":
+        return "📄";
+      default:
+        return "📎";
+    }
+  }
+
+  function truncateName(name: string, maxLen: number = 25): string {
+    if (name.length <= maxLen) return name;
+    const ext = name.slice(name.lastIndexOf("."));
+    const base = name.slice(0, name.lastIndexOf("."));
+    const available = maxLen - ext.length - 3;
+    return base.slice(0, available) + "..." + ext;
+  }
+
+  async function handleCopy(content: string, messageId: string) {
+    try {
+      await navigator.clipboard.writeText(content);
+      copiedMessageId = messageId;
+      setTimeout(() => {
+        copiedMessageId = null;
+      }, 2000);
+    } catch (error) {
+      console.error("Failed to copy:", error);
+    }
+  }
+
+  function toggleThinkingVisibility(messageId: string) {
+    const next = new Set(expandedThinkingMessageIds);
+    if (next.has(messageId)) {
+      next.delete(messageId);
+    } else {
+      next.add(messageId);
+    }
+    expandedThinkingMessageIds = next;
+  }
+
+  function isThinkingExpanded(messageId: string): boolean {
+    return expandedThinkingMessageIds.has(messageId);
+  }
+
+  function handleStartEdit(messageId: string, content: string) {
+    editingMessageId = messageId;
+    editContent = content;
+    setTimeout(() => {
+      if (editTextareaRef) {
+        editTextareaRef.focus();
+        editTextareaRef.setSelectionRange(
+          editTextareaRef.value.length,
+          editTextareaRef.value.length,
+        );
+        // Auto-resize
+        editTextareaRef.style.height = "auto";
+        editTextareaRef.style.height =
+          Math.min(editTextareaRef.scrollHeight, 200) + "px";
+      }
+    }, 10);
+  }
+
+  function handleCancelEdit() {
+    editingMessageId = null;
+    editContent = "";
+  }
+
+  function handleSaveEdit() {
+    if (editingMessageId && editContent.trim()) {
+      editAndRegenerate(editingMessageId, editContent.trim());
+    }
+    editingMessageId = null;
+    editContent = "";
+  }
+
+  function handleEditKeydown(event: KeyboardEvent) {
+    if (event.key === "Enter" && !event.shiftKey) {
+      event.preventDefault();
+      handleSaveEdit();
+    } else if (event.key === "Escape") {
+      handleCancelEdit();
+    }
+  }
+
+  function handleEditInput() {
+    if (editTextareaRef) {
+      editTextareaRef.style.height = "auto";
+      editTextareaRef.style.height =
+        Math.min(editTextareaRef.scrollHeight, 200) + "px";
+    }
+  }
+
+  function handleDeleteClick(messageId: string) {
+    deleteConfirmId = messageId;
+  }
+
+  function handleConfirmDelete() {
+    if (deleteConfirmId) {
+      deleteMessage(deleteConfirmId);
+      deleteConfirmId = null;
+    }
+  }
+
+  function handleCancelDelete() {
+    deleteConfirmId = null;
+  }
+
+  function handleRegenerate() {
+    regenerateLastResponse();
+  }
+
+  // Check if a message is the last assistant message
+  function isLastAssistantMessage(messageId: string): boolean {
+    for (let i = messageList.length - 1; i >= 0; i--) {
+      if (messageList[i].role === "assistant") {
+        return messageList[i].id === messageId;
+      }
+    }
+    return false;
+  }
 </script>
 
 <div class="flex flex-col gap-4 sm:gap-6 {className}">
-	{#each messageList as message (message.id)}
-		<div class="group flex {message.role === 'user' ? 'justify-end' : 'justify-start'}">
-			<div class="{message.role === 'user' ? 'max-w-[85%] sm:max-w-[70%] flex flex-col items-end' : 'w-full max-w-[98%] sm:max-w-[95%]'}">
-				{#if message.role === 'assistant'}
-					<!-- Assistant message header -->
-					<div class="flex items-center gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
-						<div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow rounded-full shadow-[0_0_10px_rgba(255,215,0,0.5)]"></div>
-						<span class="text-sm sm:text-xs text-exo-yellow tracking-[0.15em] sm:tracking-[0.2em] uppercase font-medium">EXO</span>
-						<span class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums">{formatTimestamp(message.timestamp)}</span>
-						{#if message.ttftMs || message.tps}
-							<span class="text-xs text-exo-light-gray/80 font-mono ml-2">
-								{#if message.ttftMs}<span class="text-exo-light-gray/50">TTFT</span> {message.ttftMs.toFixed(0)}ms{/if}{#if message.ttftMs && message.tps}<span class="text-exo-light-gray/30 mx-1">•</span>{/if}{#if message.tps}{message.tps.toFixed(1)} <span class="text-exo-light-gray/50">tok/s</span>{/if}
-							</span>
-						{/if}
-					</div>
-				{:else}
-					<!-- User message header -->
-					<div class="flex items-center justify-end gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
-						<span class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums">{formatTimestamp(message.timestamp)}</span>
-						<span class="text-sm sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase">QUERY</span>
-						<div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-light-gray/50 rounded-full"></div>
-					</div>
-				{/if}
-				
-				{#if deleteConfirmId === message.id}
-					<!-- Delete confirmation -->
-					<div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
-						<p class="text-xs text-red-400 mb-3">Delete this message{message.role === 'user' ? ' and all responses after it' : ''}?</p>
-						<div class="flex gap-2 justify-end">
-						<button
-							onclick={handleCancelDelete}
-							class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
-						>
-							CANCEL
-						</button>
-						<button
-							onclick={handleConfirmDelete}
-							class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
-						>
-								DELETE
-							</button>
-						</div>
-					</div>
-				{:else if editingMessageId === message.id}
-					<!-- Edit mode -->
-					<div class="command-panel rounded-lg p-3">
-						<textarea
-							bind:this={editTextareaRef}
-							bind:value={editContent}
-							onkeydown={handleEditKeydown}
-							oninput={handleEditInput}
-							class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-3 py-2 text-sm text-foreground font-mono focus:outline-none focus:border-exo-yellow/50 resize-none"
-							style="min-height: 60px; max-height: 200px;"
-						></textarea>
-						<div class="flex gap-2 justify-end mt-2">
-						<button
-							onclick={handleCancelEdit}
-							class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
-						>
-							CANCEL
-						</button>
-						<button
-							onclick={handleSaveEdit}
-							disabled={!editContent.trim()}
-							class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer"
-						>
-								<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
-								</svg>
-								SEND
-							</button>
-						</div>
-					</div>
-				{:else}
-					<div class="{message.role === 'user' 
-						? 'command-panel rounded-lg rounded-tr-sm inline-block' 
-						: 'command-panel rounded-lg rounded-tl-sm border-l-2 border-l-exo-yellow/50 block w-full'}">
-						
-						{#if message.role === 'user'}
-							<!-- User message styling -->
-							<div class="px-4 py-3">
-								<!-- Attachments -->
-								{#if message.attachments && message.attachments.length > 0}
-									<div class="flex flex-wrap gap-2 mb-3">
-										{#each message.attachments as attachment}
-											<div class="flex items-center gap-2 bg-exo-dark-gray/60 border border-exo-yellow/20 rounded px-2 py-1 text-xs font-mono">
-												{#if attachment.type === 'image' && attachment.preview}
-													<img 
-														src={attachment.preview} 
-														alt={attachment.name}
-														class="w-12 h-12 object-cover rounded border border-exo-yellow/20"
-													/>
-												{:else}
-													<span>{getAttachmentIcon(attachment)}</span>
-												{/if}
-												<span class="text-exo-yellow" title={attachment.name}>{truncateName(attachment.name)}</span>
-											</div>
-										{/each}
-									</div>
-								{/if}
-								
-								{#if message.content}
-									<div class="text-xs text-foreground font-mono tracking-wide whitespace-pre-wrap break-words leading-relaxed">
-										{message.content}
-									</div>
-								{/if}
-							</div>
-						{:else}
-							<!-- Assistant message styling -->
-							<div class="p-3 sm:p-4">
-								{#if message.thinking && message.thinking.trim().length > 0}
-									<div class="mb-3 rounded border border-exo-yellow/20 bg-exo-black/40">
-										<button
-											type="button"
-											class="w-full flex items-center justify-between px-3 py-2 text-xs font-mono uppercase tracking-[0.2em] text-exo-light-gray/80 hover:text-exo-yellow transition-colors cursor-pointer"
-											onclick={() => toggleThinkingVisibility(message.id)}
-											aria-expanded={isThinkingExpanded(message.id)}
-											aria-controls={`thinking-panel-${message.id}`}
-										>
-											<span class="flex items-center gap-2 tracking-[0.25em]">
-												<svg
-													class={`w-3.5 h-3.5 text-current transition-transform duration-200 ${isThinkingExpanded(message.id) ? 'rotate-90' : ''}`}
-													fill="none"
-													viewBox="0 0 24 24"
-													stroke="currentColor"
-													aria-hidden="true"
-												>
-													<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
-												</svg>
-												<span>Thinking...</span>
-											</span>
-											<span class="text-[10px] tracking-[0.2em] text-exo-light-gray/60 ml-4">
-												{isThinkingExpanded(message.id) ? 'HIDE' : 'SHOW'}
-											</span>
-										</button>
-										{#if isThinkingExpanded(message.id)}
-											<div
-												id={`thinking-panel-${message.id}`}
-												class="px-3 pb-3 text-xs text-exo-light-gray/90 font-mono whitespace-pre-wrap break-words leading-relaxed"
-											>
-												{message.thinking.trim()}
-											</div>
-										{/if}
-									</div>
-								{/if}
-								
-								<!-- Generated Images -->
-								{#if message.attachments?.some(a => a.type === 'generated-image')}
-									<div class="mb-3">
-										{#each message.attachments.filter(a => a.type === 'generated-image') as attachment}
-											<div class="relative group/img inline-block">
-												<img 
-													src={attachment.preview} 
-													alt=""
-													class="max-w-full max-h-[512px] rounded-lg border border-exo-yellow/20 shadow-lg shadow-black/20"
-												/>
-												<!-- Button overlay -->
-												<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity">
-													<!-- Edit button -->
-													<button
-														type="button"
-														class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
-														onclick={() => {
-															if (attachment.preview) {
-																setEditingImage(attachment.preview, message);
-															}
-														}}
-														title="Edit image"
-													>
-														<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
-															<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
-														</svg>
-													</button>
-													<!-- Download button -->
-													<button
-														type="button"
-														class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
-														onclick={() => {
-															if (attachment.preview) {
-																const link = document.createElement('a');
-																link.href = attachment.preview;
-																const ext = attachment.name?.split('.').pop() || 'png';
-																link.download = `generated-image-${Date.now()}.${ext}`;
-																link.click();
-															}
-														}}
-														title="Download image"
-													>
-														<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
-															<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
-														</svg>
-													</button>
-												</div>
-											</div>
-										{/each}
-									</div>
-								{/if}
-								
-								<div class="text-xs text-foreground">
-									{#if message.content === 'Generating image...' || message.content === 'Editing image...' || message.content?.startsWith('Generating...') || message.content?.startsWith('Editing...')}
-										<div class="flex items-center gap-3 text-exo-yellow">
-											<div class="relative">
-												<div class="w-8 h-8 border-2 border-exo-yellow/30 border-t-exo-yellow rounded-full animate-spin"></div>
-												<svg class="absolute inset-0 w-8 h-8 p-1.5 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
-													<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
-													<circle cx="8.5" cy="8.5" r="1.5"/>
-													<polyline points="21 15 16 10 5 21"/>
-												</svg>
-											</div>
-											<span class="font-mono tracking-wider uppercase text-sm">{message.content}</span>
-										</div>
-									{:else if message.content || (loading && !message.attachments?.some(a => a.type === 'generated-image'))}
-										<MarkdownContent content={message.content || (loading ? response : '')} />
-										{#if loading && !message.content}
-											<span class="inline-block w-2 h-4 bg-exo-yellow/70 ml-1 cursor-blink"></span>
-										{/if}
-									{/if}
-								</div>
-							</div>
-						{/if}
-					</div>
-					
-					<!-- Action buttons -->
-					<div class="flex items-center gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity {message.role === 'user' ? 'justify-end' : 'justify-start'}">
-						<!-- Copy button -->
-						<button
-							onclick={() => handleCopy(message.content, message.id)}
-							class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
-							title="Copy message"
-						>
-							{#if copiedMessageId === message.id}
-								<svg class="w-3.5 h-3.5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
-								</svg>
-							{:else}
-								<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
-								</svg>
-							{/if}
-						</button>
-						
-						<!-- Edit button (user messages only) -->
-						{#if message.role === 'user'}
-							<button
-								onclick={() => handleStartEdit(message.id, message.content)}
-								class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
-								title="Edit message"
-							>
-								<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
-								</svg>
-							</button>
-						{/if}
-						
-						<!-- Regenerate button (last assistant message only) -->
-						{#if message.role === 'assistant' && isLastAssistantMessage(message.id) && !loading}
-							<button
-								onclick={handleRegenerate}
-								class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
-								title="Regenerate response"
-							>
-								<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
-								</svg>
-							</button>
-						{/if}
-						
-						<!-- Delete button -->
-						<button
-							onclick={() => handleDeleteClick(message.id)}
-							class="p-1.5 text-exo-light-gray hover:text-red-400 transition-colors rounded hover:bg-red-500/10 cursor-pointer"
-							title="Delete message"
-						>
-							<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-								<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
-							</svg>
-						</button>
-					</div>
-				{/if}
-			</div>
-		</div>
-	{/each}
-	
-	{#if messageList.length === 0}
-		<div class="flex-1 flex flex-col items-center justify-center text-center pt-[20vh]">
-			<div class="w-12 h-12 sm:w-16 sm:h-16 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3 sm:mb-4">
-				<div class="w-6 h-6 sm:w-8 sm:h-8 border border-exo-yellow/40 rounded-full flex items-center justify-center">
-					<div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow/60 rounded-full"></div>
-				</div>
-			</div>
-			<p class="text-xs sm:text-sm text-exo-light-gray tracking-[0.15em] sm:tracking-[0.2em] uppercase">AWAITING INPUT</p>
-			<p class="text-sm sm:text-xs text-exo-light-gray tracking-wider mt-1">ENTER A QUERY TO BEGIN</p>
-		</div>
-	{/if}
-	
-	<!-- Invisible element for container reference -->
-	<div bind:this={containerRef}></div>
-
-	<!-- Scroll to bottom button -->
-	{#if showScrollButton}
-		<button
-			type="button"
-			onclick={scrollToBottom}
-			class="sticky bottom-4 left-1/2 -translate-x-1/2 w-10 h-10 rounded-full bg-exo-dark-gray/90 border border-exo-medium-gray/50 flex items-center justify-center text-exo-light-gray hover:text-exo-yellow hover:border-exo-yellow/50 transition-all shadow-lg cursor-pointer z-10"
-			title="Scroll to bottom"
-		>
-			<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-				<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
-			</svg>
-		</button>
-	{/if}
+  {#each messageList as message (message.id)}
+    <div
+      class="group flex {message.role === 'user'
+        ? 'justify-end'
+        : 'justify-start'}"
+    >
+      <div
+        class={message.role === "user"
+          ? "max-w-[85%] sm:max-w-[70%] flex flex-col items-end"
+          : "w-full max-w-[98%] sm:max-w-[95%]"}
+      >
+        {#if message.role === "assistant"}
+          <!-- Assistant message header -->
+          <div class="flex items-center gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
+            <div
+              class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow rounded-full shadow-[0_0_10px_rgba(255,215,0,0.5)]"
+            ></div>
+            <span
+              class="text-sm sm:text-xs text-exo-yellow tracking-[0.15em] sm:tracking-[0.2em] uppercase font-medium"
+              >EXO</span
+            >
+            <span
+              class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums"
+              >{formatTimestamp(message.timestamp)}</span
+            >
+            {#if message.ttftMs || message.tps}
+              <span class="text-xs text-exo-light-gray/80 font-mono ml-2">
+                {#if message.ttftMs}<span class="text-exo-light-gray/50"
+                    >TTFT</span
+                  >
+                  {message.ttftMs.toFixed(
+                    0,
+                  )}ms{/if}{#if message.ttftMs && message.tps}<span
+                    class="text-exo-light-gray/30 mx-1">•</span
+                  >{/if}{#if message.tps}{message.tps.toFixed(1)}
+                  <span class="text-exo-light-gray/50">tok/s</span>{/if}
+              </span>
+            {/if}
+          </div>
+        {:else}
+          <!-- User message header -->
+          <div
+            class="flex items-center justify-end gap-1.5 sm:gap-2 mb-1.5 sm:mb-2"
+          >
+            <span
+              class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums"
+              >{formatTimestamp(message.timestamp)}</span
+            >
+            <span
+              class="text-sm sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase"
+              >QUERY</span
+            >
+            <div
+              class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-light-gray/50 rounded-full"
+            ></div>
+          </div>
+        {/if}
+
+        {#if deleteConfirmId === message.id}
+          <!-- Delete confirmation -->
+          <div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
+            <p class="text-xs text-red-400 mb-3">
+              Delete this message{message.role === "user"
+                ? " and all responses after it"
+                : ""}?
+            </p>
+            <div class="flex gap-2 justify-end">
+              <button
+                onclick={handleCancelDelete}
+                class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+              >
+                CANCEL
+              </button>
+              <button
+                onclick={handleConfirmDelete}
+                class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
+              >
+                DELETE
+              </button>
+            </div>
+          </div>
+        {:else if editingMessageId === message.id}
+          <!-- Edit mode -->
+          <div class="command-panel rounded-lg p-3">
+            <textarea
+              bind:this={editTextareaRef}
+              bind:value={editContent}
+              onkeydown={handleEditKeydown}
+              oninput={handleEditInput}
+              class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-3 py-2 text-sm text-foreground font-mono focus:outline-none focus:border-exo-yellow/50 resize-none"
+              style="min-height: 60px; max-height: 200px;"
+            ></textarea>
+            <div class="flex gap-2 justify-end mt-2">
+              <button
+                onclick={handleCancelEdit}
+                class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+              >
+                CANCEL
+              </button>
+              <button
+                onclick={handleSaveEdit}
+                disabled={!editContent.trim()}
+                class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer"
+              >
+                <svg
+                  class="w-3 h-3"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                >
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    stroke-width="2"
+                    d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
+                  />
+                </svg>
+                SEND
+              </button>
+            </div>
+          </div>
+        {:else}
+          <div
+            class={message.role === "user"
+              ? "command-panel rounded-lg rounded-tr-sm inline-block"
+              : "command-panel rounded-lg rounded-tl-sm border-l-2 border-l-exo-yellow/50 block w-full"}
+          >
+            {#if message.role === "user"}
+              <!-- User message styling -->
+              <div class="px-4 py-3">
+                <!-- Attachments -->
+                {#if message.attachments && message.attachments.length > 0}
+                  <div class="flex flex-wrap gap-2 mb-3">
+                    {#each message.attachments as attachment}
+                      <div
+                        class="flex items-center gap-2 bg-exo-dark-gray/60 border border-exo-yellow/20 rounded px-2 py-1 text-xs font-mono"
+                      >
+                        {#if attachment.type === "image" && attachment.preview}
+                          <img
+                            src={attachment.preview}
+                            alt={attachment.name}
+                            class="w-12 h-12 object-cover rounded border border-exo-yellow/20"
+                          />
+                        {:else}
+                          <span>{getAttachmentIcon(attachment)}</span>
+                        {/if}
+                        <span class="text-exo-yellow" title={attachment.name}
+                          >{truncateName(attachment.name)}</span
+                        >
+                      </div>
+                    {/each}
+                  </div>
+                {/if}
+
+                {#if message.content}
+                  <div
+                    class="text-xs text-foreground font-mono tracking-wide whitespace-pre-wrap break-words leading-relaxed"
+                  >
+                    {message.content}
+                  </div>
+                {/if}
+              </div>
+            {:else}
+              <!-- Assistant message styling -->
+              <div class="p-3 sm:p-4">
+                {#if message.thinking && message.thinking.trim().length > 0}
+                  <div
+                    class="mb-3 rounded border border-exo-yellow/20 bg-exo-black/40"
+                  >
+                    <button
+                      type="button"
+                      class="w-full flex items-center justify-between px-3 py-2 text-xs font-mono uppercase tracking-[0.2em] text-exo-light-gray/80 hover:text-exo-yellow transition-colors cursor-pointer"
+                      onclick={() => toggleThinkingVisibility(message.id)}
+                      aria-expanded={isThinkingExpanded(message.id)}
+                      aria-controls={`thinking-panel-${message.id}`}
+                    >
+                      <span class="flex items-center gap-2 tracking-[0.25em]">
+                        <svg
+                          class={`w-3.5 h-3.5 text-current transition-transform duration-200 ${isThinkingExpanded(message.id) ? "rotate-90" : ""}`}
+                          fill="none"
+                          viewBox="0 0 24 24"
+                          stroke="currentColor"
+                          aria-hidden="true"
+                        >
+                          <path
+                            stroke-linecap="round"
+                            stroke-linejoin="round"
+                            stroke-width="2"
+                            d="M9 5l7 7-7 7"
+                          />
+                        </svg>
+                        <span>Thinking...</span>
+                      </span>
+                      <span
+                        class="text-[10px] tracking-[0.2em] text-exo-light-gray/60 ml-4"
+                      >
+                        {isThinkingExpanded(message.id) ? "HIDE" : "SHOW"}
+                      </span>
+                    </button>
+                    {#if isThinkingExpanded(message.id)}
+                      <div
+                        id={`thinking-panel-${message.id}`}
+                        class="px-3 pb-3 text-xs text-exo-light-gray/90 font-mono whitespace-pre-wrap break-words leading-relaxed"
+                      >
+                        {message.thinking.trim()}
+                      </div>
+                    {/if}
+                  </div>
+                {/if}
+
+                <!-- Generated Images -->
+                {#if message.attachments?.some((a) => a.type === "generated-image")}
+                  <div class="mb-3">
+                    {#each message.attachments.filter((a) => a.type === "generated-image") as attachment}
+                      <div class="relative group/img inline-block">
+                        <img
+                          src={attachment.preview}
+                          alt=""
+                          class="max-w-full max-h-[512px] rounded-lg border border-exo-yellow/20 shadow-lg shadow-black/20"
+                        />
+                        <!-- Button overlay -->
+                        <div
+                          class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity"
+                        >
+                          <!-- Edit button -->
+                          <button
+                            type="button"
+                            class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
+                            onclick={() => {
+                              if (attachment.preview) {
+                                setEditingImage(attachment.preview, message);
+                              }
+                            }}
+                            title="Edit image"
+                          >
+                            <svg
+                              class="w-4 h-4"
+                              fill="none"
+                              viewBox="0 0 24 24"
+                              stroke="currentColor"
+                              stroke-width="2"
+                            >
+                              <path
+                                stroke-linecap="round"
+                                stroke-linejoin="round"
+                                d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+                              />
+                            </svg>
+                          </button>
+                          <!-- Download button -->
+                          <button
+                            type="button"
+                            class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
+                            onclick={() => {
+                              if (attachment.preview) {
+                                const link = document.createElement("a");
+                                link.href = attachment.preview;
+                                const ext =
+                                  attachment.name?.split(".").pop() || "png";
+                                link.download = `generated-image-${Date.now()}.${ext}`;
+                                link.click();
+                              }
+                            }}
+                            title="Download image"
+                          >
+                            <svg
+                              class="w-4 h-4"
+                              fill="none"
+                              viewBox="0 0 24 24"
+                              stroke="currentColor"
+                              stroke-width="2"
+                            >
+                              <path
+                                stroke-linecap="round"
+                                stroke-linejoin="round"
+                                d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
+                              />
+                            </svg>
+                          </button>
+                        </div>
+                      </div>
+                    {/each}
+                  </div>
+                {/if}
+
+                <div class="text-xs text-foreground">
+                  {#if message.content === "Generating image..." || message.content === "Editing image..." || message.content?.startsWith("Generating...") || message.content?.startsWith("Editing...")}
+                    <div class="flex items-center gap-3 text-exo-yellow">
+                      <div class="relative">
+                        <div
+                          class="w-8 h-8 border-2 border-exo-yellow/30 border-t-exo-yellow rounded-full animate-spin"
+                        ></div>
+                        <svg
+                          class="absolute inset-0 w-8 h-8 p-1.5 text-exo-yellow/60"
+                          fill="none"
+                          viewBox="0 0 24 24"
+                          stroke="currentColor"
+                          stroke-width="2"
+                        >
+                          <rect
+                            x="3"
+                            y="3"
+                            width="18"
+                            height="18"
+                            rx="2"
+                            ry="2"
+                          />
+                          <circle cx="8.5" cy="8.5" r="1.5" />
+                          <polyline points="21 15 16 10 5 21" />
+                        </svg>
+                      </div>
+                      <span class="font-mono tracking-wider uppercase text-sm"
+                        >{message.content}</span
+                      >
+                    </div>
+                  {:else if message.content || (loading && !message.attachments?.some((a) => a.type === "generated-image"))}
+                    <MarkdownContent
+                      content={message.content || (loading ? response : "")}
+                    />
+                    {#if loading && !message.content}
+                      <span
+                        class="inline-block w-2 h-4 bg-exo-yellow/70 ml-1 cursor-blink"
+                      ></span>
+                    {/if}
+                  {/if}
+                </div>
+              </div>
+            {/if}
+          </div>
+
+          <!-- Action buttons -->
+          <div
+            class="flex items-center gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity {message.role ===
+            'user'
+              ? 'justify-end'
+              : 'justify-start'}"
+          >
+            <!-- Copy button -->
+            <button
+              onclick={() => handleCopy(message.content, message.id)}
+              class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+              title="Copy message"
+            >
+              {#if copiedMessageId === message.id}
+                <svg
+                  class="w-3.5 h-3.5 text-green-400"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                >
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    stroke-width="2"
+                    d="M5 13l4 4L19 7"
+                  />
+                </svg>
+              {:else}
+                <svg
+                  class="w-3.5 h-3.5"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                >
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    stroke-width="2"
+                    d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
+                  />
+                </svg>
+              {/if}
+            </button>
+
+            <!-- Edit button (user messages only) -->
+            {#if message.role === "user"}
+              <button
+                onclick={() => handleStartEdit(message.id, message.content)}
+                class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+                title="Edit message"
+              >
+                <svg
+                  class="w-3.5 h-3.5"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                >
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    stroke-width="2"
+                    d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+                  />
+                </svg>
+              </button>
+            {/if}
+
+            <!-- Regenerate button (last assistant message only) -->
+            {#if message.role === "assistant" && isLastAssistantMessage(message.id) && !loading}
+              <button
+                onclick={handleRegenerate}
+                class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+                title="Regenerate response"
+              >
+                <svg
+                  class="w-3.5 h-3.5"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                >
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    stroke-width="2"
+                    d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+                  />
+                </svg>
+              </button>
+            {/if}
+
+            <!-- Delete button -->
+            <button
+              onclick={() => handleDeleteClick(message.id)}
+              class="p-1.5 text-exo-light-gray hover:text-red-400 transition-colors rounded hover:bg-red-500/10 cursor-pointer"
+              title="Delete message"
+            >
+              <svg
+                class="w-3.5 h-3.5"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  stroke-width="2"
+                  d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+                />
+              </svg>
+            </button>
+          </div>
+        {/if}
+      </div>
+    </div>
+  {/each}
+
+  {#if messageList.length === 0}
+    <div
+      class="flex-1 flex flex-col items-center justify-center text-center pt-[20vh]"
+    >
+      <div
+        class="w-12 h-12 sm:w-16 sm:h-16 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3 sm:mb-4"
+      >
+        <div
+          class="w-6 h-6 sm:w-8 sm:h-8 border border-exo-yellow/40 rounded-full flex items-center justify-center"
+        >
+          <div
+            class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow/60 rounded-full"
+          ></div>
+        </div>
+      </div>
+      <p
+        class="text-xs sm:text-sm text-exo-light-gray tracking-[0.15em] sm:tracking-[0.2em] uppercase"
+      >
+        AWAITING INPUT
+      </p>
+      <p class="text-sm sm:text-xs text-exo-light-gray tracking-wider mt-1">
+        ENTER A QUERY TO BEGIN
+      </p>
+    </div>
+  {/if}
+
+  <!-- Invisible element for container reference -->
+  <div bind:this={containerRef}></div>
+
+  <!-- Scroll to bottom button -->
+  {#if showScrollButton}
+    <button
+      type="button"
+      onclick={scrollToBottom}
+      class="sticky bottom-4 left-1/2 -translate-x-1/2 w-10 h-10 rounded-full bg-exo-dark-gray/90 border border-exo-medium-gray/50 flex items-center justify-center text-exo-light-gray hover:text-exo-yellow hover:border-exo-yellow/50 transition-all shadow-lg cursor-pointer z-10"
+      title="Scroll to bottom"
+    >
+      <svg
+        class="w-5 h-5"
+        fill="none"
+        viewBox="0 0 24 24"
+        stroke="currentColor"
+      >
+        <path
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          d="M19 14l-7 7m0 0l-7-7m7 7V3"
+        />
+      </svg>
+    </button>
+  {/if}
 </div>
diff --git a/dashboard/src/lib/components/ChatSidebar.svelte b/dashboard/src/lib/components/ChatSidebar.svelte
index d369819d..b721b033 100644
--- a/dashboard/src/lib/components/ChatSidebar.svelte
+++ b/dashboard/src/lib/components/ChatSidebar.svelte
@@ -1,446 +1,594 @@
 <script lang="ts">
-import { 
-		conversations, 
-		activeConversationId, 
-		createConversation, 
-		loadConversation, 
-		deleteConversation, 
-		deleteAllConversations,
-		renameConversation,
-		clearChat,
-		instances,
-		debugMode,
-		toggleDebugMode,
-		topologyOnlyMode,
-		toggleTopologyOnlyMode
-	} from '$lib/stores/app.svelte';
-
-	interface Props {
-		class?: string;
-	}
-
-	let { class: className = '' }: Props = $props();
-
-	const conversationList = $derived(conversations());
-	const activeId = $derived(activeConversationId());
-const instanceData = $derived(instances());
-const debugEnabled = $derived(debugMode());
-const topologyOnlyEnabled = $derived(topologyOnlyMode());
-
-	let searchQuery = $state('');
-	let editingId = $state<string | null>(null);
-	let editingName = $state('');
-	let deleteConfirmId = $state<string | null>(null);
-	let showDeleteAllConfirm = $state(false);
-
-	const filteredConversations = $derived(
-		searchQuery.trim()
-			? conversationList.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
-			: conversationList
-	);
-
-	function handleNewChat() {
-		createConversation();
-	}
-
-	function handleSelectConversation(id: string) {
-		loadConversation(id);
-	}
-
-	function handleStartEdit(id: string, name: string, event: MouseEvent) {
-		event.stopPropagation();
-		editingId = id;
-		editingName = name;
-	}
-
-	function handleSaveEdit() {
-		if (editingId && editingName.trim()) {
-			renameConversation(editingId, editingName.trim());
-		}
-		editingId = null;
-		editingName = '';
-	}
-
-	function handleCancelEdit() {
-		editingId = null;
-		editingName = '';
-	}
-
-	function handleEditKeydown(event: KeyboardEvent) {
-		if (event.key === 'Enter') {
-			handleSaveEdit();
-		} else if (event.key === 'Escape') {
-			handleCancelEdit();
-		}
-	}
-
-	function handleDeleteClick(id: string, event: MouseEvent) {
-		event.stopPropagation();
-		deleteConfirmId = id;
-	}
-
-	function handleConfirmDelete() {
-		if (deleteConfirmId) {
-			deleteConversation(deleteConfirmId);
-			deleteConfirmId = null;
-		}
-	}
-
-	function handleCancelDelete() {
-		deleteConfirmId = null;
-	}
-
-	function handleDeleteAllClick() {
-		showDeleteAllConfirm = true;
-	}
-
-	function handleConfirmDeleteAll() {
-		deleteAllConversations();
-		showDeleteAllConfirm = false;
-	}
-
-	function handleCancelDeleteAll() {
-		showDeleteAllConfirm = false;
-	}
-
-	function formatDate(timestamp: number): string {
-		const date = new Date(timestamp);
-		const now = new Date();
-		const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
-		
-		if (diffDays === 0) {
-			return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
-		} else if (diffDays === 1) {
-			return 'Yesterday';
-		} else if (diffDays < 7) {
-			return date.toLocaleDateString('en-US', { weekday: 'short' });
-		} else {
-			return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
-		}
-	}
-
-	function getLastAssistantStats(conversation: typeof conversationList[0]): { ttftMs?: number; tps?: number } | null {
-		// Find the last assistant message with stats
-		for (let i = conversation.messages.length - 1; i >= 0; i--) {
-			const msg = conversation.messages[i];
-			if (msg.role === 'assistant' && (msg.ttftMs || msg.tps)) {
-				return { ttftMs: msg.ttftMs, tps: msg.tps };
-			}
-		}
-		return null;
-	}
-
-	function formatModelName(modelId: string | null | undefined): string {
-		if (!modelId) return 'Unknown Model';
-		const parts = modelId.split('/');
-		const tail = parts[parts.length - 1] || modelId;
-		return tail || modelId;
-	}
-
-	function formatStrategy(sharding: string | null | undefined, instanceType: string | null | undefined): string {
-		const shardLabel = sharding ?? 'Unknown';
-		const typeLabel = instanceType ?? null;
-		return typeLabel ? `${shardLabel} (${typeLabel})` : shardLabel;
-	}
-
-	function getTaggedValue(obj: unknown): [string | null, unknown] {
-		if (!obj || typeof obj !== 'object') return [null, null];
-		const keys = Object.keys(obj as Record<string, unknown>);
-		if (keys.length === 1) {
-			return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
-		}
-		return [null, null];
-	}
-
-	function extractInstanceModelId(instanceWrapped: unknown): string | null {
-		const [, instance] = getTaggedValue(instanceWrapped);
-		if (!instance || typeof instance !== 'object') return null;
-		const inst = instance as { shardAssignments?: { modelId?: string } };
-		return inst.shardAssignments?.modelId ?? null;
-	}
-
-	function describeInstance(instanceWrapped: unknown): { sharding: string | null; instanceType: string | null } {
-		const [instanceTag, instance] = getTaggedValue(instanceWrapped);
-		if (!instance || typeof instance !== 'object') {
-			return { sharding: null, instanceType: null };
-		}
-
-		let instanceType: string | null = null;
-		if (instanceTag === 'MlxRingInstance') instanceType = 'MLX Ring';
-		else if (instanceTag === 'MlxIbvInstance' || instanceTag === 'MlxJacclInstance') instanceType = 'MLX RDMA';
-
-		let sharding: string | null = null;
-		const inst = instance as { shardAssignments?: { runnerToShard?: Record<string, unknown> } };
-		const runnerToShard = inst.shardAssignments?.runnerToShard || {};
-		const firstShardWrapped = Object.values(runnerToShard)[0];
-		if (firstShardWrapped) {
-			const [shardTag] = getTaggedValue(firstShardWrapped);
-			if (shardTag === 'PipelineShardMetadata') sharding = 'Pipeline';
-			else if (shardTag === 'TensorShardMetadata') sharding = 'Tensor';
-			else if (shardTag === 'PrefillDecodeShardMetadata') sharding = 'Prefill/Decode';
-		}
-
-		return { sharding, instanceType };
-	}
-
-	function resolveConversationInfo(conversation: typeof conversationList[0]): { modelLabel: string; strategyLabel: string } {
-		// Attempt to match conversation model to an instance
-		let matchedInstance: unknown = null;
-		let modelId = conversation.modelId ?? null;
-
-		if (modelId) {
-			for (const [, instanceWrapper] of Object.entries(instanceData)) {
-				const candidate = extractInstanceModelId(instanceWrapper);
-				if (candidate === modelId) {
-					matchedInstance = instanceWrapper;
-					break;
-				}
-			}
-		}
-
-		// Fallback: use the first available instance if no explicit match
-		if (!matchedInstance) {
-			const firstInstance = Object.values(instanceData)[0];
-			if (firstInstance) {
-				matchedInstance = firstInstance;
-				modelId = modelId ?? extractInstanceModelId(firstInstance);
-			}
-		}
-
-		const instanceDetails = matchedInstance ? describeInstance(matchedInstance) : { sharding: null, instanceType: null };
-		const displayModel = modelId ?? conversation.modelId ?? null;
-		const sharding = conversation.sharding ?? instanceDetails.sharding ?? 'Unknown';
-		const instanceType = conversation.instanceType ?? instanceDetails.instanceType;
-
-		return {
-			modelLabel: formatModelName(displayModel),
-			strategyLabel: formatStrategy(sharding, instanceType)
-		};
-	}
+  import {
+    conversations,
+    activeConversationId,
+    createConversation,
+    loadConversation,
+    deleteConversation,
+    deleteAllConversations,
+    renameConversation,
+    clearChat,
+    instances,
+    debugMode,
+    toggleDebugMode,
+    topologyOnlyMode,
+    toggleTopologyOnlyMode,
+  } from "$lib/stores/app.svelte";
+
+  interface Props {
+    class?: string;
+  }
+
+  let { class: className = "" }: Props = $props();
+
+  const conversationList = $derived(conversations());
+  const activeId = $derived(activeConversationId());
+  const instanceData = $derived(instances());
+  const debugEnabled = $derived(debugMode());
+  const topologyOnlyEnabled = $derived(topologyOnlyMode());
+
+  let searchQuery = $state("");
+  let editingId = $state<string | null>(null);
+  let editingName = $state("");
+  let deleteConfirmId = $state<string | null>(null);
+  let showDeleteAllConfirm = $state(false);
+
+  const filteredConversations = $derived(
+    searchQuery.trim()
+      ? conversationList.filter((c) =>
+          c.name.toLowerCase().includes(searchQuery.toLowerCase()),
+        )
+      : conversationList,
+  );
+
+  function handleNewChat() {
+    createConversation();
+  }
+
+  function handleSelectConversation(id: string) {
+    loadConversation(id);
+  }
+
+  function handleStartEdit(id: string, name: string, event: MouseEvent) {
+    event.stopPropagation();
+    editingId = id;
+    editingName = name;
+  }
+
+  function handleSaveEdit() {
+    if (editingId && editingName.trim()) {
+      renameConversation(editingId, editingName.trim());
+    }
+    editingId = null;
+    editingName = "";
+  }
+
+  function handleCancelEdit() {
+    editingId = null;
+    editingName = "";
+  }
+
+  function handleEditKeydown(event: KeyboardEvent) {
+    if (event.key === "Enter") {
+      handleSaveEdit();
+    } else if (event.key === "Escape") {
+      handleCancelEdit();
+    }
+  }
+
+  function handleDeleteClick(id: string, event: MouseEvent) {
+    event.stopPropagation();
+    deleteConfirmId = id;
+  }
+
+  function handleConfirmDelete() {
+    if (deleteConfirmId) {
+      deleteConversation(deleteConfirmId);
+      deleteConfirmId = null;
+    }
+  }
+
+  function handleCancelDelete() {
+    deleteConfirmId = null;
+  }
+
+  function handleDeleteAllClick() {
+    showDeleteAllConfirm = true;
+  }
+
+  function handleConfirmDeleteAll() {
+    deleteAllConversations();
+    showDeleteAllConfirm = false;
+  }
+
+  function handleCancelDeleteAll() {
+    showDeleteAllConfirm = false;
+  }
+
+  function formatDate(timestamp: number): string {
+    const date = new Date(timestamp);
+    const now = new Date();
+    const diffDays = Math.floor(
+      (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
+    );
+
+    if (diffDays === 0) {
+      return date.toLocaleTimeString("en-US", {
+        hour: "2-digit",
+        minute: "2-digit",
+      });
+    } else if (diffDays === 1) {
+      return "Yesterday";
+    } else if (diffDays < 7) {
+      return date.toLocaleDateString("en-US", { weekday: "short" });
+    } else {
+      return date.toLocaleDateString("en-US", {
+        month: "short",
+        day: "numeric",
+      });
+    }
+  }
+
+  function getLastAssistantStats(
+    conversation: (typeof conversationList)[0],
+  ): { ttftMs?: number; tps?: number } | null {
+    // Find the last assistant message with stats
+    for (let i = conversation.messages.length - 1; i >= 0; i--) {
+      const msg = conversation.messages[i];
+      if (msg.role === "assistant" && (msg.ttftMs || msg.tps)) {
+        return { ttftMs: msg.ttftMs, tps: msg.tps };
+      }
+    }
+    return null;
+  }
+
+  function formatModelName(modelId: string | null | undefined): string {
+    if (!modelId) return "Unknown Model";
+    const parts = modelId.split("/");
+    const tail = parts[parts.length - 1] || modelId;
+    return tail || modelId;
+  }
+
+  function formatStrategy(
+    sharding: string | null | undefined,
+    instanceType: string | null | undefined,
+  ): string {
+    const shardLabel = sharding ?? "Unknown";
+    const typeLabel = instanceType ?? null;
+    return typeLabel ? `${shardLabel} (${typeLabel})` : shardLabel;
+  }
+
+  function getTaggedValue(obj: unknown): [string | null, unknown] {
+    if (!obj || typeof obj !== "object") return [null, null];
+    const keys = Object.keys(obj as Record<string, unknown>);
+    if (keys.length === 1) {
+      return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
+    }
+    return [null, null];
+  }
+
+  function extractInstanceModelId(instanceWrapped: unknown): string | null {
+    const [, instance] = getTaggedValue(instanceWrapped);
+    if (!instance || typeof instance !== "object") return null;
+    const inst = instance as { shardAssignments?: { modelId?: string } };
+    return inst.shardAssignments?.modelId ?? null;
+  }
+
+  function describeInstance(instanceWrapped: unknown): {
+    sharding: string | null;
+    instanceType: string | null;
+  } {
+    const [instanceTag, instance] = getTaggedValue(instanceWrapped);
+    if (!instance || typeof instance !== "object") {
+      return { sharding: null, instanceType: null };
+    }
+
+    let instanceType: string | null = null;
+    if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
+    else if (
+      instanceTag === "MlxIbvInstance" ||
+      instanceTag === "MlxJacclInstance"
+    )
+      instanceType = "MLX RDMA";
+
+    let sharding: string | null = null;
+    const inst = instance as {
+      shardAssignments?: { runnerToShard?: Record<string, unknown> };
+    };
+    const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+    const firstShardWrapped = Object.values(runnerToShard)[0];
+    if (firstShardWrapped) {
+      const [shardTag] = getTaggedValue(firstShardWrapped);
+      if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
+      else if (shardTag === "TensorShardMetadata") sharding = "Tensor";
+      else if (shardTag === "PrefillDecodeShardMetadata")
+        sharding = "Prefill/Decode";
+    }
+
+    return { sharding, instanceType };
+  }
+
+  function resolveConversationInfo(
+    conversation: (typeof conversationList)[0],
+  ): { modelLabel: string; strategyLabel: string } {
+    // Attempt to match conversation model to an instance
+    let matchedInstance: unknown = null;
+    let modelId = conversation.modelId ?? null;
+
+    if (modelId) {
+      for (const [, instanceWrapper] of Object.entries(instanceData)) {
+        const candidate = extractInstanceModelId(instanceWrapper);
+        if (candidate === modelId) {
+          matchedInstance = instanceWrapper;
+          break;
+        }
+      }
+    }
+
+    // Fallback: use the first available instance if no explicit match
+    if (!matchedInstance) {
+      const firstInstance = Object.values(instanceData)[0];
+      if (firstInstance) {
+        matchedInstance = firstInstance;
+        modelId = modelId ?? extractInstanceModelId(firstInstance);
+      }
+    }
+
+    const instanceDetails = matchedInstance
+      ? describeInstance(matchedInstance)
+      : { sharding: null, instanceType: null };
+    const displayModel = modelId ?? conversation.modelId ?? null;
+    const sharding =
+      conversation.sharding ?? instanceDetails.sharding ?? "Unknown";
+    const instanceType =
+      conversation.instanceType ?? instanceDetails.instanceType;
+
+    return {
+      modelLabel: formatModelName(displayModel),
+      strategyLabel: formatStrategy(sharding, instanceType),
+    };
+  }
 </script>
 
-<aside class="flex flex-col h-full bg-exo-dark-gray border-r border-exo-yellow/10 {className}">
-	<!-- Header -->
-	<div class="p-4">
-		<button
-			onclick={handleNewChat}
-			class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-transparent border border-exo-yellow/30 text-exo-yellow text-xs font-mono tracking-wider uppercase hover:border-exo-yellow/50 transition-all cursor-pointer"
-		>
-			<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-				<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
-			</svg>
-			NEW CHAT
-		</button>
-	</div>
-
-	<!-- Search -->
-	<div class="px-4 py-3">
-		<div class="relative">
-			<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-				<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
-			</svg>
-			<input
-				type="text"
-				bind:value={searchQuery}
-				placeholder="Search conversations..."
-				class="w-full bg-exo-black/40 border border-exo-medium-gray/30 rounded px-3 py-2 pl-9 text-xs text-white/90 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/30"
-			/>
-		</div>
-	</div>
-
-	<!-- Conversation List -->
-	<div class="flex-1 overflow-y-auto">
-		{#if filteredConversations.length > 0}
-			<div class="py-2">
-				<div class="px-4 py-2">
-					<span class="text-sm text-white/70 font-mono tracking-wider uppercase">
-						{searchQuery ? 'SEARCH RESULTS' : 'CONVERSATIONS'}
-					</span>
-				</div>
-				
-				{#each filteredConversations as conversation (conversation.id)}
-					{@const info = resolveConversationInfo(conversation)}
-					<div class="px-2">
-						{#if editingId === conversation.id}
-							<!-- Edit mode -->
-							<div class="p-2 bg-transparent border border-exo-yellow/20 rounded mb-1">
-								<input
-									type="text"
-									bind:value={editingName}
-									onkeydown={handleEditKeydown}
-									class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs text-exo-light-gray focus:outline-none focus:border-exo-yellow/50 mb-2"
-									autofocus
-								/>
-								<div class="flex gap-2">
-									<button
-										onclick={handleSaveEdit}
-										class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 cursor-pointer"
-									>
-										SAVE
-									</button>
-									<button
-										onclick={handleCancelEdit}
-										class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
-									>
-										CANCEL
-									</button>
-								</div>
-							</div>
-						{:else if deleteConfirmId === conversation.id}
-							<!-- Delete confirmation -->
-							<div class="p-2 bg-red-500/10 border border-red-500/30 rounded mb-1">
-								<p class="text-xs text-red-400 mb-2">Delete "{conversation.name}"?</p>
-								<div class="flex gap-2">
-									<button
-										onclick={handleConfirmDelete}
-										class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 cursor-pointer"
-									>
-										DELETE
-									</button>
-									<button
-										onclick={handleCancelDelete}
-										class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
-									>
-										CANCEL
-									</button>
-								</div>
-							</div>
-						{:else}
-							<!-- Normal view -->
-							{@const stats = getLastAssistantStats(conversation)}
-							<div
-								role="button"
-								tabindex="0"
-								onclick={() => handleSelectConversation(conversation.id)}
-								onkeydown={(e) => e.key === 'Enter' && handleSelectConversation(conversation.id)}
-								class="group w-full flex items-center justify-between p-2 rounded mb-1 transition-all text-left cursor-pointer
-									{activeId === conversation.id 
-								? 'bg-transparent border border-exo-yellow/30' 
-									: 'hover:border-exo-yellow/20 border border-transparent'}"
-							>
-								<div class="flex-1 min-w-0 pr-2">
-									<div class="text-sm truncate {activeId === conversation.id ? 'text-exo-yellow' : 'text-white/90'}">
-										{conversation.name}
-									</div>
-									<div class="text-sm text-white/50 mt-0.5">
-										{formatDate(conversation.updatedAt)}
-									</div>
-									<div class="text-sm text-white/70 truncate">
-										{info.modelLabel}
-									</div>
-									<div class="text-xs text-white/60 font-mono">
-										Strategy: <span class="text-white/80">{info.strategyLabel}</span>
-									</div>
-									{#if stats}
-										<div class="text-xs text-white/60 font-mono mt-1">
-											{#if stats.ttftMs}<span class="text-white/40">TTFT</span> {stats.ttftMs.toFixed(0)}ms{/if}{#if stats.ttftMs && stats.tps}<span class="text-white/30 mx-1.5">•</span>{/if}{#if stats.tps}{stats.tps.toFixed(1)} <span class="text-white/40">tok/s</span>{/if}
-										</div>
-									{/if}
-								</div>
-								
-								<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
-								<button
-									type="button"
-									onclick={(e) => handleStartEdit(conversation.id, conversation.name, e)}
-									class="p-1 text-exo-light-gray hover:text-exo-yellow transition-colors cursor-pointer"
-									title="Rename"
-								>
-										<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-											<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
-										</svg>
-									</button>
-								<button
-									type="button"
-									onclick={(e) => handleDeleteClick(conversation.id, e)}
-									class="p-1 text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
-									title="Delete"
-								>
-										<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-											<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
-										</svg>
-									</button>
-								</div>
-							</div>
-						{/if}
-					</div>
-				{/each}
-			</div>
-		{:else}
-			<div class="flex flex-col items-center justify-center h-full p-4 text-center">
-				<div class="w-12 h-12 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3">
-					<svg class="w-6 h-6 text-exo-yellow/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-						<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
-					</svg>
-				</div>
-				<p class="text-xs text-white/70 font-mono tracking-wider uppercase mb-1">
-					{searchQuery ? 'NO RESULTS' : 'NO CONVERSATIONS'}
-				</p>
-				<p class="text-sm text-white/50">
-					{searchQuery ? 'Try a different search' : 'Start a new chat to begin'}
-				</p>
-			</div>
-		{/if}
-	</div>
-
-	<!-- Footer -->
-	<div class="p-3 border-t border-exo-yellow/10">
-		{#if showDeleteAllConfirm}
-			<div class="bg-red-500/10 border border-red-500/30 rounded p-2 mb-2">
-				<p class="text-xs text-red-400 text-center mb-2">Delete all {conversationList.length} conversations?</p>
-				<div class="flex gap-2">
-				<button
-					onclick={handleConfirmDeleteAll}
-					class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
-				>
-					DELETE ALL
-				</button>
-				<button
-					onclick={handleCancelDeleteAll}
-					class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
-				>
-						CANCEL
-					</button>
-				</div>
-			</div>
-		{:else if conversationList.length > 0}
-		<button
-			onclick={handleDeleteAllClick}
-			class="w-full flex items-center justify-center gap-2 py-1.5 text-sm font-mono tracking-wider uppercase text-white/70 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 rounded transition-all cursor-pointer"
-		>
-				<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-					<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
-				</svg>
-				DELETE ALL CHATS
-			</button>
-		{/if}
-	<div class="flex items-center justify-center gap-3 {conversationList.length > 0 && !showDeleteAllConfirm ? 'mt-2' : ''}">
-		<button
-			type="button"
-			onclick={toggleDebugMode}
-			class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
-			title="Toggle debug mode"
-		>
-			<svg class="w-4 h-4 {debugEnabled ? 'text-exo-yellow' : 'text-exo-medium-gray'}" fill="currentColor" viewBox="0 0 24 24">
-				<path d="M19 8h-1.81A6.002 6.002 0 0 0 12 2a6.002 6.002 0 0 0-5.19 3H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1.81A6.002 6.002 0 0 0 12 22a6.002 6.002 0 0 0 5.19-3H19a1 1 0 0 0 0-2h-1v-2h1a1 1 0 0 0 0-2h-1v-2h1a1 1 0 1 0 0-2Zm-5 10.32V19a1 1 0 1 1-2 0v-.68a3.999 3.999 0 0 1-3-3.83V9.32a3.999 3.999 0 0 1 3-3.83V5a1 1 0 0 1 2 0v.49a3.999 3.999 0 0 1 3 3.83v5.17a3.999 3.999 0 0 1-3 3.83Z"/>
-			</svg>
-		</button>
-		<div class="text-xs text-white/60 font-mono tracking-wider text-center">
-			{conversationList.length} CONVERSATION{conversationList.length !== 1 ? 'S' : ''}
-		</div>
-		<button
-			type="button"
-			onclick={toggleTopologyOnlyMode}
-			class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
-			title="Toggle topology only mode"
-		>
-			<svg class="w-4 h-4 {topologyOnlyEnabled ? 'text-exo-yellow' : 'text-exo-medium-gray'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
-				<circle cx="12" cy="5" r="2" fill="currentColor" />
-				<circle cx="5" cy="19" r="2" fill="currentColor" />
-				<circle cx="19" cy="19" r="2" fill="currentColor" />
-				<path stroke-linecap="round" d="M12 7v5m0 0l-5 5m5-5l5 5" />
-			</svg>
-		</button>
-	</div>
-	</div>
+<aside
+  class="flex flex-col h-full bg-exo-dark-gray border-r border-exo-yellow/10 {className}"
+>
+  <!-- Header -->
+  <div class="p-4">
+    <button
+      onclick={handleNewChat}
+      class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-transparent border border-exo-yellow/30 text-exo-yellow text-xs font-mono tracking-wider uppercase hover:border-exo-yellow/50 transition-all cursor-pointer"
+    >
+      <svg
+        class="w-4 h-4"
+        fill="none"
+        viewBox="0 0 24 24"
+        stroke="currentColor"
+      >
+        <path
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          d="M12 4v16m8-8H4"
+        />
+      </svg>
+      NEW CHAT
+    </button>
+  </div>
+
+  <!-- Search -->
+  <div class="px-4 py-3">
+    <div class="relative">
+      <svg
+        class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/50"
+        fill="none"
+        viewBox="0 0 24 24"
+        stroke="currentColor"
+      >
+        <path
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
+        />
+      </svg>
+      <input
+        type="text"
+        bind:value={searchQuery}
+        placeholder="Search conversations..."
+        class="w-full bg-exo-black/40 border border-exo-medium-gray/30 rounded px-3 py-2 pl-9 text-xs text-white/90 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/30"
+      />
+    </div>
+  </div>
+
+  <!-- Conversation List -->
+  <div class="flex-1 overflow-y-auto">
+    {#if filteredConversations.length > 0}
+      <div class="py-2">
+        <div class="px-4 py-2">
+          <span
+            class="text-sm text-white/70 font-mono tracking-wider uppercase"
+          >
+            {searchQuery ? "SEARCH RESULTS" : "CONVERSATIONS"}
+          </span>
+        </div>
+
+        {#each filteredConversations as conversation (conversation.id)}
+          {@const info = resolveConversationInfo(conversation)}
+          <div class="px-2">
+            {#if editingId === conversation.id}
+              <!-- Edit mode -->
+              <div
+                class="p-2 bg-transparent border border-exo-yellow/20 rounded mb-1"
+              >
+                <input
+                  type="text"
+                  bind:value={editingName}
+                  onkeydown={handleEditKeydown}
+                  class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs text-exo-light-gray focus:outline-none focus:border-exo-yellow/50 mb-2"
+                  autofocus
+                />
+                <div class="flex gap-2">
+                  <button
+                    onclick={handleSaveEdit}
+                    class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 cursor-pointer"
+                  >
+                    SAVE
+                  </button>
+                  <button
+                    onclick={handleCancelEdit}
+                    class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
+                  >
+                    CANCEL
+                  </button>
+                </div>
+              </div>
+            {:else if deleteConfirmId === conversation.id}
+              <!-- Delete confirmation -->
+              <div
+                class="p-2 bg-red-500/10 border border-red-500/30 rounded mb-1"
+              >
+                <p class="text-xs text-red-400 mb-2">
+                  Delete "{conversation.name}"?
+                </p>
+                <div class="flex gap-2">
+                  <button
+                    onclick={handleConfirmDelete}
+                    class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 cursor-pointer"
+                  >
+                    DELETE
+                  </button>
+                  <button
+                    onclick={handleCancelDelete}
+                    class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
+                  >
+                    CANCEL
+                  </button>
+                </div>
+              </div>
+            {:else}
+              <!-- Normal view -->
+              {@const stats = getLastAssistantStats(conversation)}
+              <div
+                role="button"
+                tabindex="0"
+                onclick={() => handleSelectConversation(conversation.id)}
+                onkeydown={(e) =>
+                  e.key === "Enter" &&
+                  handleSelectConversation(conversation.id)}
+                class="group w-full flex items-center justify-between p-2 rounded mb-1 transition-all text-left cursor-pointer
+									{activeId === conversation.id
+                  ? 'bg-transparent border border-exo-yellow/30'
+                  : 'hover:border-exo-yellow/20 border border-transparent'}"
+              >
+                <div class="flex-1 min-w-0 pr-2">
+                  <div
+                    class="text-sm truncate {activeId === conversation.id
+                      ? 'text-exo-yellow'
+                      : 'text-white/90'}"
+                  >
+                    {conversation.name}
+                  </div>
+                  <div class="text-sm text-white/50 mt-0.5">
+                    {formatDate(conversation.updatedAt)}
+                  </div>
+                  <div class="text-sm text-white/70 truncate">
+                    {info.modelLabel}
+                  </div>
+                  <div class="text-xs text-white/60 font-mono">
+                    Strategy: <span class="text-white/80"
+                      >{info.strategyLabel}</span
+                    >
+                  </div>
+                  {#if stats}
+                    <div class="text-xs text-white/60 font-mono mt-1">
+                      {#if stats.ttftMs}<span class="text-white/40">TTFT</span>
+                        {stats.ttftMs.toFixed(
+                          0,
+                        )}ms{/if}{#if stats.ttftMs && stats.tps}<span
+                          class="text-white/30 mx-1.5">•</span
+                        >{/if}{#if stats.tps}{stats.tps.toFixed(1)}
+                        <span class="text-white/40">tok/s</span>{/if}
+                    </div>
+                  {/if}
+                </div>
+
+                <div
+                  class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
+                >
+                  <button
+                    type="button"
+                    onclick={(e) =>
+                      handleStartEdit(conversation.id, conversation.name, e)}
+                    class="p-1 text-exo-light-gray hover:text-exo-yellow transition-colors cursor-pointer"
+                    title="Rename"
+                  >
+                    <svg
+                      class="w-3 h-3"
+                      fill="none"
+                      viewBox="0 0 24 24"
+                      stroke="currentColor"
+                    >
+                      <path
+                        stroke-linecap="round"
+                        stroke-linejoin="round"
+                        stroke-width="2"
+                        d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+                      />
+                    </svg>
+                  </button>
+                  <button
+                    type="button"
+                    onclick={(e) => handleDeleteClick(conversation.id, e)}
+                    class="p-1 text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
+                    title="Delete"
+                  >
+                    <svg
+                      class="w-3 h-3"
+                      fill="none"
+                      viewBox="0 0 24 24"
+                      stroke="currentColor"
+                    >
+                      <path
+                        stroke-linecap="round"
+                        stroke-linejoin="round"
+                        stroke-width="2"
+                        d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+                      />
+                    </svg>
+                  </button>
+                </div>
+              </div>
+            {/if}
+          </div>
+        {/each}
+      </div>
+    {:else}
+      <div
+        class="flex flex-col items-center justify-center h-full p-4 text-center"
+      >
+        <div
+          class="w-12 h-12 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3"
+        >
+          <svg
+            class="w-6 h-6 text-exo-yellow/40"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              stroke-width="1.5"
+              d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
+            />
+          </svg>
+        </div>
+        <p
+          class="text-xs text-white/70 font-mono tracking-wider uppercase mb-1"
+        >
+          {searchQuery ? "NO RESULTS" : "NO CONVERSATIONS"}
+        </p>
+        <p class="text-sm text-white/50">
+          {searchQuery ? "Try a different search" : "Start a new chat to begin"}
+        </p>
+      </div>
+    {/if}
+  </div>
+
+  <!-- Footer -->
+  <div class="p-3 border-t border-exo-yellow/10">
+    {#if showDeleteAllConfirm}
+      <div class="bg-red-500/10 border border-red-500/30 rounded p-2 mb-2">
+        <p class="text-xs text-red-400 text-center mb-2">
+          Delete all {conversationList.length} conversations?
+        </p>
+        <div class="flex gap-2">
+          <button
+            onclick={handleConfirmDeleteAll}
+            class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
+          >
+            DELETE ALL
+          </button>
+          <button
+            onclick={handleCancelDeleteAll}
+            class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+          >
+            CANCEL
+          </button>
+        </div>
+      </div>
+    {:else if conversationList.length > 0}
+      <button
+        onclick={handleDeleteAllClick}
+        class="w-full flex items-center justify-center gap-2 py-1.5 text-sm font-mono tracking-wider uppercase text-white/70 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 rounded transition-all cursor-pointer"
+      >
+        <svg
+          class="w-3.5 h-3.5"
+          fill="none"
+          viewBox="0 0 24 24"
+          stroke="currentColor"
+        >
+          <path
+            stroke-linecap="round"
+            stroke-linejoin="round"
+            stroke-width="2"
+            d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+          />
+        </svg>
+        DELETE ALL CHATS
+      </button>
+    {/if}
+    <div
+      class="flex items-center justify-center gap-3 {conversationList.length >
+        0 && !showDeleteAllConfirm
+        ? 'mt-2'
+        : ''}"
+    >
+      <button
+        type="button"
+        onclick={toggleDebugMode}
+        class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
+        title="Toggle debug mode"
+      >
+        <svg
+          class="w-4 h-4 {debugEnabled
+            ? 'text-exo-yellow'
+            : 'text-exo-medium-gray'}"
+          fill="currentColor"
+          viewBox="0 0 24 24"
+        >
+          <path
+            d="M19 8h-1.81A6.002 6.002 0 0 0 12 2a6.002 6.002 0 0 0-5.19 3H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1.81A6.002 6.002 0 0 0 12 22a6.002 6.002 0 0 0 5.19-3H19a1 1 0 0 0 0-2h-1v-2h1a1 1 0 0 0 0-2h-1v-2h1a1 1 0 1 0 0-2Zm-5 10.32V19a1 1 0 1 1-2 0v-.68a3.999 3.999 0 0 1-3-3.83V9.32a3.999 3.999 0 0 1 3-3.83V5a1 1 0 0 1 2 0v.49a3.999 3.999 0 0 1 3 3.83v5.17a3.999 3.999 0 0 1-3 3.83Z"
+          />
+        </svg>
+      </button>
+      <div class="text-xs text-white/60 font-mono tracking-wider text-center">
+        {conversationList.length} CONVERSATION{conversationList.length !== 1
+          ? "S"
+          : ""}
+      </div>
+      <button
+        type="button"
+        onclick={toggleTopologyOnlyMode}
+        class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
+        title="Toggle topology only mode"
+      >
+        <svg
+          class="w-4 h-4 {topologyOnlyEnabled
+            ? 'text-exo-yellow'
+            : 'text-exo-medium-gray'}"
+          fill="none"
+          viewBox="0 0 24 24"
+          stroke="currentColor"
+          stroke-width="2"
+        >
+          <circle cx="12" cy="5" r="2" fill="currentColor" />
+          <circle cx="5" cy="19" r="2" fill="currentColor" />
+          <circle cx="19" cy="19" r="2" fill="currentColor" />
+          <path stroke-linecap="round" d="M12 7v5m0 0l-5 5m5-5l5 5" />
+        </svg>
+      </button>
+    </div>
+  </div>
 </aside>
-
diff --git a/dashboard/src/lib/components/HeaderNav.svelte b/dashboard/src/lib/components/HeaderNav.svelte
index 2e1a3c9d..5bdcf1ba 100644
--- a/dashboard/src/lib/components/HeaderNav.svelte
+++ b/dashboard/src/lib/components/HeaderNav.svelte
@@ -1,85 +1,129 @@
 <script lang="ts">
-	import { browser } from '$app/environment';
+  import { browser } from "$app/environment";
 
-	export let showHome = true;
-	export let onHome: (() => void) | null = null;
-	export let showSidebarToggle = false;
-	export let sidebarVisible = true;
-	export let onToggleSidebar: (() => void) | null = null;
+  export let showHome = true;
+  export let onHome: (() => void) | null = null;
+  export let showSidebarToggle = false;
+  export let sidebarVisible = true;
+  export let onToggleSidebar: (() => void) | null = null;
 
-	function handleHome(): void {
-		if (onHome) {
-			onHome();
-			return;
-		}
-		if (browser) {
-			// Hash router: send to root
-			window.location.hash = '/';
-		}
-	}
+  function handleHome(): void {
+    if (onHome) {
+      onHome();
+      return;
+    }
+    if (browser) {
+      // Hash router: send to root
+      window.location.hash = "/";
+    }
+  }
 
-	function handleToggleSidebar(): void {
-		if (onToggleSidebar) {
-			onToggleSidebar();
-		}
-	}
+  function handleToggleSidebar(): void {
+    if (onToggleSidebar) {
+      onToggleSidebar();
+    }
+  }
 </script>
 
-<header class="relative z-20 flex items-center justify-center px-6 pt-8 pb-4 bg-exo-dark-gray">
-	<!-- Left: Sidebar Toggle -->
-	{#if showSidebarToggle}
-	<div class="absolute left-6 top-1/2 -translate-y-1/2">
-		<button
-			onclick={handleToggleSidebar}
-			class="p-2 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
-			title={sidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
-		>
-			<svg class="w-5 h-5 {sidebarVisible ? 'text-exo-yellow' : 'text-exo-medium-gray'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
-				{#if sidebarVisible}
-					<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
-				{:else}
-					<path stroke-linecap="round" stroke-linejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
-				{/if}
-			</svg>
-		</button>
-	</div>
-	{/if}
+<header
+  class="relative z-20 flex items-center justify-center px-6 pt-8 pb-4 bg-exo-dark-gray"
+>
+  <!-- Left: Sidebar Toggle -->
+  {#if showSidebarToggle}
+    <div class="absolute left-6 top-1/2 -translate-y-1/2">
+      <button
+        onclick={handleToggleSidebar}
+        class="p-2 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
+        title={sidebarVisible ? "Hide sidebar" : "Show sidebar"}
+      >
+        <svg
+          class="w-5 h-5 {sidebarVisible
+            ? 'text-exo-yellow'
+            : 'text-exo-medium-gray'}"
+          fill="none"
+          viewBox="0 0 24 24"
+          stroke="currentColor"
+          stroke-width="2"
+        >
+          {#if sidebarVisible}
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
+            />
+          {:else}
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              d="M13 5l7 7-7 7M5 5l7 7-7 7"
+            />
+          {/if}
+        </svg>
+      </button>
+    </div>
+  {/if}
 
-	<!-- Center: Logo (clickable to go home) -->
-	<button
-		onclick={handleHome}
-		class="bg-transparent border-none outline-none focus:outline-none transition-opacity duration-200 hover:opacity-90 {showHome ? 'cursor-pointer' : 'cursor-default'}"
-		title={showHome ? 'Go to home' : ''}
-		disabled={!showHome}
-	>
-		<img src="/exo-logo.png" alt="EXO" class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]" />
-	</button>
+  <!-- Center: Logo (clickable to go home) -->
+  <button
+    onclick={handleHome}
+    class="bg-transparent border-none outline-none focus:outline-none transition-opacity duration-200 hover:opacity-90 {showHome
+      ? 'cursor-pointer'
+      : 'cursor-default'}"
+    title={showHome ? "Go to home" : ""}
+    disabled={!showHome}
+  >
+    <img
+      src="/exo-logo.png"
+      alt="EXO"
+      class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]"
+    />
+  </button>
 
-	<!-- Right: Home + Downloads -->
-	<div class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4">
-		{#if showHome}
-			<button
-				onclick={handleHome}
-				class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
-				title="Back to topology view"
-			>
-				<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-					<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
-				</svg>
-				Home
-			</button>
-		{/if}
-		<a
-			href="/#/downloads"
-			class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
-			title="View downloads overview"
-		>
-			<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-				<path d="M12 3v12" />
-				<path d="M7 12l5 5 5-5" />
-				<path d="M5 21h14" />
-			</svg>
-			Downloads
-		</a>
-	</div>
+  <!-- Right: Home + Downloads -->
+  <div
+    class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4"
+  >
+    {#if showHome}
+      <button
+        onclick={handleHome}
+        class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+        title="Back to topology view"
+      >
+        <svg
+          class="w-4 h-4"
+          fill="none"
+          viewBox="0 0 24 24"
+          stroke="currentColor"
+        >
+          <path
+            stroke-linecap="round"
+            stroke-linejoin="round"
+            stroke-width="2"
+            d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
+          />
+        </svg>
+        Home
+      </button>
+    {/if}
+    <a
+      href="/#/downloads"
+      class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+      title="View downloads overview"
+    >
+      <svg
+        class="w-4 h-4"
+        viewBox="0 0 24 24"
+        fill="none"
+        stroke="currentColor"
+        stroke-width="2"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+      >
+        <path d="M12 3v12" />
+        <path d="M7 12l5 5 5-5" />
+        <path d="M5 21h14" />
+      </svg>
+      Downloads
+    </a>
+  </div>
 </header>
diff --git a/dashboard/src/lib/components/ImageParamsPanel.svelte b/dashboard/src/lib/components/ImageParamsPanel.svelte
index 39742b22..a7d23798 100644
--- a/dashboard/src/lib/components/ImageParamsPanel.svelte
+++ b/dashboard/src/lib/components/ImageParamsPanel.svelte
@@ -1,502 +1,556 @@
 <script lang="ts">
-	import {
-		imageGenerationParams,
-		setImageGenerationParams,
-		resetImageGenerationParams,
-		type ImageGenerationParams,
-	} from "$lib/stores/app.svelte";
-
-	interface Props {
-		isEditMode?: boolean;
-	}
-
-	let { isEditMode = false }: Props = $props();
-
-	let showAdvanced = $state(false);
-
-	// Custom dropdown state
-	let isSizeDropdownOpen = $state(false);
-	let isQualityDropdownOpen = $state(false);
-	let sizeButtonRef: HTMLButtonElement | undefined = $state();
-	let qualityButtonRef: HTMLButtonElement | undefined = $state();
-
-	const sizeDropdownPosition = $derived(() => {
-		if (!sizeButtonRef || !isSizeDropdownOpen) return { top: 0, left: 0, width: 0 };
-		const rect = sizeButtonRef.getBoundingClientRect();
-		return { top: rect.top, left: rect.left, width: rect.width };
-	});
-
-	const qualityDropdownPosition = $derived(() => {
-		if (!qualityButtonRef || !isQualityDropdownOpen) return { top: 0, left: 0, width: 0 };
-		const rect = qualityButtonRef.getBoundingClientRect();
-		return { top: rect.top, left: rect.left, width: rect.width };
-	});
-
-	const params = $derived(imageGenerationParams());
-
-	const inputFidelityOptions: ImageGenerationParams["inputFidelity"][] = [
-		"low",
-		"high",
-	];
-
-	const outputFormatOptions: ImageGenerationParams["outputFormat"][] = [
-		"png",
-		"jpeg",
-	];
-
-	function handleInputFidelityChange(value: ImageGenerationParams["inputFidelity"]) {
-		setImageGenerationParams({ inputFidelity: value });
-	}
-
-	function handleOutputFormatChange(value: ImageGenerationParams["outputFormat"]) {
-		setImageGenerationParams({ outputFormat: value });
-	}
-
-	const sizeOptions: ImageGenerationParams["size"][] = [
-		"512x512",
-		"768x768",
-		"1024x1024",
-		"1024x768",
-		"768x1024",
-	];
-
-	const qualityOptions: ImageGenerationParams["quality"][] = [
-		"low",
-		"medium",
-		"high",
-	];
-
-	function selectSize(value: ImageGenerationParams["size"]) {
-		setImageGenerationParams({ size: value });
-		isSizeDropdownOpen = false;
-	}
-
-	function selectQuality(value: ImageGenerationParams["quality"]) {
-		setImageGenerationParams({ quality: value });
-		isQualityDropdownOpen = false;
-	}
-
-	function handleSeedChange(event: Event) {
-		const input = event.target as HTMLInputElement;
-		const value = input.value.trim();
-		if (value === "") {
-			setImageGenerationParams({ seed: null });
-		} else {
-			const num = parseInt(value, 10);
-			if (!isNaN(num) && num >= 0) {
-				setImageGenerationParams({ seed: num });
-			}
-		}
-	}
-
-	function handleStepsChange(event: Event) {
-		const value = parseInt((event.target as HTMLInputElement).value, 10);
-		setImageGenerationParams({ numInferenceSteps: value });
-	}
-
-	function handleGuidanceChange(event: Event) {
-		const value = parseFloat((event.target as HTMLInputElement).value);
-		setImageGenerationParams({ guidance: value });
-	}
-
-	function handleNegativePromptChange(event: Event) {
-		const value = (event.target as HTMLTextAreaElement).value;
-		setImageGenerationParams({ negativePrompt: value || null });
-	}
-
-	function clearSteps() {
-		setImageGenerationParams({ numInferenceSteps: null });
-	}
-
-	function clearGuidance() {
-		setImageGenerationParams({ guidance: null });
-	}
-
-	function handleReset() {
-		resetImageGenerationParams();
-		showAdvanced = false;
-	}
-
-	const hasAdvancedParams = $derived(
-		params.seed !== null ||
-			params.numInferenceSteps !== null ||
-			params.guidance !== null ||
-			(params.negativePrompt !== null && params.negativePrompt.trim() !== ""),
-	);
+  import {
+    imageGenerationParams,
+    setImageGenerationParams,
+    resetImageGenerationParams,
+    type ImageGenerationParams,
+  } from "$lib/stores/app.svelte";
+
+  interface Props {
+    isEditMode?: boolean;
+  }
+
+  let { isEditMode = false }: Props = $props();
+
+  let showAdvanced = $state(false);
+
+  // Custom dropdown state
+  let isSizeDropdownOpen = $state(false);
+  let isQualityDropdownOpen = $state(false);
+  let sizeButtonRef: HTMLButtonElement | undefined = $state();
+  let qualityButtonRef: HTMLButtonElement | undefined = $state();
+
+  const sizeDropdownPosition = $derived(() => {
+    if (!sizeButtonRef || !isSizeDropdownOpen)
+      return { top: 0, left: 0, width: 0 };
+    const rect = sizeButtonRef.getBoundingClientRect();
+    return { top: rect.top, left: rect.left, width: rect.width };
+  });
+
+  const qualityDropdownPosition = $derived(() => {
+    if (!qualityButtonRef || !isQualityDropdownOpen)
+      return { top: 0, left: 0, width: 0 };
+    const rect = qualityButtonRef.getBoundingClientRect();
+    return { top: rect.top, left: rect.left, width: rect.width };
+  });
+
+  const params = $derived(imageGenerationParams());
+
+  const inputFidelityOptions: ImageGenerationParams["inputFidelity"][] = [
+    "low",
+    "high",
+  ];
+
+  const outputFormatOptions: ImageGenerationParams["outputFormat"][] = [
+    "png",
+    "jpeg",
+  ];
+
+  function handleInputFidelityChange(
+    value: ImageGenerationParams["inputFidelity"],
+  ) {
+    setImageGenerationParams({ inputFidelity: value });
+  }
+
+  function handleOutputFormatChange(
+    value: ImageGenerationParams["outputFormat"],
+  ) {
+    setImageGenerationParams({ outputFormat: value });
+  }
+
+  const sizeOptions: ImageGenerationParams["size"][] = [
+    "512x512",
+    "768x768",
+    "1024x1024",
+    "1024x768",
+    "768x1024",
+  ];
+
+  const qualityOptions: ImageGenerationParams["quality"][] = [
+    "low",
+    "medium",
+    "high",
+  ];
+
+  function selectSize(value: ImageGenerationParams["size"]) {
+    setImageGenerationParams({ size: value });
+    isSizeDropdownOpen = false;
+  }
+
+  function selectQuality(value: ImageGenerationParams["quality"]) {
+    setImageGenerationParams({ quality: value });
+    isQualityDropdownOpen = false;
+  }
+
+  function handleSeedChange(event: Event) {
+    const input = event.target as HTMLInputElement;
+    const value = input.value.trim();
+    if (value === "") {
+      setImageGenerationParams({ seed: null });
+    } else {
+      const num = parseInt(value, 10);
+      if (!isNaN(num) && num >= 0) {
+        setImageGenerationParams({ seed: num });
+      }
+    }
+  }
+
+  function handleStepsChange(event: Event) {
+    const value = parseInt((event.target as HTMLInputElement).value, 10);
+    setImageGenerationParams({ numInferenceSteps: value });
+  }
+
+  function handleGuidanceChange(event: Event) {
+    const value = parseFloat((event.target as HTMLInputElement).value);
+    setImageGenerationParams({ guidance: value });
+  }
+
+  function handleNegativePromptChange(event: Event) {
+    const value = (event.target as HTMLTextAreaElement).value;
+    setImageGenerationParams({ negativePrompt: value || null });
+  }
+
+  function clearSteps() {
+    setImageGenerationParams({ numInferenceSteps: null });
+  }
+
+  function clearGuidance() {
+    setImageGenerationParams({ guidance: null });
+  }
+
+  function handleReset() {
+    resetImageGenerationParams();
+    showAdvanced = false;
+  }
+
+  const hasAdvancedParams = $derived(
+    params.seed !== null ||
+      params.numInferenceSteps !== null ||
+      params.guidance !== null ||
+      (params.negativePrompt !== null && params.negativePrompt.trim() !== ""),
+  );
 </script>
 
 <div class="border-b border-exo-medium-gray/30 px-3 py-2">
-	<!-- Basic params row -->
-	<div class="flex items-center gap-3 flex-wrap">
-		<!-- Size -->
-		<div class="flex items-center gap-1.5">
-			<span class="text-xs text-exo-light-gray uppercase tracking-wider"
-				>SIZE:</span
-			>
-			<div class="relative">
-				<button
-					bind:this={sizeButtonRef}
-					type="button"
-					onclick={() => isSizeDropdownOpen = !isSizeDropdownOpen}
-					class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isSizeDropdownOpen ? 'border-exo-yellow/70' : ''}"
-				>
-					{params.size}
-				</button>
-				<div class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isSizeDropdownOpen ? 'rotate-180' : ''}">
-					<svg class="w-3 h-3 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-						<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
-					</svg>
-				</div>
-			</div>
-
-			{#if isSizeDropdownOpen}
-				<!-- Backdrop to close dropdown -->
-				<button
-					type="button"
-					class="fixed inset-0 z-[9998] cursor-default"
-					onclick={() => isSizeDropdownOpen = false}
-					aria-label="Close dropdown"
-				></button>
-
-				<!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
-				<div
-					class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
-					style="bottom: calc(100vh - {sizeDropdownPosition().top}px + 4px); left: {sizeDropdownPosition().left}px;"
-				>
-					<div class="py-1">
-						{#each sizeOptions as size}
-							<button
-								type="button"
-								onclick={() => selectSize(size)}
-								class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {
-									params.size === size
-									? 'bg-transparent text-exo-yellow'
-									: 'text-exo-light-gray hover:text-exo-yellow'
-								}"
-							>
-								{#if params.size === size}
-									<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
-										<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
-									</svg>
-								{:else}
-									<span class="w-3"></span>
-								{/if}
-								<span>{size}</span>
-							</button>
-						{/each}
-					</div>
-				</div>
-			{/if}
-		</div>
-
-		<!-- Quality -->
-		<div class="flex items-center gap-1.5">
-			<span class="text-xs text-exo-light-gray uppercase tracking-wider"
-				>QUALITY:</span
-			>
-			<div class="relative">
-				<button
-					bind:this={qualityButtonRef}
-					type="button"
-					onclick={() => isQualityDropdownOpen = !isQualityDropdownOpen}
-					class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isQualityDropdownOpen ? 'border-exo-yellow/70' : ''}"
-				>
-					{params.quality.toUpperCase()}
-				</button>
-				<div class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isQualityDropdownOpen ? 'rotate-180' : ''}">
-					<svg class="w-3 h-3 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-						<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
-					</svg>
-				</div>
-			</div>
-
-			{#if isQualityDropdownOpen}
-				<!-- Backdrop to close dropdown -->
-				<button
-					type="button"
-					class="fixed inset-0 z-[9998] cursor-default"
-					onclick={() => isQualityDropdownOpen = false}
-					aria-label="Close dropdown"
-				></button>
-
-				<!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
-				<div
-					class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
-					style="bottom: calc(100vh - {qualityDropdownPosition().top}px + 4px); left: {qualityDropdownPosition().left}px;"
-				>
-					<div class="py-1">
-						{#each qualityOptions as quality}
-							<button
-								type="button"
-								onclick={() => selectQuality(quality)}
-								class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {
-									params.quality === quality
-									? 'bg-transparent text-exo-yellow'
-									: 'text-exo-light-gray hover:text-exo-yellow'
-								}"
-							>
-								{#if params.quality === quality}
-									<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
-										<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
-									</svg>
-								{:else}
-									<span class="w-3"></span>
-								{/if}
-								<span>{quality.toUpperCase()}</span>
-							</button>
-						{/each}
-					</div>
-				</div>
-			{/if}
-		</div>
-
-		<!-- Format -->
-		<div class="flex items-center gap-1.5">
-			<span class="text-xs text-exo-light-gray uppercase tracking-wider"
-				>FORMAT:</span
-			>
-			<div class="flex rounded overflow-hidden border border-exo-yellow/30">
-				{#each outputFormatOptions as format}
-					<button
-						type="button"
-						onclick={() => handleOutputFormatChange(format)}
-						class="px-2 py-1 text-xs font-mono uppercase transition-all duration-200 cursor-pointer {
-							params.outputFormat === format
-								? 'bg-exo-yellow text-exo-black'
-								: 'bg-exo-medium-gray/50 text-exo-light-gray hover:text-exo-yellow'
-						}"
-					>
-						{format}
-					</button>
-				{/each}
-			</div>
-		</div>
-
-		<!-- Input Fidelity (edit mode only) -->
-		{#if isEditMode}
-			<div class="flex items-center gap-1.5">
-				<span class="text-xs text-exo-light-gray uppercase tracking-wider"
-					>FIDELITY:</span
-				>
-				<div class="flex rounded overflow-hidden border border-exo-yellow/30">
-					{#each inputFidelityOptions as fidelity}
-						<button
-							type="button"
-							onclick={() => handleInputFidelityChange(fidelity)}
-							class="px-2 py-1 text-xs font-mono uppercase transition-all duration-200 cursor-pointer {
-								params.inputFidelity === fidelity
-									? 'bg-exo-yellow text-exo-black'
-									: 'bg-exo-medium-gray/50 text-exo-light-gray hover:text-exo-yellow'
-							}"
-							title={fidelity === 'low' ? 'More creative variation' : 'Closer to original'}
-						>
-							{fidelity}
-						</button>
-					{/each}
-				</div>
-			</div>
-		{/if}
-
-		<!-- Spacer -->
-		<div class="flex-1"></div>
-
-		<!-- Advanced toggle -->
-		<button
-			type="button"
-			onclick={() => (showAdvanced = !showAdvanced)}
-			class="flex items-center gap-1 text-xs font-mono tracking-wider uppercase transition-colors duration-200 {showAdvanced ||
-			hasAdvancedParams
-				? 'text-exo-yellow'
-				: 'text-exo-light-gray hover:text-exo-yellow'}"
-		>
-			<span>ADVANCED</span>
-			<svg
-				class="w-3 h-3 transition-transform duration-200 {showAdvanced
-					? 'rotate-180'
-					: ''}"
-				fill="none"
-				viewBox="0 0 24 24"
-				stroke="currentColor"
-			>
-				<path
-					stroke-linecap="round"
-					stroke-linejoin="round"
-					stroke-width="2"
-					d="M19 9l-7 7-7-7"
-				/>
-			</svg>
-			{#if hasAdvancedParams && !showAdvanced}
-				<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"></span>
-			{/if}
-		</button>
-	</div>
-
-	<!-- Advanced params section -->
-	{#if showAdvanced}
-		<div class="mt-3 pt-3 border-t border-exo-medium-gray/20 space-y-3">
-			<!-- Row 1: Seed and Steps -->
-			<div class="flex items-center gap-4 flex-wrap">
-				<!-- Seed -->
-				<div class="flex items-center gap-1.5">
-					<span class="text-xs text-exo-light-gray uppercase tracking-wider"
-						>SEED:</span
-					>
-					<input
-						type="number"
-						min="0"
-						value={params.seed ?? ""}
-						oninput={handleSeedChange}
-						placeholder="Random"
-						class="w-24 bg-exo-medium-gray/50 border border-exo-yellow/30 rounded px-2 py-1 text-xs font-mono text-exo-yellow placeholder:text-exo-light-gray/50 transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70"
-					/>
-				</div>
-
-				<!-- Steps Slider -->
-				<div class="flex items-center gap-1.5 flex-1 min-w-[200px]">
-					<span
-						class="text-xs text-exo-light-gray uppercase tracking-wider whitespace-nowrap"
-						>STEPS:</span
-					>
-					<div class="flex items-center gap-2 flex-1">
-						<input
-							type="range"
-							min="1"
-							max="100"
-							value={params.numInferenceSteps ?? 50}
-							oninput={handleStepsChange}
-							class="flex-1 h-1 bg-exo-medium-gray/50 rounded appearance-none cursor-pointer accent-exo-yellow"
-						/>
-						<span class="text-xs font-mono text-exo-yellow w-8 text-right">
-							{params.numInferenceSteps ?? "--"}
-						</span>
-						{#if params.numInferenceSteps !== null}
-							<button
-								type="button"
-								onclick={clearSteps}
-								class="text-exo-light-gray hover:text-exo-yellow transition-colors"
-								title="Clear"
-							>
-								<svg
-									class="w-3 h-3"
-									fill="none"
-									viewBox="0 0 24 24"
-									stroke="currentColor"
-								>
-									<path
-										stroke-linecap="round"
-										stroke-linejoin="round"
-										stroke-width="2"
-										d="M6 18L18 6M6 6l12 12"
-									/>
-								</svg>
-							</button>
-						{/if}
-					</div>
-				</div>
-			</div>
-
-			<!-- Row 2: Guidance -->
-			<div class="flex items-center gap-1.5">
-				<span
-					class="text-xs text-exo-light-gray uppercase tracking-wider whitespace-nowrap"
-					>GUIDANCE:</span
-				>
-				<div class="flex items-center gap-2 flex-1 max-w-xs">
-					<input
-						type="range"
-						min="1"
-						max="20"
-						step="0.5"
-						value={params.guidance ?? 7.5}
-						oninput={handleGuidanceChange}
-						class="flex-1 h-1 bg-exo-medium-gray/50 rounded appearance-none cursor-pointer accent-exo-yellow"
-					/>
-					<span class="text-xs font-mono text-exo-yellow w-8 text-right">
-						{params.guidance !== null ? params.guidance.toFixed(1) : "--"}
-					</span>
-					{#if params.guidance !== null}
-						<button
-							type="button"
-							onclick={clearGuidance}
-							class="text-exo-light-gray hover:text-exo-yellow transition-colors"
-							title="Clear"
-						>
-							<svg
-								class="w-3 h-3"
-								fill="none"
-								viewBox="0 0 24 24"
-								stroke="currentColor"
-							>
-								<path
-									stroke-linecap="round"
-									stroke-linejoin="round"
-									stroke-width="2"
-									d="M6 18L18 6M6 6l12 12"
-								/>
-							</svg>
-						</button>
-					{/if}
-				</div>
-			</div>
-
-			<!-- Row 3: Negative Prompt -->
-			<div class="flex flex-col gap-1.5">
-				<span class="text-xs text-exo-light-gray uppercase tracking-wider"
-					>NEGATIVE PROMPT:</span
-				>
-				<textarea
-					value={params.negativePrompt ?? ""}
-					oninput={handleNegativePromptChange}
-					placeholder="Things to avoid in the image..."
-					rows={2}
-					class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs font-mono text-exo-yellow placeholder:text-exo-light-gray/50 resize-none transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70"
-				></textarea>
-			</div>
-
-			<!-- Reset Button -->
-			<div class="flex justify-end pt-1">
-				<button
-					type="button"
-					onclick={handleReset}
-					class="text-xs font-mono tracking-wider uppercase text-exo-light-gray hover:text-exo-yellow transition-colors duration-200"
-				>
-					RESET TO DEFAULTS
-				</button>
-			</div>
-		</div>
-	{/if}
+  <!-- Basic params row -->
+  <div class="flex items-center gap-3 flex-wrap">
+    <!-- Size -->
+    <div class="flex items-center gap-1.5">
+      <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+        >SIZE:</span
+      >
+      <div class="relative">
+        <button
+          bind:this={sizeButtonRef}
+          type="button"
+          onclick={() => (isSizeDropdownOpen = !isSizeDropdownOpen)}
+          class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isSizeDropdownOpen
+            ? 'border-exo-yellow/70'
+            : ''}"
+        >
+          {params.size}
+        </button>
+        <div
+          class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isSizeDropdownOpen
+            ? 'rotate-180'
+            : ''}"
+        >
+          <svg
+            class="w-3 h-3 text-exo-yellow/60"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              stroke-width="2"
+              d="M19 9l-7 7-7-7"
+            />
+          </svg>
+        </div>
+      </div>
+
+      {#if isSizeDropdownOpen}
+        <!-- Backdrop to close dropdown -->
+        <button
+          type="button"
+          class="fixed inset-0 z-[9998] cursor-default"
+          onclick={() => (isSizeDropdownOpen = false)}
+          aria-label="Close dropdown"
+        ></button>
+
+        <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+        <div
+          class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
+          style="bottom: calc(100vh - {sizeDropdownPosition()
+            .top}px + 4px); left: {sizeDropdownPosition().left}px;"
+        >
+          <div class="py-1">
+            {#each sizeOptions as size}
+              <button
+                type="button"
+                onclick={() => selectSize(size)}
+                class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {params.size ===
+                size
+                  ? 'bg-transparent text-exo-yellow'
+                  : 'text-exo-light-gray hover:text-exo-yellow'}"
+              >
+                {#if params.size === size}
+                  <svg
+                    class="w-3 h-3 flex-shrink-0"
+                    fill="currentColor"
+                    viewBox="0 0 20 20"
+                  >
+                    <path
+                      fill-rule="evenodd"
+                      d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+                      clip-rule="evenodd"
+                    />
+                  </svg>
+                {:else}
+                  <span class="w-3"></span>
+                {/if}
+                <span>{size}</span>
+              </button>
+            {/each}
+          </div>
+        </div>
+      {/if}
+    </div>
+
+    <!-- Quality -->
+    <div class="flex items-center gap-1.5">
+      <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+        >QUALITY:</span
+      >
+      <div class="relative">
+        <button
+          bind:this={qualityButtonRef}
+          type="button"
+          onclick={() => (isQualityDropdownOpen = !isQualityDropdownOpen)}
+          class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isQualityDropdownOpen
+            ? 'border-exo-yellow/70'
+            : ''}"
+        >
+          {params.quality.toUpperCase()}
+        </button>
+        <div
+          class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isQualityDropdownOpen
+            ? 'rotate-180'
+            : ''}"
+        >
+          <svg
+            class="w-3 h-3 text-exo-yellow/60"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              stroke-width="2"
+              d="M19 9l-7 7-7-7"
+            />
+          </svg>
+        </div>
+      </div>
+
+      {#if isQualityDropdownOpen}
+        <!-- Backdrop to close dropdown -->
+        <button
+          type="button"
+          class="fixed inset-0 z-[9998] cursor-default"
+          onclick={() => (isQualityDropdownOpen = false)}
+          aria-label="Close dropdown"
+        ></button>
+
+        <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+        <div
+          class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
+          style="bottom: calc(100vh - {qualityDropdownPosition()
+            .top}px + 4px); left: {qualityDropdownPosition().left}px;"
+        >
+          <div class="py-1">
+            {#each qualityOptions as quality}
+              <button
+                type="button"
+                onclick={() => selectQuality(quality)}
+                class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {params.quality ===
+                quality
+                  ? 'bg-transparent text-exo-yellow'
+                  : 'text-exo-light-gray hover:text-exo-yellow'}"
+              >
+                {#if params.quality === quality}
+                  <svg
+                    class="w-3 h-3 flex-shrink-0"
+                    fill="currentColor"
+                    viewBox="0 0 20 20"
+                  >
+                    <path
+                      fill-rule="evenodd"
+                      d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+                      clip-rule="evenodd"
+                    />
+                  </svg>
+                {:else}
+                  <span class="w-3"></span>
+                {/if}
+                <span>{quality.toUpperCase()}</span>
+              </button>
+            {/each}
+          </div>
+        </div>
+      {/if}
+    </div>
+
+    <!-- Format -->
+    <div class="flex items-center gap-1.5">
+      <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+        >FORMAT:</span
+      >
+      <div class="flex rounded overflow-hidden border border-exo-yellow/30">
+        {#each outputFormatOptions as format}
+          <button
+            type="button"
+            onclick={() => handleOutputFormatChange(format)}
+            class="px-2 py-1 text-xs font-mono uppercase transition-all duration-200 cursor-pointer {params.outputFormat ===
+            format
+              ? 'bg-exo-yellow text-exo-black'
+              : 'bg-exo-medium-gray/50 text-exo-light-gray hover:text-exo-yellow'}"
+          >
+            {format}
+          </button>
+        {/each}
+      </div>
+    </div>
+
+    <!-- Input Fidelity (edit mode only) -->
+    {#if isEditMode}
+      <div class="flex items-center gap-1.5">
+        <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+          >FIDELITY:</span
+        >
+        <div class="flex rounded overflow-hidden border border-exo-yellow/30">
+          {#each inputFidelityOptions as fidelity}
+            <button
+              type="button"
+              onclick={() => handleInputFidelityChange(fidelity)}
+              class="px-2 py-1 text-xs font-mono uppercase transition-all duration-200 cursor-pointer {params.inputFidelity ===
+              fidelity
+                ? 'bg-exo-yellow text-exo-black'
+                : 'bg-exo-medium-gray/50 text-exo-light-gray hover:text-exo-yellow'}"
+              title={fidelity === "low"
+                ? "More creative variation"
+                : "Closer to original"}
+            >
+              {fidelity}
+            </button>
+          {/each}
+        </div>
+      </div>
+    {/if}
+
+    <!-- Spacer -->
+    <div class="flex-1"></div>
+
+    <!-- Advanced toggle -->
+    <button
+      type="button"
+      onclick={() => (showAdvanced = !showAdvanced)}
+      class="flex items-center gap-1 text-xs font-mono tracking-wider uppercase transition-colors duration-200 {showAdvanced ||
+      hasAdvancedParams
+        ? 'text-exo-yellow'
+        : 'text-exo-light-gray hover:text-exo-yellow'}"
+    >
+      <span>ADVANCED</span>
+      <svg
+        class="w-3 h-3 transition-transform duration-200 {showAdvanced
+          ? 'rotate-180'
+          : ''}"
+        fill="none"
+        viewBox="0 0 24 24"
+        stroke="currentColor"
+      >
+        <path
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          d="M19 9l-7 7-7-7"
+        />
+      </svg>
+      {#if hasAdvancedParams && !showAdvanced}
+        <span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"></span>
+      {/if}
+    </button>
+  </div>
+
+  <!-- Advanced params section -->
+  {#if showAdvanced}
+    <div class="mt-3 pt-3 border-t border-exo-medium-gray/20 space-y-3">
+      <!-- Row 1: Seed and Steps -->
+      <div class="flex items-center gap-4 flex-wrap">
+        <!-- Seed -->
+        <div class="flex items-center gap-1.5">
+          <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+            >SEED:</span
+          >
+          <input
+            type="number"
+            min="0"
+            value={params.seed ?? ""}
+            oninput={handleSeedChange}
+            placeholder="Random"
+            class="w-24 bg-exo-medium-gray/50 border border-exo-yellow/30 rounded px-2 py-1 text-xs font-mono text-exo-yellow placeholder:text-exo-light-gray/50 transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70"
+          />
+        </div>
+
+        <!-- Steps Slider -->
+        <div class="flex items-center gap-1.5 flex-1 min-w-[200px]">
+          <span
+            class="text-xs text-exo-light-gray uppercase tracking-wider whitespace-nowrap"
+            >STEPS:</span
+          >
+          <div class="flex items-center gap-2 flex-1">
+            <input
+              type="range"
+              min="1"
+              max="100"
+              value={params.numInferenceSteps ?? 50}
+              oninput={handleStepsChange}
+              class="flex-1 h-1 bg-exo-medium-gray/50 rounded appearance-none cursor-pointer accent-exo-yellow"
+            />
+            <span class="text-xs font-mono text-exo-yellow w-8 text-right">
+              {params.numInferenceSteps ?? "--"}
+            </span>
+            {#if params.numInferenceSteps !== null}
+              <button
+                type="button"
+                onclick={clearSteps}
+                class="text-exo-light-gray hover:text-exo-yellow transition-colors"
+                title="Clear"
+              >
+                <svg
+                  class="w-3 h-3"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                >
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    stroke-width="2"
+                    d="M6 18L18 6M6 6l12 12"
+                  />
+                </svg>
+              </button>
+            {/if}
+          </div>
+        </div>
+      </div>
+
+      <!-- Row 2: Guidance -->
+      <div class="flex items-center gap-1.5">
+        <span
+          class="text-xs text-exo-light-gray uppercase tracking-wider whitespace-nowrap"
+          >GUIDANCE:</span
+        >
+        <div class="flex items-center gap-2 flex-1 max-w-xs">
+          <input
+            type="range"
+            min="1"
+            max="20"
+            step="0.5"
+            value={params.guidance ?? 7.5}
+            oninput={handleGuidanceChange}
+            class="flex-1 h-1 bg-exo-medium-gray/50 rounded appearance-none cursor-pointer accent-exo-yellow"
+          />
+          <span class="text-xs font-mono text-exo-yellow w-8 text-right">
+            {params.guidance !== null ? params.guidance.toFixed(1) : "--"}
+          </span>
+          {#if params.guidance !== null}
+            <button
+              type="button"
+              onclick={clearGuidance}
+              class="text-exo-light-gray hover:text-exo-yellow transition-colors"
+              title="Clear"
+            >
+              <svg
+                class="w-3 h-3"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  stroke-width="2"
+                  d="M6 18L18 6M6 6l12 12"
+                />
+              </svg>
+            </button>
+          {/if}
+        </div>
+      </div>
+
+      <!-- Row 3: Negative Prompt -->
+      <div class="flex flex-col gap-1.5">
+        <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+          >NEGATIVE PROMPT:</span
+        >
+        <textarea
+          value={params.negativePrompt ?? ""}
+          oninput={handleNegativePromptChange}
+          placeholder="Things to avoid in the image..."
+          rows={2}
+          class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs font-mono text-exo-yellow placeholder:text-exo-light-gray/50 resize-none transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70"
+        ></textarea>
+      </div>
+
+      <!-- Reset Button -->
+      <div class="flex justify-end pt-1">
+        <button
+          type="button"
+          onclick={handleReset}
+          class="text-xs font-mono tracking-wider uppercase text-exo-light-gray hover:text-exo-yellow transition-colors duration-200"
+        >
+          RESET TO DEFAULTS
+        </button>
+      </div>
+    </div>
+  {/if}
 </div>
 
 <style>
-	/* Custom range slider styling */
-	input[type="range"]::-webkit-slider-thumb {
-		-webkit-appearance: none;
-		appearance: none;
-		width: 12px;
-		height: 12px;
-		border-radius: 50%;
-		background: #ffd700;
-		cursor: pointer;
-		border: none;
-	}
-
-	input[type="range"]::-moz-range-thumb {
-		width: 12px;
-		height: 12px;
-		border-radius: 50%;
-		background: #ffd700;
-		cursor: pointer;
-		border: none;
-	}
-
-	/* Hide number input spinners */
-	input[type="number"]::-webkit-inner-spin-button,
-	input[type="number"]::-webkit-outer-spin-button {
-		-webkit-appearance: none;
-		margin: 0;
-	}
-
-	input[type="number"] {
-		-moz-appearance: textfield;
-	}
+  /* Custom range slider styling */
+  input[type="range"]::-webkit-slider-thumb {
+    -webkit-appearance: none;
+    appearance: none;
+    width: 12px;
+    height: 12px;
+    border-radius: 50%;
+    background: #ffd700;
+    cursor: pointer;
+    border: none;
+  }
+
+  input[type="range"]::-moz-range-thumb {
+    width: 12px;
+    height: 12px;
+    border-radius: 50%;
+    background: #ffd700;
+    cursor: pointer;
+    border: none;
+  }
+
+  /* Hide number input spinners */
+  input[type="number"]::-webkit-inner-spin-button,
+  input[type="number"]::-webkit-outer-spin-button {
+    -webkit-appearance: none;
+    margin: 0;
+  }
+
+  input[type="number"] {
+    -moz-appearance: textfield;
+  }
 </style>
diff --git a/dashboard/src/lib/components/MarkdownContent.svelte b/dashboard/src/lib/components/MarkdownContent.svelte
index 7ac3b66b..1f83ac0c 100644
--- a/dashboard/src/lib/components/MarkdownContent.svelte
+++ b/dashboard/src/lib/components/MarkdownContent.svelte
@@ -1,35 +1,35 @@
 <script lang="ts">
-	import { marked } from 'marked';
-	import hljs from 'highlight.js';
-	import katex from 'katex';
-	import 'katex/dist/katex.min.css';
-	import { browser } from '$app/environment';
+  import { marked } from "marked";
+  import hljs from "highlight.js";
+  import katex from "katex";
+  import "katex/dist/katex.min.css";
+  import { browser } from "$app/environment";
 
-	interface Props {
-		content: string;
-		class?: string;
-	}
+  interface Props {
+    content: string;
+    class?: string;
+  }
 
-	let { content, class: className = '' }: Props = $props();
+  let { content, class: className = "" }: Props = $props();
 
-	let containerRef = $state<HTMLDivElement>();
-	let processedHtml = $state('');
+  let containerRef = $state<HTMLDivElement>();
+  let processedHtml = $state("");
 
-	// Configure marked with syntax highlighting
-	marked.setOptions({
-		gfm: true,
-		breaks: true
-	});
+  // Configure marked with syntax highlighting
+  marked.setOptions({
+    gfm: true,
+    breaks: true,
+  });
 
-	// Custom renderer for code blocks
-	const renderer = new marked.Renderer();
+  // Custom renderer for code blocks
+  const renderer = new marked.Renderer();
 
-	renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
-		const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
-		const highlighted = hljs.highlight(text, { language }).value;
-		const codeId = `code-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+  renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
+    const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
+    const highlighted = hljs.highlight(text, { language }).value;
+    const codeId = `code-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
 
-		return `
+    return `
 			<div class="code-block-wrapper">
 				<div class="code-block-header">
 					<span class="code-language">${language}</span>
@@ -43,264 +43,334 @@
 				<pre><code class="hljs language-${language}" data-code-id="${codeId}">${highlighted}</code></pre>
 			</div>
 		`;
-	};
-
-	// Inline code
-	renderer.codespan = function ({ text }: { text: string }) {
-		return `<code class="inline-code">${text}</code>`;
-	};
-
-	marked.use({ renderer });
-
-	/**
-	 * Unescape HTML entities that marked may have escaped
-	 */
-	function unescapeHtmlEntities(text: string): string {
-		return text
-			.replace(/&lt;/g, '<')
-			.replace(/&gt;/g, '>')
-			.replace(/&amp;/g, '&')
-			.replace(/&quot;/g, '"')
-			.replace(/&#39;/g, "'");
-	}
-
-	// Storage for math expressions extracted before markdown processing
-	const mathExpressions: Map<string, { content: string; displayMode: boolean }> = new Map();
-	let mathCounter = 0;
-
-	// Storage for HTML snippets that need protection from markdown
-	const htmlSnippets: Map<string, string> = new Map();
-	let htmlCounter = 0;
-
-	// Use alphanumeric placeholders that won't be interpreted as HTML tags
-	const MATH_PLACEHOLDER_PREFIX = 'MATHPLACEHOLDER';
-	const CODE_PLACEHOLDER_PREFIX = 'CODEPLACEHOLDER';
-	const HTML_PLACEHOLDER_PREFIX = 'HTMLPLACEHOLDER';
-
-	/**
-	 * Preprocess LaTeX: extract math, handle LaTeX document commands, and protect content
-	 */
-	function preprocessLaTeX(text: string): string {
-		// Reset storage
-		mathExpressions.clear();
-		mathCounter = 0;
-		htmlSnippets.clear();
-		htmlCounter = 0;
-
-		// Protect code blocks first
-		const codeBlocks: string[] = [];
-		let processed = text.replace(/```[\s\S]*?```|`[^`]+`/g, (match) => {
-			codeBlocks.push(match);
-			return `${CODE_PLACEHOLDER_PREFIX}${codeBlocks.length - 1}END`;
-		});
-
-		// Remove LaTeX document commands
-		processed = processed.replace(/\\documentclass(\[[^\]]*\])?\{[^}]*\}/g, '');
-		processed = processed.replace(/\\usepackage(\[[^\]]*\])?\{[^}]*\}/g, '');
-		processed = processed.replace(/\\begin\{document\}/g, '');
-		processed = processed.replace(/\\end\{document\}/g, '');
-		processed = processed.replace(/\\maketitle/g, '');
-		processed = processed.replace(/\\title\{[^}]*\}/g, '');
-		processed = processed.replace(/\\author\{[^}]*\}/g, '');
-		processed = processed.replace(/\\date\{[^}]*\}/g, '');
-
-		// Remove \require{...} commands (MathJax-specific, not supported by KaTeX)
-		processed = processed.replace(/\$\\require\{[^}]*\}\$/g, '');
-		processed = processed.replace(/\\require\{[^}]*\}/g, '');
-
-		// Remove unsupported LaTeX commands/environments (tikzpicture, figure, center, etc.)
-		processed = processed.replace(/\\begin\{tikzpicture\}[\s\S]*?\\end\{tikzpicture\}/g, () => {
-			const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
-			htmlSnippets.set(placeholder, '<div class="latex-diagram-placeholder"><span class="latex-diagram-icon">📐</span><span class="latex-diagram-text">Diagram</span></div>');
-			htmlCounter++;
-			return placeholder;
-		});
-		processed = processed.replace(/\\begin\{figure\}[\s\S]*?\\end\{figure\}/g, () => {
-			const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
-			htmlSnippets.set(placeholder, '<div class="latex-diagram-placeholder"><span class="latex-diagram-icon">🖼️</span><span class="latex-diagram-text">Figure</span></div>');
-			htmlCounter++;
-			return placeholder;
-		});
-		// Strip center environment (layout only, no content change)
-		processed = processed.replace(/\\begin\{center\}/g, '');
-		processed = processed.replace(/\\end\{center\}/g, '');
-		// Strip other layout environments
-		processed = processed.replace(/\\begin\{flushleft\}/g, '');
-		processed = processed.replace(/\\end\{flushleft\}/g, '');
-		processed = processed.replace(/\\begin\{flushright\}/g, '');
-		processed = processed.replace(/\\end\{flushright\}/g, '');
-		processed = processed.replace(/\\label\{[^}]*\}/g, '');
-		processed = processed.replace(/\\caption\{[^}]*\}/g, '');
-
-		// Protect escaped dollar signs (e.g., \$50 should become $50, not LaTeX)
-		processed = processed.replace(/\\\$/g, 'ESCAPEDDOLLARPLACEHOLDER');
-
-		// Convert LaTeX math environments to display math (both bare and wrapped in $...$)
-		const mathEnvs = ['align', 'align\\*', 'equation', 'equation\\*', 'gather', 'gather\\*', 'multline', 'multline\\*', 'eqnarray', 'eqnarray\\*', 'array', 'matrix', 'pmatrix', 'bmatrix', 'vmatrix', 'cases'];
-		for (const env of mathEnvs) {
-			// Handle $\begin{env}...\end{env}$ (with dollar signs, possibly multiline)
-			const wrappedRegex = new RegExp(`\\$\\\\begin\\{${env}\\}(\\{[^}]*\\})?([\\s\\S]*?)\\\\end\\{${env}\\}\\$`, 'g');
-			processed = processed.replace(wrappedRegex, (_, args, content) => {
-				const cleanEnv = env.replace('\\*', '*');
-				const mathContent = `\\begin{${cleanEnv}}${args || ''}${content}\\end{${cleanEnv}}`;
-				const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
-				mathExpressions.set(placeholder, { content: mathContent, displayMode: true });
-				mathCounter++;
-				return placeholder;
-			});
-
-			// Handle bare \begin{env}...\end{env} (without dollar signs)
-			const bareRegex = new RegExp(`\\\\begin\\{${env}\\}(\\{[^}]*\\})?([\\s\\S]*?)\\\\end\\{${env}\\}`, 'g');
-			processed = processed.replace(bareRegex, (_, args, content) => {
-				const cleanEnv = env.replace('\\*', '*');
-				const mathContent = `\\begin{${cleanEnv}}${args || ''}${content}\\end{${cleanEnv}}`;
-				const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
-				mathExpressions.set(placeholder, { content: mathContent, displayMode: true });
-				mathCounter++;
-				return placeholder;
-			});
-		}
-
-		// Convert LaTeX proof environments to styled blocks (use placeholders for HTML)
-		processed = processed.replace(
-			/\\begin\{proof\}([\s\S]*?)\\end\{proof\}/g,
-			(_, content) => {
-				const html = `<div class="latex-proof"><div class="latex-proof-header">Proof</div><div class="latex-proof-content">${content}</div></div>`;
-				const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
-				htmlSnippets.set(placeholder, html);
-				htmlCounter++;
-				return placeholder;
-			}
-		);
-
-		// Convert LaTeX theorem-like environments
-		const theoremEnvs = ['theorem', 'lemma', 'corollary', 'proposition', 'definition', 'remark', 'example'];
-		for (const env of theoremEnvs) {
-			const envRegex = new RegExp(`\\\\begin\\{${env}\\}([\\s\\S]*?)\\\\end\\{${env}\\}`, 'gi');
-			const envName = env.charAt(0).toUpperCase() + env.slice(1);
-			processed = processed.replace(envRegex, (_, content) => {
-				const html = `<div class="latex-theorem"><div class="latex-theorem-header">${envName}</div><div class="latex-theorem-content">${content}</div></div>`;
-				const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
-				htmlSnippets.set(placeholder, html);
-				htmlCounter++;
-				return placeholder;
-			});
-		}
-
-		// Convert LaTeX text formatting commands (use placeholders to protect from markdown)
-		processed = processed.replace(/\\emph\{([^}]*)\}/g, (_, content) => {
-			const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
-			htmlSnippets.set(placeholder, `<em>${content}</em>`);
-			htmlCounter++;
-			return placeholder;
-		});
-		processed = processed.replace(/\\textit\{([^}]*)\}/g, (_, content) => {
-			const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
-			htmlSnippets.set(placeholder, `<em>${content}</em>`);
-			htmlCounter++;
-			return placeholder;
-		});
-		processed = processed.replace(/\\textbf\{([^}]*)\}/g, (_, content) => {
-			const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
-			htmlSnippets.set(placeholder, `<strong>${content}</strong>`);
-			htmlCounter++;
-			return placeholder;
-		});
-		processed = processed.replace(/\\texttt\{([^}]*)\}/g, (_, content) => {
-			const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
-			htmlSnippets.set(placeholder, `<code class="inline-code">${content}</code>`);
-			htmlCounter++;
-			return placeholder;
-		});
-		processed = processed.replace(/\\underline\{([^}]*)\}/g, (_, content) => {
-			const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
-			htmlSnippets.set(placeholder, `<u>${content}</u>`);
-			htmlCounter++;
-			return placeholder;
-		});
-
-		// Handle LaTeX line breaks and spacing
-		processed = processed.replace(/\\\\(?:\s*\n)?/g, '\n'); // \\ -> newline
-		processed = processed.replace(/\\newline/g, '\n');
-		processed = processed.replace(/\\par\b/g, '\n\n');
-		processed = processed.replace(/\\quad/g, ' ');
-		processed = processed.replace(/\\qquad/g, '  ');
-		processed = processed.replace(/~~/g, ' '); // non-breaking space
-
-		// Remove other common LaTeX commands that don't render
-		processed = processed.replace(/\\centering/g, '');
-		processed = processed.replace(/\\noindent/g, '');
-		processed = processed.replace(/\\hfill/g, '');
-		processed = processed.replace(/\\vspace\{[^}]*\}/g, '');
-		processed = processed.replace(/\\hspace\{[^}]*\}/g, ' ');
-
-		// Convert \(...\) to placeholder (display: false)
-		processed = processed.replace(/\\\(([\s\S]+?)\\\)/g, (_, content) => {
-			const placeholder = `${MATH_PLACEHOLDER_PREFIX}INLINE${mathCounter}END`;
-			mathExpressions.set(placeholder, { content, displayMode: false });
-			mathCounter++;
-			return placeholder;
-		});
-
-		// Convert \[...\] to placeholder (display: true)
-		processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, (_, content) => {
-			const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
-			mathExpressions.set(placeholder, { content, displayMode: true });
-			mathCounter++;
-			return placeholder;
-		});
-
-		// Extract display math ($$...$$) BEFORE markdown processing
-		processed = processed.replace(/\$\$([\s\S]*?)\$\$/g, (_, content) => {
-			const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
-			mathExpressions.set(placeholder, { content: content.trim(), displayMode: true });
-			mathCounter++;
-			return placeholder;
-		});
-
-		// Extract inline math ($...$) BEFORE markdown processing
-		// Allow single-line only, skip currency patterns like $5 or $50
-		processed = processed.replace(/\$([^\$\n]+?)\$/g, (match, content) => {
-			if (/^\d/.test(content.trim())) {
-				return match; // Keep as-is for currency
-			}
-			const placeholder = `${MATH_PLACEHOLDER_PREFIX}INLINE${mathCounter}END`;
-			mathExpressions.set(placeholder, { content: content.trim(), displayMode: false });
-			mathCounter++;
-			return placeholder;
-		});
-
-		// Restore escaped dollar signs
-		processed = processed.replace(/ESCAPEDDOLLARPLACEHOLDER/g, '$');
-
-		// Restore code blocks
-		processed = processed.replace(new RegExp(`${CODE_PLACEHOLDER_PREFIX}(\\d+)END`, 'g'), (_, index) => codeBlocks[parseInt(index)]);
-
-		// Clean up any remaining stray backslashes from unrecognized commands
-		processed = processed.replace(/\\(?=[a-zA-Z])/g, ''); // Remove \ before letters (unrecognized commands)
-
-		return processed;
-	}
-
-	/**
-	 * Render math expressions with KaTeX and restore HTML placeholders
-	 */
-	function renderMath(html: string): string {
-		// Replace all math placeholders with rendered KaTeX
-		for (const [placeholder, { content, displayMode }] of mathExpressions) {
-			const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-			const regex = new RegExp(escapedPlaceholder, 'g');
-
-			html = html.replace(regex, () => {
-				try {
-					const rendered = katex.renderToString(content, {
-						displayMode,
-						throwOnError: false,
-						output: 'html'
-					});
-
-					if (displayMode) {
-						return `
+  };
+
+  // Inline code
+  renderer.codespan = function ({ text }: { text: string }) {
+    return `<code class="inline-code">${text}</code>`;
+  };
+
+  marked.use({ renderer });
+
+  /**
+   * Unescape HTML entities that marked may have escaped
+   */
+  function unescapeHtmlEntities(text: string): string {
+    return text
+      .replace(/&lt;/g, "<")
+      .replace(/&gt;/g, ">")
+      .replace(/&amp;/g, "&")
+      .replace(/&quot;/g, '"')
+      .replace(/&#39;/g, "'");
+  }
+
+  // Storage for math expressions extracted before markdown processing
+  const mathExpressions: Map<
+    string,
+    { content: string; displayMode: boolean }
+  > = new Map();
+  let mathCounter = 0;
+
+  // Storage for HTML snippets that need protection from markdown
+  const htmlSnippets: Map<string, string> = new Map();
+  let htmlCounter = 0;
+
+  // Use alphanumeric placeholders that won't be interpreted as HTML tags
+  const MATH_PLACEHOLDER_PREFIX = "MATHPLACEHOLDER";
+  const CODE_PLACEHOLDER_PREFIX = "CODEPLACEHOLDER";
+  const HTML_PLACEHOLDER_PREFIX = "HTMLPLACEHOLDER";
+
+  /**
+   * Preprocess LaTeX: extract math, handle LaTeX document commands, and protect content
+   */
+  function preprocessLaTeX(text: string): string {
+    // Reset storage
+    mathExpressions.clear();
+    mathCounter = 0;
+    htmlSnippets.clear();
+    htmlCounter = 0;
+
+    // Protect code blocks first
+    const codeBlocks: string[] = [];
+    let processed = text.replace(/```[\s\S]*?```|`[^`]+`/g, (match) => {
+      codeBlocks.push(match);
+      return `${CODE_PLACEHOLDER_PREFIX}${codeBlocks.length - 1}END`;
+    });
+
+    // Remove LaTeX document commands
+    processed = processed.replace(/\\documentclass(\[[^\]]*\])?\{[^}]*\}/g, "");
+    processed = processed.replace(/\\usepackage(\[[^\]]*\])?\{[^}]*\}/g, "");
+    processed = processed.replace(/\\begin\{document\}/g, "");
+    processed = processed.replace(/\\end\{document\}/g, "");
+    processed = processed.replace(/\\maketitle/g, "");
+    processed = processed.replace(/\\title\{[^}]*\}/g, "");
+    processed = processed.replace(/\\author\{[^}]*\}/g, "");
+    processed = processed.replace(/\\date\{[^}]*\}/g, "");
+
+    // Remove \require{...} commands (MathJax-specific, not supported by KaTeX)
+    processed = processed.replace(/\$\\require\{[^}]*\}\$/g, "");
+    processed = processed.replace(/\\require\{[^}]*\}/g, "");
+
+    // Remove unsupported LaTeX commands/environments (tikzpicture, figure, center, etc.)
+    processed = processed.replace(
+      /\\begin\{tikzpicture\}[\s\S]*?\\end\{tikzpicture\}/g,
+      () => {
+        const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+        htmlSnippets.set(
+          placeholder,
+          '<div class="latex-diagram-placeholder"><span class="latex-diagram-icon">📐</span><span class="latex-diagram-text">Diagram</span></div>',
+        );
+        htmlCounter++;
+        return placeholder;
+      },
+    );
+    processed = processed.replace(
+      /\\begin\{figure\}[\s\S]*?\\end\{figure\}/g,
+      () => {
+        const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+        htmlSnippets.set(
+          placeholder,
+          '<div class="latex-diagram-placeholder"><span class="latex-diagram-icon">🖼️</span><span class="latex-diagram-text">Figure</span></div>',
+        );
+        htmlCounter++;
+        return placeholder;
+      },
+    );
+    // Strip center environment (layout only, no content change)
+    processed = processed.replace(/\\begin\{center\}/g, "");
+    processed = processed.replace(/\\end\{center\}/g, "");
+    // Strip other layout environments
+    processed = processed.replace(/\\begin\{flushleft\}/g, "");
+    processed = processed.replace(/\\end\{flushleft\}/g, "");
+    processed = processed.replace(/\\begin\{flushright\}/g, "");
+    processed = processed.replace(/\\end\{flushright\}/g, "");
+    processed = processed.replace(/\\label\{[^}]*\}/g, "");
+    processed = processed.replace(/\\caption\{[^}]*\}/g, "");
+
+    // Protect escaped dollar signs (e.g., \$50 should become $50, not LaTeX)
+    processed = processed.replace(/\\\$/g, "ESCAPEDDOLLARPLACEHOLDER");
+
+    // Convert LaTeX math environments to display math (both bare and wrapped in $...$)
+    const mathEnvs = [
+      "align",
+      "align\\*",
+      "equation",
+      "equation\\*",
+      "gather",
+      "gather\\*",
+      "multline",
+      "multline\\*",
+      "eqnarray",
+      "eqnarray\\*",
+      "array",
+      "matrix",
+      "pmatrix",
+      "bmatrix",
+      "vmatrix",
+      "cases",
+    ];
+    for (const env of mathEnvs) {
+      // Handle $\begin{env}...\end{env}$ (with dollar signs, possibly multiline)
+      const wrappedRegex = new RegExp(
+        `\\$\\\\begin\\{${env}\\}(\\{[^}]*\\})?([\\s\\S]*?)\\\\end\\{${env}\\}\\$`,
+        "g",
+      );
+      processed = processed.replace(wrappedRegex, (_, args, content) => {
+        const cleanEnv = env.replace("\\*", "*");
+        const mathContent = `\\begin{${cleanEnv}}${args || ""}${content}\\end{${cleanEnv}}`;
+        const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
+        mathExpressions.set(placeholder, {
+          content: mathContent,
+          displayMode: true,
+        });
+        mathCounter++;
+        return placeholder;
+      });
+
+      // Handle bare \begin{env}...\end{env} (without dollar signs)
+      const bareRegex = new RegExp(
+        `\\\\begin\\{${env}\\}(\\{[^}]*\\})?([\\s\\S]*?)\\\\end\\{${env}\\}`,
+        "g",
+      );
+      processed = processed.replace(bareRegex, (_, args, content) => {
+        const cleanEnv = env.replace("\\*", "*");
+        const mathContent = `\\begin{${cleanEnv}}${args || ""}${content}\\end{${cleanEnv}}`;
+        const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
+        mathExpressions.set(placeholder, {
+          content: mathContent,
+          displayMode: true,
+        });
+        mathCounter++;
+        return placeholder;
+      });
+    }
+
+    // Convert LaTeX proof environments to styled blocks (use placeholders for HTML)
+    processed = processed.replace(
+      /\\begin\{proof\}([\s\S]*?)\\end\{proof\}/g,
+      (_, content) => {
+        const html = `<div class="latex-proof"><div class="latex-proof-header">Proof</div><div class="latex-proof-content">${content}</div></div>`;
+        const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+        htmlSnippets.set(placeholder, html);
+        htmlCounter++;
+        return placeholder;
+      },
+    );
+
+    // Convert LaTeX theorem-like environments
+    const theoremEnvs = [
+      "theorem",
+      "lemma",
+      "corollary",
+      "proposition",
+      "definition",
+      "remark",
+      "example",
+    ];
+    for (const env of theoremEnvs) {
+      const envRegex = new RegExp(
+        `\\\\begin\\{${env}\\}([\\s\\S]*?)\\\\end\\{${env}\\}`,
+        "gi",
+      );
+      const envName = env.charAt(0).toUpperCase() + env.slice(1);
+      processed = processed.replace(envRegex, (_, content) => {
+        const html = `<div class="latex-theorem"><div class="latex-theorem-header">${envName}</div><div class="latex-theorem-content">${content}</div></div>`;
+        const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+        htmlSnippets.set(placeholder, html);
+        htmlCounter++;
+        return placeholder;
+      });
+    }
+
+    // Convert LaTeX text formatting commands (use placeholders to protect from markdown)
+    processed = processed.replace(/\\emph\{([^}]*)\}/g, (_, content) => {
+      const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+      htmlSnippets.set(placeholder, `<em>${content}</em>`);
+      htmlCounter++;
+      return placeholder;
+    });
+    processed = processed.replace(/\\textit\{([^}]*)\}/g, (_, content) => {
+      const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+      htmlSnippets.set(placeholder, `<em>${content}</em>`);
+      htmlCounter++;
+      return placeholder;
+    });
+    processed = processed.replace(/\\textbf\{([^}]*)\}/g, (_, content) => {
+      const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+      htmlSnippets.set(placeholder, `<strong>${content}</strong>`);
+      htmlCounter++;
+      return placeholder;
+    });
+    processed = processed.replace(/\\texttt\{([^}]*)\}/g, (_, content) => {
+      const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+      htmlSnippets.set(
+        placeholder,
+        `<code class="inline-code">${content}</code>`,
+      );
+      htmlCounter++;
+      return placeholder;
+    });
+    processed = processed.replace(/\\underline\{([^}]*)\}/g, (_, content) => {
+      const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+      htmlSnippets.set(placeholder, `<u>${content}</u>`);
+      htmlCounter++;
+      return placeholder;
+    });
+
+    // Handle LaTeX line breaks and spacing
+    processed = processed.replace(/\\\\(?:\s*\n)?/g, "\n"); // \\ -> newline
+    processed = processed.replace(/\\newline/g, "\n");
+    processed = processed.replace(/\\par\b/g, "\n\n");
+    processed = processed.replace(/\\quad/g, " ");
+    processed = processed.replace(/\\qquad/g, "  ");
+    processed = processed.replace(/~~/g, " "); // non-breaking space
+
+    // Remove other common LaTeX commands that don't render
+    processed = processed.replace(/\\centering/g, "");
+    processed = processed.replace(/\\noindent/g, "");
+    processed = processed.replace(/\\hfill/g, "");
+    processed = processed.replace(/\\vspace\{[^}]*\}/g, "");
+    processed = processed.replace(/\\hspace\{[^}]*\}/g, " ");
+
+    // Convert \(...\) to placeholder (display: false)
+    processed = processed.replace(/\\\(([\s\S]+?)\\\)/g, (_, content) => {
+      const placeholder = `${MATH_PLACEHOLDER_PREFIX}INLINE${mathCounter}END`;
+      mathExpressions.set(placeholder, { content, displayMode: false });
+      mathCounter++;
+      return placeholder;
+    });
+
+    // Convert \[...\] to placeholder (display: true)
+    processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, (_, content) => {
+      const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
+      mathExpressions.set(placeholder, { content, displayMode: true });
+      mathCounter++;
+      return placeholder;
+    });
+
+    // Extract display math ($$...$$) BEFORE markdown processing
+    processed = processed.replace(/\$\$([\s\S]*?)\$\$/g, (_, content) => {
+      const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
+      mathExpressions.set(placeholder, {
+        content: content.trim(),
+        displayMode: true,
+      });
+      mathCounter++;
+      return placeholder;
+    });
+
+    // Extract inline math ($...$) BEFORE markdown processing
+    // Allow single-line only, skip currency patterns like $5 or $50
+    processed = processed.replace(/\$([^\$\n]+?)\$/g, (match, content) => {
+      if (/^\d/.test(content.trim())) {
+        return match; // Keep as-is for currency
+      }
+      const placeholder = `${MATH_PLACEHOLDER_PREFIX}INLINE${mathCounter}END`;
+      mathExpressions.set(placeholder, {
+        content: content.trim(),
+        displayMode: false,
+      });
+      mathCounter++;
+      return placeholder;
+    });
+
+    // Restore escaped dollar signs
+    processed = processed.replace(/ESCAPEDDOLLARPLACEHOLDER/g, "$");
+
+    // Restore code blocks
+    processed = processed.replace(
+      new RegExp(`${CODE_PLACEHOLDER_PREFIX}(\\d+)END`, "g"),
+      (_, index) => codeBlocks[parseInt(index)],
+    );
+
+    // Clean up any remaining stray backslashes from unrecognized commands
+    processed = processed.replace(/\\(?=[a-zA-Z])/g, ""); // Remove \ before letters (unrecognized commands)
+
+    return processed;
+  }
+
+  /**
+   * Render math expressions with KaTeX and restore HTML placeholders
+   */
+  function renderMath(html: string): string {
+    // Replace all math placeholders with rendered KaTeX
+    for (const [placeholder, { content, displayMode }] of mathExpressions) {
+      const escapedPlaceholder = placeholder.replace(
+        /[.*+?^${}()|[\]\\]/g,
+        "\\$&",
+      );
+      const regex = new RegExp(escapedPlaceholder, "g");
+
+      html = html.replace(regex, () => {
+        try {
+          const rendered = katex.renderToString(content, {
+            displayMode,
+            throwOnError: false,
+            output: "html",
+          });
+
+          if (displayMode) {
+            return `
 							<div class="math-display-wrapper">
 								<div class="math-display-header">
 									<span class="math-label">LaTeX</span>
@@ -316,655 +386,669 @@
 								</div>
 							</div>
 						`;
-					} else {
-						return `<span class="math-inline">${rendered}</span>`;
-					}
-				} catch {
-					const display = displayMode ? `$$${content}$$` : `$${content}$`;
-					return `<span class="math-error"><span class="math-error-icon">⚠</span> ${display}</span>`;
-				}
-			});
-		}
-
-		// Restore HTML placeholders (for \textbf, \emph, etc.)
-		for (const [placeholder, htmlContent] of htmlSnippets) {
-			const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-			const regex = new RegExp(escapedPlaceholder, 'g');
-			html = html.replace(regex, htmlContent);
-		}
-
-		return html;
-	}
-
-	function processMarkdown(text: string): string {
-		try {
-			// Preprocess LaTeX notation
-			const preprocessed = preprocessLaTeX(text);
-			// Parse markdown
-			let html = marked.parse(preprocessed) as string;
-			// Render math expressions
-			html = renderMath(html);
-			return html;
-		} catch (error) {
-			console.error('Markdown processing error:', error);
-			return text.replace(/\n/g, '<br>');
-		}
-	}
-
-	async function handleCopyClick(event: Event) {
-		const target = event.currentTarget as HTMLButtonElement;
-		const encodedCode = target.getAttribute('data-code');
-		if (!encodedCode) return;
-
-		const code = decodeURIComponent(encodedCode);
-
-		try {
-			await navigator.clipboard.writeText(code);
-			// Show copied feedback
-			const originalHtml = target.innerHTML;
-			target.innerHTML = `
+          } else {
+            return `<span class="math-inline">${rendered}</span>`;
+          }
+        } catch {
+          const display = displayMode ? `$$${content}$$` : `$${content}$`;
+          return `<span class="math-error"><span class="math-error-icon">⚠</span> ${display}</span>`;
+        }
+      });
+    }
+
+    // Restore HTML placeholders (for \textbf, \emph, etc.)
+    for (const [placeholder, htmlContent] of htmlSnippets) {
+      const escapedPlaceholder = placeholder.replace(
+        /[.*+?^${}()|[\]\\]/g,
+        "\\$&",
+      );
+      const regex = new RegExp(escapedPlaceholder, "g");
+      html = html.replace(regex, htmlContent);
+    }
+
+    return html;
+  }
+
+  function processMarkdown(text: string): string {
+    try {
+      // Preprocess LaTeX notation
+      const preprocessed = preprocessLaTeX(text);
+      // Parse markdown
+      let html = marked.parse(preprocessed) as string;
+      // Render math expressions
+      html = renderMath(html);
+      return html;
+    } catch (error) {
+      console.error("Markdown processing error:", error);
+      return text.replace(/\n/g, "<br>");
+    }
+  }
+
+  async function handleCopyClick(event: Event) {
+    const target = event.currentTarget as HTMLButtonElement;
+    const encodedCode = target.getAttribute("data-code");
+    if (!encodedCode) return;
+
+    const code = decodeURIComponent(encodedCode);
+
+    try {
+      await navigator.clipboard.writeText(code);
+      // Show copied feedback
+      const originalHtml = target.innerHTML;
+      target.innerHTML = `
 				<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 					<path d="M20 6L9 17l-5-5"/>
 				</svg>
 			`;
-			target.classList.add('copied');
-			setTimeout(() => {
-				target.innerHTML = originalHtml;
-				target.classList.remove('copied');
-			}, 2000);
-		} catch (error) {
-			console.error('Failed to copy:', error);
-		}
-	}
-
-	async function handleMathCopyClick(event: Event) {
-		const target = event.currentTarget as HTMLButtonElement;
-		const encodedSource = target.getAttribute('data-math-source');
-		if (!encodedSource) return;
-
-		const source = decodeURIComponent(encodedSource);
-
-		try {
-			await navigator.clipboard.writeText(source);
-			// Show copied feedback
-			const originalHtml = target.innerHTML;
-			target.innerHTML = `
+      target.classList.add("copied");
+      setTimeout(() => {
+        target.innerHTML = originalHtml;
+        target.classList.remove("copied");
+      }, 2000);
+    } catch (error) {
+      console.error("Failed to copy:", error);
+    }
+  }
+
+  async function handleMathCopyClick(event: Event) {
+    const target = event.currentTarget as HTMLButtonElement;
+    const encodedSource = target.getAttribute("data-math-source");
+    if (!encodedSource) return;
+
+    const source = decodeURIComponent(encodedSource);
+
+    try {
+      await navigator.clipboard.writeText(source);
+      // Show copied feedback
+      const originalHtml = target.innerHTML;
+      target.innerHTML = `
 				<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
 					<path d="M20 6L9 17l-5-5"/>
 				</svg>
 			`;
-			target.classList.add('copied');
-			setTimeout(() => {
-				target.innerHTML = originalHtml;
-				target.classList.remove('copied');
-			}, 2000);
-		} catch (error) {
-			console.error('Failed to copy math:', error);
-		}
-	}
-
-	function setupCopyButtons() {
-		if (!containerRef || !browser) return;
-
-		const codeButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
-		for (const button of codeButtons) {
-			if (button.dataset.listenerBound !== 'true') {
-				button.dataset.listenerBound = 'true';
-				button.addEventListener('click', handleCopyClick);
-			}
-		}
-
-		const mathButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-math-btn');
-		for (const button of mathButtons) {
-			if (button.dataset.listenerBound !== 'true') {
-				button.dataset.listenerBound = 'true';
-				button.addEventListener('click', handleMathCopyClick);
-			}
-		}
-	}
-
-	$effect(() => {
-		if (content) {
-			processedHtml = processMarkdown(content);
-		} else {
-			processedHtml = '';
-		}
-	});
-
-	$effect(() => {
-		if (containerRef && processedHtml) {
-			setupCopyButtons();
-		}
-	});
+      target.classList.add("copied");
+      setTimeout(() => {
+        target.innerHTML = originalHtml;
+        target.classList.remove("copied");
+      }, 2000);
+    } catch (error) {
+      console.error("Failed to copy math:", error);
+    }
+  }
+
+  function setupCopyButtons() {
+    if (!containerRef || !browser) return;
+
+    const codeButtons =
+      containerRef.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
+    for (const button of codeButtons) {
+      if (button.dataset.listenerBound !== "true") {
+        button.dataset.listenerBound = "true";
+        button.addEventListener("click", handleCopyClick);
+      }
+    }
+
+    const mathButtons =
+      containerRef.querySelectorAll<HTMLButtonElement>(".copy-math-btn");
+    for (const button of mathButtons) {
+      if (button.dataset.listenerBound !== "true") {
+        button.dataset.listenerBound = "true";
+        button.addEventListener("click", handleMathCopyClick);
+      }
+    }
+  }
+
+  $effect(() => {
+    if (content) {
+      processedHtml = processMarkdown(content);
+    } else {
+      processedHtml = "";
+    }
+  });
+
+  $effect(() => {
+    if (containerRef && processedHtml) {
+      setupCopyButtons();
+    }
+  });
 </script>
 
 <div bind:this={containerRef} class="markdown-content {className}">
-	{@html processedHtml}
+  {@html processedHtml}
 </div>
 
 <style>
-	.markdown-content {
-		line-height: 1.6;
-	}
-
-	/* Paragraphs */
-	.markdown-content :global(p) {
-		margin-bottom: 1rem;
-	}
-
-	.markdown-content :global(p:last-child) {
-		margin-bottom: 0;
-	}
-
-	/* Headers */
-	.markdown-content :global(h1) {
-		font-size: 1.5rem;
-		font-weight: 700;
-		margin: 1.5rem 0 0.75rem 0;
-		color: var(--exo-yellow, #ffd700);
-	}
-
-	.markdown-content :global(h2) {
-		font-size: 1.25rem;
-		font-weight: 600;
-		margin: 1.25rem 0 0.5rem 0;
-		color: var(--exo-yellow, #ffd700);
-	}
-
-	.markdown-content :global(h3) {
-		font-size: 1.125rem;
-		font-weight: 600;
-		margin: 1rem 0 0.5rem 0;
-	}
-
-	.markdown-content :global(h4),
-	.markdown-content :global(h5),
-	.markdown-content :global(h6) {
-		font-size: 1rem;
-		font-weight: 600;
-		margin: 0.75rem 0 0.25rem 0;
-	}
-
-	/* Bold and italic */
-	.markdown-content :global(strong) {
-		font-weight: 600;
-	}
-
-	.markdown-content :global(em) {
-		font-style: italic;
-	}
-
-	/* Inline code */
-	.markdown-content :global(.inline-code) {
-		background: rgba(255, 215, 0, 0.1);
-		color: var(--exo-yellow, #ffd700);
-		padding: 0.125rem 0.375rem;
-		border-radius: 0.25rem;
-		font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
-		font-size: 0.875em;
-	}
-
-	/* Links */
-	.markdown-content :global(a) {
-		color: var(--exo-yellow, #ffd700);
-		text-decoration: underline;
-		text-underline-offset: 2px;
-	}
-
-	.markdown-content :global(a:hover) {
-		opacity: 0.8;
-	}
-
-	/* Lists */
-	.markdown-content :global(ul) {
-		list-style-type: disc;
-		margin-left: 1.5rem;
-		margin-bottom: 1rem;
-	}
-
-	.markdown-content :global(ol) {
-		list-style-type: decimal;
-		margin-left: 1.5rem;
-		margin-bottom: 1rem;
-	}
-
-	.markdown-content :global(li) {
-		margin-bottom: 0.25rem;
-	}
-
-	.markdown-content :global(li::marker) {
-		color: var(--exo-light-gray, #9ca3af);
-	}
-
-	/* Blockquotes */
-	.markdown-content :global(blockquote) {
-		border-left: 3px solid var(--exo-yellow, #ffd700);
-		padding: 0.5rem 1rem;
-		margin: 1rem 0;
-		background: rgba(255, 215, 0, 0.05);
-		border-radius: 0 0.25rem 0.25rem 0;
-	}
-
-	/* Tables */
-	.markdown-content :global(table) {
-		width: 100%;
-		margin: 1rem 0;
-		border-collapse: collapse;
-		font-size: 0.875rem;
-	}
-
-	.markdown-content :global(th) {
-		background: rgba(255, 215, 0, 0.1);
-		border: 1px solid rgba(255, 215, 0, 0.2);
-		padding: 0.5rem;
-		text-align: left;
-		font-weight: 600;
-	}
-
-	.markdown-content :global(td) {
-		border: 1px solid rgba(255, 255, 255, 0.1);
-		padding: 0.5rem;
-	}
-
-	/* Horizontal rule */
-	.markdown-content :global(hr) {
-		border: none;
-		border-top: 1px solid rgba(255, 255, 255, 0.1);
-		margin: 1.5rem 0;
-	}
-
-	/* Code block wrapper */
-	.markdown-content :global(.code-block-wrapper) {
-		margin: 1rem 0;
-		border-radius: 0.5rem;
-		overflow: hidden;
-		border: 1px solid rgba(255, 215, 0, 0.2);
-		background: rgba(0, 0, 0, 0.4);
-	}
-
-	.markdown-content :global(.code-block-header) {
-		display: flex;
-		justify-content: space-between;
-		align-items: center;
-		padding: 0.5rem 0.75rem;
-		background: rgba(255, 215, 0, 0.05);
-		border-bottom: 1px solid rgba(255, 215, 0, 0.1);
-	}
-
-	.markdown-content :global(.code-language) {
-		color: var(--exo-yellow, #ffd700);
-		font-size: 0.7rem;
-		font-weight: 500;
-		text-transform: uppercase;
-		letter-spacing: 0.1em;
-		font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
-	}
-
-	.markdown-content :global(.copy-code-btn) {
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		padding: 0.25rem;
-		background: transparent;
-		border: none;
-		color: var(--exo-light-gray, #9ca3af);
-		cursor: pointer;
-		transition: color 0.2s;
-		border-radius: 0.25rem;
-	}
-
-	.markdown-content :global(.copy-code-btn:hover) {
-		color: var(--exo-yellow, #ffd700);
-	}
-
-	.markdown-content :global(.copy-code-btn.copied) {
-		color: #22c55e;
-	}
-
-	.markdown-content :global(.code-block-wrapper pre) {
-		margin: 0;
-		padding: 1rem;
-		overflow-x: auto;
-		background: transparent;
-	}
-
-	.markdown-content :global(.code-block-wrapper code) {
-		font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
-		font-size: 0.8125rem;
-		line-height: 1.5;
-		background: transparent;
-	}
-
-	/* Syntax highlighting - dark theme matching EXO style */
-	.markdown-content :global(.hljs) {
-		color: #e5e7eb;
-	}
-
-	.markdown-content :global(.hljs-keyword),
-	.markdown-content :global(.hljs-selector-tag),
-	.markdown-content :global(.hljs-literal),
-	.markdown-content :global(.hljs-section),
-	.markdown-content :global(.hljs-link) {
-		color: #c084fc;
-	}
-
-	.markdown-content :global(.hljs-string),
-	.markdown-content :global(.hljs-title),
-	.markdown-content :global(.hljs-name),
-	.markdown-content :global(.hljs-type),
-	.markdown-content :global(.hljs-attribute),
-	.markdown-content :global(.hljs-symbol),
-	.markdown-content :global(.hljs-bullet),
-	.markdown-content :global(.hljs-addition),
-	.markdown-content :global(.hljs-variable),
-	.markdown-content :global(.hljs-template-tag),
-	.markdown-content :global(.hljs-template-variable) {
-		color: #fbbf24;
-	}
-
-	.markdown-content :global(.hljs-comment),
-	.markdown-content :global(.hljs-quote),
-	.markdown-content :global(.hljs-deletion),
-	.markdown-content :global(.hljs-meta) {
-		color: #6b7280;
-	}
-
-	.markdown-content :global(.hljs-number),
-	.markdown-content :global(.hljs-regexp),
-	.markdown-content :global(.hljs-literal),
-	.markdown-content :global(.hljs-built_in) {
-		color: #34d399;
-	}
-
-	.markdown-content :global(.hljs-function),
-	.markdown-content :global(.hljs-class .hljs-title) {
-		color: #60a5fa;
-	}
-
-	/* KaTeX math styling - Base */
-	.markdown-content :global(.katex) {
-		font-size: 1.1em;
-		color: oklch(0.9 0 0);
-	}
-
-	/* Display math container wrapper */
-	.markdown-content :global(.math-display-wrapper) {
-		margin: 1rem 0;
-		border-radius: 0.5rem;
-		overflow: hidden;
-		border: 1px solid rgba(255, 215, 0, 0.15);
-		background: rgba(0, 0, 0, 0.3);
-		transition: border-color 0.2s ease, box-shadow 0.2s ease;
-	}
-
-	.markdown-content :global(.math-display-wrapper:hover) {
-		border-color: rgba(255, 215, 0, 0.25);
-		box-shadow: 0 0 12px rgba(255, 215, 0, 0.08);
-	}
-
-	/* Display math header - hidden by default, slides in on hover */
-	.markdown-content :global(.math-display-header) {
-		display: flex;
-		justify-content: space-between;
-		align-items: center;
-		padding: 0.375rem 0.75rem;
-		background: rgba(255, 215, 0, 0.03);
-		border-bottom: 1px solid rgba(255, 215, 0, 0.08);
-		opacity: 0;
-		max-height: 0;
-		padding-top: 0;
-		padding-bottom: 0;
-		overflow: hidden;
-		transition:
-			opacity 0.2s ease,
-			max-height 0.2s ease,
-			padding 0.2s ease;
-	}
-
-	.markdown-content :global(.math-display-wrapper:hover .math-display-header) {
-		opacity: 1;
-		max-height: 2.5rem;
-		padding: 0.375rem 0.75rem;
-	}
-
-	.markdown-content :global(.math-label) {
-		color: rgba(255, 215, 0, 0.7);
-		font-size: 0.65rem;
-		font-weight: 500;
-		text-transform: uppercase;
-		letter-spacing: 0.1em;
-		font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
-	}
-
-	.markdown-content :global(.copy-math-btn) {
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		padding: 0.25rem;
-		background: transparent;
-		border: none;
-		color: var(--exo-light-gray, #9ca3af);
-		cursor: pointer;
-		transition: color 0.2s;
-		border-radius: 0.25rem;
-		opacity: 0;
-		transition:
-			color 0.2s,
-			opacity 0.15s ease;
-	}
-
-	.markdown-content :global(.math-display-wrapper:hover .copy-math-btn) {
-		opacity: 1;
-	}
-
-	.markdown-content :global(.copy-math-btn:hover) {
-		color: var(--exo-yellow, #ffd700);
-	}
-
-	.markdown-content :global(.copy-math-btn.copied) {
-		color: #22c55e;
-	}
-
-	/* Display math content area */
-	.markdown-content :global(.math-display-content) {
-		padding: 1rem 1.25rem;
-		overflow-x: auto;
-		overflow-y: hidden;
-	}
-
-	/* Custom scrollbar for math overflow */
-	.markdown-content :global(.math-display-content::-webkit-scrollbar) {
-		height: 6px;
-	}
-
-	.markdown-content :global(.math-display-content::-webkit-scrollbar-track) {
-		background: rgba(255, 255, 255, 0.05);
-		border-radius: 3px;
-	}
-
-	.markdown-content :global(.math-display-content::-webkit-scrollbar-thumb) {
-		background: rgba(255, 215, 0, 0.2);
-		border-radius: 3px;
-	}
-
-	.markdown-content :global(.math-display-content::-webkit-scrollbar-thumb:hover) {
-		background: rgba(255, 215, 0, 0.35);
-	}
-
-	.markdown-content :global(.math-display-content .katex-display) {
-		margin: 0;
-		padding: 0;
-	}
-
-	.markdown-content :global(.math-display-content .katex-display > .katex) {
-		text-align: center;
-	}
-
-	/* Inline math wrapper */
-	.markdown-content :global(.math-inline) {
-		display: inline;
-		padding: 0 0.125rem;
-		border-radius: 0.25rem;
-		transition: background-color 0.15s ease;
-	}
-
-	.markdown-content :global(.math-inline:hover) {
-		background: rgba(255, 215, 0, 0.05);
-	}
-
-	/* Dark theme KaTeX overrides */
-	.markdown-content :global(.katex .mord),
-	.markdown-content :global(.katex .minner),
-	.markdown-content :global(.katex .mop),
-	.markdown-content :global(.katex .mbin),
-	.markdown-content :global(.katex .mrel),
-	.markdown-content :global(.katex .mpunct) {
-		color: oklch(0.9 0 0);
-	}
-
-	/* Fraction lines and rules */
-	.markdown-content :global(.katex .frac-line),
-	.markdown-content :global(.katex .overline-line),
-	.markdown-content :global(.katex .underline-line),
-	.markdown-content :global(.katex .hline),
-	.markdown-content :global(.katex .rule) {
-		border-color: oklch(0.85 0 0) !important;
-		background: oklch(0.85 0 0);
-	}
-
-	/* Square roots and SVG elements */
-	.markdown-content :global(.katex .sqrt-line) {
-		border-color: oklch(0.85 0 0) !important;
-	}
-
-	.markdown-content :global(.katex svg) {
-		fill: oklch(0.85 0 0);
-		stroke: oklch(0.85 0 0);
-	}
-
-	.markdown-content :global(.katex svg path) {
-		stroke: oklch(0.85 0 0);
-	}
-
-	/* Delimiters (parentheses, brackets, braces) */
-	.markdown-content :global(.katex .delimsizing),
-	.markdown-content :global(.katex .delim-size1),
-	.markdown-content :global(.katex .delim-size2),
-	.markdown-content :global(.katex .delim-size3),
-	.markdown-content :global(.katex .delim-size4),
-	.markdown-content :global(.katex .mopen),
-	.markdown-content :global(.katex .mclose) {
-		color: oklch(0.75 0 0);
-	}
-
-	/* Math error styling */
-	.markdown-content :global(.math-error) {
-		display: inline-flex;
-		align-items: center;
-		gap: 0.375rem;
-		color: #f87171;
-		font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
-		font-size: 0.875em;
-		background: rgba(248, 113, 113, 0.1);
-		padding: 0.25rem 0.5rem;
-		border-radius: 0.25rem;
-		border: 1px solid rgba(248, 113, 113, 0.2);
-	}
-
-	.markdown-content :global(.math-error-icon) {
-		font-size: 0.875em;
-		opacity: 0.9;
-	}
-
-	/* LaTeX proof environment */
-	.markdown-content :global(.latex-proof) {
-		margin: 1rem 0;
-		padding: 1rem 1.25rem;
-		background: rgba(255, 255, 255, 0.02);
-		border-left: 3px solid rgba(255, 215, 0, 0.4);
-		border-radius: 0 0.375rem 0.375rem 0;
-	}
-
-	.markdown-content :global(.latex-proof-header) {
-		font-weight: 600;
-		font-style: italic;
-		color: oklch(0.85 0 0);
-		margin-bottom: 0.5rem;
-	}
-
-	.markdown-content :global(.latex-proof-header::after) {
-		content: '.';
-	}
-
-	.markdown-content :global(.latex-proof-content) {
-		color: oklch(0.9 0 0);
-	}
-
-	.markdown-content :global(.latex-proof-content p:last-child) {
-		margin-bottom: 0;
-	}
-
-	/* QED symbol at end of proof */
-	.markdown-content :global(.latex-proof-content::after) {
-		content: '∎';
-		display: block;
-		text-align: right;
-		color: oklch(0.7 0 0);
-		margin-top: 0.5rem;
-	}
-
-	/* LaTeX theorem-like environments */
-	.markdown-content :global(.latex-theorem) {
-		margin: 1rem 0;
-		padding: 1rem 1.25rem;
-		background: rgba(255, 215, 0, 0.03);
-		border: 1px solid rgba(255, 215, 0, 0.15);
-		border-radius: 0.375rem;
-	}
-
-	.markdown-content :global(.latex-theorem-header) {
-		font-weight: 700;
-		color: var(--exo-yellow, #ffd700);
-		margin-bottom: 0.5rem;
-	}
-
-	.markdown-content :global(.latex-theorem-header::after) {
-		content: '.';
-	}
-
-	.markdown-content :global(.latex-theorem-content) {
-		color: oklch(0.9 0 0);
-		font-style: italic;
-	}
-
-	.markdown-content :global(.latex-theorem-content p:last-child) {
-		margin-bottom: 0;
-	}
-
-	/* LaTeX diagram/figure placeholder */
-	.markdown-content :global(.latex-diagram-placeholder) {
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		gap: 0.5rem;
-		margin: 1rem 0;
-		padding: 1.5rem 2rem;
-		background: rgba(255, 255, 255, 0.02);
-		border: 1px dashed rgba(255, 215, 0, 0.25);
-		border-radius: 0.5rem;
-		color: rgba(255, 215, 0, 0.6);
-		font-size: 0.875rem;
-	}
-
-	.markdown-content :global(.latex-diagram-icon) {
-		font-size: 1.25rem;
-		opacity: 0.8;
-	}
-
-	.markdown-content :global(.latex-diagram-text) {
-		font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
-		font-size: 0.75rem;
-		text-transform: uppercase;
-		letter-spacing: 0.05em;
-	}
+  .markdown-content {
+    line-height: 1.6;
+  }
+
+  /* Paragraphs */
+  .markdown-content :global(p) {
+    margin-bottom: 1rem;
+  }
+
+  .markdown-content :global(p:last-child) {
+    margin-bottom: 0;
+  }
+
+  /* Headers */
+  .markdown-content :global(h1) {
+    font-size: 1.5rem;
+    font-weight: 700;
+    margin: 1.5rem 0 0.75rem 0;
+    color: var(--exo-yellow, #ffd700);
+  }
+
+  .markdown-content :global(h2) {
+    font-size: 1.25rem;
+    font-weight: 600;
+    margin: 1.25rem 0 0.5rem 0;
+    color: var(--exo-yellow, #ffd700);
+  }
+
+  .markdown-content :global(h3) {
+    font-size: 1.125rem;
+    font-weight: 600;
+    margin: 1rem 0 0.5rem 0;
+  }
+
+  .markdown-content :global(h4),
+  .markdown-content :global(h5),
+  .markdown-content :global(h6) {
+    font-size: 1rem;
+    font-weight: 600;
+    margin: 0.75rem 0 0.25rem 0;
+  }
+
+  /* Bold and italic */
+  .markdown-content :global(strong) {
+    font-weight: 600;
+  }
+
+  .markdown-content :global(em) {
+    font-style: italic;
+  }
+
+  /* Inline code */
+  .markdown-content :global(.inline-code) {
+    background: rgba(255, 215, 0, 0.1);
+    color: var(--exo-yellow, #ffd700);
+    padding: 0.125rem 0.375rem;
+    border-radius: 0.25rem;
+    font-family:
+      ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+    font-size: 0.875em;
+  }
+
+  /* Links */
+  .markdown-content :global(a) {
+    color: var(--exo-yellow, #ffd700);
+    text-decoration: underline;
+    text-underline-offset: 2px;
+  }
+
+  .markdown-content :global(a:hover) {
+    opacity: 0.8;
+  }
+
+  /* Lists */
+  .markdown-content :global(ul) {
+    list-style-type: disc;
+    margin-left: 1.5rem;
+    margin-bottom: 1rem;
+  }
+
+  .markdown-content :global(ol) {
+    list-style-type: decimal;
+    margin-left: 1.5rem;
+    margin-bottom: 1rem;
+  }
+
+  .markdown-content :global(li) {
+    margin-bottom: 0.25rem;
+  }
+
+  .markdown-content :global(li::marker) {
+    color: var(--exo-light-gray, #9ca3af);
+  }
+
+  /* Blockquotes */
+  .markdown-content :global(blockquote) {
+    border-left: 3px solid var(--exo-yellow, #ffd700);
+    padding: 0.5rem 1rem;
+    margin: 1rem 0;
+    background: rgba(255, 215, 0, 0.05);
+    border-radius: 0 0.25rem 0.25rem 0;
+  }
+
+  /* Tables */
+  .markdown-content :global(table) {
+    width: 100%;
+    margin: 1rem 0;
+    border-collapse: collapse;
+    font-size: 0.875rem;
+  }
+
+  .markdown-content :global(th) {
+    background: rgba(255, 215, 0, 0.1);
+    border: 1px solid rgba(255, 215, 0, 0.2);
+    padding: 0.5rem;
+    text-align: left;
+    font-weight: 600;
+  }
+
+  .markdown-content :global(td) {
+    border: 1px solid rgba(255, 255, 255, 0.1);
+    padding: 0.5rem;
+  }
+
+  /* Horizontal rule */
+  .markdown-content :global(hr) {
+    border: none;
+    border-top: 1px solid rgba(255, 255, 255, 0.1);
+    margin: 1.5rem 0;
+  }
+
+  /* Code block wrapper */
+  .markdown-content :global(.code-block-wrapper) {
+    margin: 1rem 0;
+    border-radius: 0.5rem;
+    overflow: hidden;
+    border: 1px solid rgba(255, 215, 0, 0.2);
+    background: rgba(0, 0, 0, 0.4);
+  }
+
+  .markdown-content :global(.code-block-header) {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0.5rem 0.75rem;
+    background: rgba(255, 215, 0, 0.05);
+    border-bottom: 1px solid rgba(255, 215, 0, 0.1);
+  }
+
+  .markdown-content :global(.code-language) {
+    color: var(--exo-yellow, #ffd700);
+    font-size: 0.7rem;
+    font-weight: 500;
+    text-transform: uppercase;
+    letter-spacing: 0.1em;
+    font-family:
+      ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+  }
+
+  .markdown-content :global(.copy-code-btn) {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 0.25rem;
+    background: transparent;
+    border: none;
+    color: var(--exo-light-gray, #9ca3af);
+    cursor: pointer;
+    transition: color 0.2s;
+    border-radius: 0.25rem;
+  }
+
+  .markdown-content :global(.copy-code-btn:hover) {
+    color: var(--exo-yellow, #ffd700);
+  }
+
+  .markdown-content :global(.copy-code-btn.copied) {
+    color: #22c55e;
+  }
+
+  .markdown-content :global(.code-block-wrapper pre) {
+    margin: 0;
+    padding: 1rem;
+    overflow-x: auto;
+    background: transparent;
+  }
+
+  .markdown-content :global(.code-block-wrapper code) {
+    font-family:
+      ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+    font-size: 0.8125rem;
+    line-height: 1.5;
+    background: transparent;
+  }
+
+  /* Syntax highlighting - dark theme matching EXO style */
+  .markdown-content :global(.hljs) {
+    color: #e5e7eb;
+  }
+
+  .markdown-content :global(.hljs-keyword),
+  .markdown-content :global(.hljs-selector-tag),
+  .markdown-content :global(.hljs-literal),
+  .markdown-content :global(.hljs-section),
+  .markdown-content :global(.hljs-link) {
+    color: #c084fc;
+  }
+
+  .markdown-content :global(.hljs-string),
+  .markdown-content :global(.hljs-title),
+  .markdown-content :global(.hljs-name),
+  .markdown-content :global(.hljs-type),
+  .markdown-content :global(.hljs-attribute),
+  .markdown-content :global(.hljs-symbol),
+  .markdown-content :global(.hljs-bullet),
+  .markdown-content :global(.hljs-addition),
+  .markdown-content :global(.hljs-variable),
+  .markdown-content :global(.hljs-template-tag),
+  .markdown-content :global(.hljs-template-variable) {
+    color: #fbbf24;
+  }
+
+  .markdown-content :global(.hljs-comment),
+  .markdown-content :global(.hljs-quote),
+  .markdown-content :global(.hljs-deletion),
+  .markdown-content :global(.hljs-meta) {
+    color: #6b7280;
+  }
+
+  .markdown-content :global(.hljs-number),
+  .markdown-content :global(.hljs-regexp),
+  .markdown-content :global(.hljs-literal),
+  .markdown-content :global(.hljs-built_in) {
+    color: #34d399;
+  }
+
+  .markdown-content :global(.hljs-function),
+  .markdown-content :global(.hljs-class .hljs-title) {
+    color: #60a5fa;
+  }
+
+  /* KaTeX math styling - Base */
+  .markdown-content :global(.katex) {
+    font-size: 1.1em;
+    color: oklch(0.9 0 0);
+  }
+
+  /* Display math container wrapper */
+  .markdown-content :global(.math-display-wrapper) {
+    margin: 1rem 0;
+    border-radius: 0.5rem;
+    overflow: hidden;
+    border: 1px solid rgba(255, 215, 0, 0.15);
+    background: rgba(0, 0, 0, 0.3);
+    transition:
+      border-color 0.2s ease,
+      box-shadow 0.2s ease;
+  }
+
+  .markdown-content :global(.math-display-wrapper:hover) {
+    border-color: rgba(255, 215, 0, 0.25);
+    box-shadow: 0 0 12px rgba(255, 215, 0, 0.08);
+  }
+
+  /* Display math header - hidden by default, slides in on hover */
+  .markdown-content :global(.math-display-header) {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0.375rem 0.75rem;
+    background: rgba(255, 215, 0, 0.03);
+    border-bottom: 1px solid rgba(255, 215, 0, 0.08);
+    opacity: 0;
+    max-height: 0;
+    padding-top: 0;
+    padding-bottom: 0;
+    overflow: hidden;
+    transition:
+      opacity 0.2s ease,
+      max-height 0.2s ease,
+      padding 0.2s ease;
+  }
+
+  .markdown-content :global(.math-display-wrapper:hover .math-display-header) {
+    opacity: 1;
+    max-height: 2.5rem;
+    padding: 0.375rem 0.75rem;
+  }
+
+  .markdown-content :global(.math-label) {
+    color: rgba(255, 215, 0, 0.7);
+    font-size: 0.65rem;
+    font-weight: 500;
+    text-transform: uppercase;
+    letter-spacing: 0.1em;
+    font-family:
+      ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+  }
+
+  .markdown-content :global(.copy-math-btn) {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 0.25rem;
+    background: transparent;
+    border: none;
+    color: var(--exo-light-gray, #9ca3af);
+    cursor: pointer;
+    transition: color 0.2s;
+    border-radius: 0.25rem;
+    opacity: 0;
+    transition:
+      color 0.2s,
+      opacity 0.15s ease;
+  }
+
+  .markdown-content :global(.math-display-wrapper:hover .copy-math-btn) {
+    opacity: 1;
+  }
+
+  .markdown-content :global(.copy-math-btn:hover) {
+    color: var(--exo-yellow, #ffd700);
+  }
+
+  .markdown-content :global(.copy-math-btn.copied) {
+    color: #22c55e;
+  }
+
+  /* Display math content area */
+  .markdown-content :global(.math-display-content) {
+    padding: 1rem 1.25rem;
+    overflow-x: auto;
+    overflow-y: hidden;
+  }
+
+  /* Custom scrollbar for math overflow */
+  .markdown-content :global(.math-display-content::-webkit-scrollbar) {
+    height: 6px;
+  }
+
+  .markdown-content :global(.math-display-content::-webkit-scrollbar-track) {
+    background: rgba(255, 255, 255, 0.05);
+    border-radius: 3px;
+  }
+
+  .markdown-content :global(.math-display-content::-webkit-scrollbar-thumb) {
+    background: rgba(255, 215, 0, 0.2);
+    border-radius: 3px;
+  }
+
+  .markdown-content
+    :global(.math-display-content::-webkit-scrollbar-thumb:hover) {
+    background: rgba(255, 215, 0, 0.35);
+  }
+
+  .markdown-content :global(.math-display-content .katex-display) {
+    margin: 0;
+    padding: 0;
+  }
+
+  .markdown-content :global(.math-display-content .katex-display > .katex) {
+    text-align: center;
+  }
+
+  /* Inline math wrapper */
+  .markdown-content :global(.math-inline) {
+    display: inline;
+    padding: 0 0.125rem;
+    border-radius: 0.25rem;
+    transition: background-color 0.15s ease;
+  }
+
+  .markdown-content :global(.math-inline:hover) {
+    background: rgba(255, 215, 0, 0.05);
+  }
+
+  /* Dark theme KaTeX overrides */
+  .markdown-content :global(.katex .mord),
+  .markdown-content :global(.katex .minner),
+  .markdown-content :global(.katex .mop),
+  .markdown-content :global(.katex .mbin),
+  .markdown-content :global(.katex .mrel),
+  .markdown-content :global(.katex .mpunct) {
+    color: oklch(0.9 0 0);
+  }
+
+  /* Fraction lines and rules */
+  .markdown-content :global(.katex .frac-line),
+  .markdown-content :global(.katex .overline-line),
+  .markdown-content :global(.katex .underline-line),
+  .markdown-content :global(.katex .hline),
+  .markdown-content :global(.katex .rule) {
+    border-color: oklch(0.85 0 0) !important;
+    background: oklch(0.85 0 0);
+  }
+
+  /* Square roots and SVG elements */
+  .markdown-content :global(.katex .sqrt-line) {
+    border-color: oklch(0.85 0 0) !important;
+  }
+
+  .markdown-content :global(.katex svg) {
+    fill: oklch(0.85 0 0);
+    stroke: oklch(0.85 0 0);
+  }
+
+  .markdown-content :global(.katex svg path) {
+    stroke: oklch(0.85 0 0);
+  }
+
+  /* Delimiters (parentheses, brackets, braces) */
+  .markdown-content :global(.katex .delimsizing),
+  .markdown-content :global(.katex .delim-size1),
+  .markdown-content :global(.katex .delim-size2),
+  .markdown-content :global(.katex .delim-size3),
+  .markdown-content :global(.katex .delim-size4),
+  .markdown-content :global(.katex .mopen),
+  .markdown-content :global(.katex .mclose) {
+    color: oklch(0.75 0 0);
+  }
+
+  /* Math error styling */
+  .markdown-content :global(.math-error) {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.375rem;
+    color: #f87171;
+    font-family:
+      ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+    font-size: 0.875em;
+    background: rgba(248, 113, 113, 0.1);
+    padding: 0.25rem 0.5rem;
+    border-radius: 0.25rem;
+    border: 1px solid rgba(248, 113, 113, 0.2);
+  }
+
+  .markdown-content :global(.math-error-icon) {
+    font-size: 0.875em;
+    opacity: 0.9;
+  }
+
+  /* LaTeX proof environment */
+  .markdown-content :global(.latex-proof) {
+    margin: 1rem 0;
+    padding: 1rem 1.25rem;
+    background: rgba(255, 255, 255, 0.02);
+    border-left: 3px solid rgba(255, 215, 0, 0.4);
+    border-radius: 0 0.375rem 0.375rem 0;
+  }
+
+  .markdown-content :global(.latex-proof-header) {
+    font-weight: 600;
+    font-style: italic;
+    color: oklch(0.85 0 0);
+    margin-bottom: 0.5rem;
+  }
+
+  .markdown-content :global(.latex-proof-header::after) {
+    content: ".";
+  }
+
+  .markdown-content :global(.latex-proof-content) {
+    color: oklch(0.9 0 0);
+  }
+
+  .markdown-content :global(.latex-proof-content p:last-child) {
+    margin-bottom: 0;
+  }
+
+  /* QED symbol at end of proof */
+  .markdown-content :global(.latex-proof-content::after) {
+    content: "∎";
+    display: block;
+    text-align: right;
+    color: oklch(0.7 0 0);
+    margin-top: 0.5rem;
+  }
+
+  /* LaTeX theorem-like environments */
+  .markdown-content :global(.latex-theorem) {
+    margin: 1rem 0;
+    padding: 1rem 1.25rem;
+    background: rgba(255, 215, 0, 0.03);
+    border: 1px solid rgba(255, 215, 0, 0.15);
+    border-radius: 0.375rem;
+  }
+
+  .markdown-content :global(.latex-theorem-header) {
+    font-weight: 700;
+    color: var(--exo-yellow, #ffd700);
+    margin-bottom: 0.5rem;
+  }
+
+  .markdown-content :global(.latex-theorem-header::after) {
+    content: ".";
+  }
+
+  .markdown-content :global(.latex-theorem-content) {
+    color: oklch(0.9 0 0);
+    font-style: italic;
+  }
+
+  .markdown-content :global(.latex-theorem-content p:last-child) {
+    margin-bottom: 0;
+  }
+
+  /* LaTeX diagram/figure placeholder */
+  .markdown-content :global(.latex-diagram-placeholder) {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 0.5rem;
+    margin: 1rem 0;
+    padding: 1.5rem 2rem;
+    background: rgba(255, 255, 255, 0.02);
+    border: 1px dashed rgba(255, 215, 0, 0.25);
+    border-radius: 0.5rem;
+    color: rgba(255, 215, 0, 0.6);
+    font-size: 0.875rem;
+  }
+
+  .markdown-content :global(.latex-diagram-icon) {
+    font-size: 1.25rem;
+    opacity: 0.8;
+  }
+
+  .markdown-content :global(.latex-diagram-text) {
+    font-family:
+      ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+    font-size: 0.75rem;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+  }
 </style>
diff --git a/dashboard/src/lib/components/ModelCard.svelte b/dashboard/src/lib/components/ModelCard.svelte
index dca89819..6fef645f 100644
--- a/dashboard/src/lib/components/ModelCard.svelte
+++ b/dashboard/src/lib/components/ModelCard.svelte
@@ -1,719 +1,1018 @@
 <script lang="ts">
-	import type { DownloadProgress, NodeInfo, PlacementPreview, TopologyEdge } from '$lib/stores/app.svelte';
-	import { debugMode, topologyData } from '$lib/stores/app.svelte';
-
-interface Props {
-		model: { id: string; name?: string; storage_size_megabytes?: number };
-		isLaunching?: boolean;
-		downloadStatus?: {
-			isDownloading: boolean;
-			progress: DownloadProgress | null;
-		perNode?: Array<{
-			nodeId: string;
-			nodeName: string;
-			progress: DownloadProgress;
-		}>;
-		} | null;
-		nodes?: Record<string, NodeInfo>;
-		sharding?: 'Pipeline' | 'Tensor';
-		runtime?: 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
-		onLaunch?: () => void;
-		tags?: string[];
-		apiPreview?: PlacementPreview | null;
-		modelIdOverride?: string | null;
-	}
-
-	let { 
-		model, 
-		isLaunching = false, 
-		downloadStatus = null, 
-		nodes = {},
-		sharding = 'Pipeline',
-		runtime = 'MlxRing',
-		onLaunch,
-		tags = [],
-		apiPreview = null,
-		modelIdOverride = null
-	}: Props = $props();
-
-	// Estimate memory requirements from model name
-	// Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
-	function estimateMemoryGB(modelId: string, modelName?: string): number {
-		// Check both ID and name for quantization info
-		const combined = `${modelId} ${modelName || ''}`.toLowerCase();
-		
-		// Detect quantization level - affects memory by roughly 2x between levels
-		const is4bit = combined.includes('4bit') || combined.includes('4-bit') || combined.includes(':4bit');
-		const is8bit = combined.includes('8bit') || combined.includes('8-bit') || combined.includes(':8bit');
-		// 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
-		const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
-		const id = modelId.toLowerCase();
-		
-		// Known large models that don't follow the standard naming pattern
-		// DeepSeek V3 has 685B parameters
-		if (id.includes('deepseek-v3')) {
-			return Math.round(685 * quantMultiplier);
-		}
-		// DeepSeek V2 has 236B parameters  
-		if (id.includes('deepseek-v2')) {
-			return Math.round(236 * quantMultiplier);
-		}
-		// Llama 4 Scout/Maverick are large models
-		if (id.includes('llama-4')) {
-			return Math.round(400 * quantMultiplier);
-		}
-		
-		// Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
-		const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
-		if (paramMatch) {
-			const params = parseFloat(paramMatch[1]);
-			return Math.max(4, Math.round(params * quantMultiplier));
-		}
-		
-		// Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
-		if (id.includes('405b') || id.includes('400b')) return Math.round(405 * quantMultiplier);
-		if (id.includes('180b')) return Math.round(180 * quantMultiplier);
-		if (id.includes('141b') || id.includes('140b')) return Math.round(140 * quantMultiplier);
-		if (id.includes('123b') || id.includes('120b')) return Math.round(123 * quantMultiplier);
-		if (id.includes('72b') || id.includes('70b')) return Math.round(70 * quantMultiplier);
-		if (id.includes('67b') || id.includes('65b')) return Math.round(65 * quantMultiplier);
-		if (id.includes('35b') || id.includes('34b') || id.includes('32b') || id.includes('30b')) return Math.round(32 * quantMultiplier);
-		if (id.includes('27b') || id.includes('26b') || id.includes('22b')) return Math.round(24 * quantMultiplier);
-		if (id.includes('14b') || id.includes('13b') || id.includes('15b')) return Math.round(14 * quantMultiplier);
-		if (id.includes('8b') || id.includes('9b') || id.includes('7b')) return Math.round(8 * quantMultiplier);
-		if (id.includes('3b') || id.includes('3.8b')) return Math.round(4 * quantMultiplier);
-		if (id.includes('2b') || id.includes('1b') || id.includes('1.5b') || id.includes('0.5b')) return Math.round(2 * quantMultiplier);
-		
-		return 16; // Default fallback
-	}
-
-	function formatBytes(bytes: number, decimals = 1): string {
-		if (!bytes || bytes === 0) return '0 B';
-		const k = 1024;
-		const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
-		const i = Math.floor(Math.log(bytes) / Math.log(k));
-		return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
-	}
-
-	function formatSpeed(bps: number): string {
-		if (!bps || bps <= 0) return '0 B/s';
-		return formatBytes(bps) + '/s';
-	}
-
-	function formatEta(ms: number): string {
-		if (!ms || ms <= 0) return '--';
-		const totalSeconds = Math.round(ms / 1000);
-		const s = totalSeconds % 60;
-		const m = Math.floor(totalSeconds / 60) % 60;
-		const h = Math.floor(totalSeconds / 3600);
-		if (h > 0) return `${h}h ${m}m`;
-		if (m > 0) return `${m}m ${s}s`;
-		return `${s}s`;
-	}
-
-	const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
-	const progress = $derived(downloadStatus?.progress);
-	const percentage = $derived(progress?.percentage ?? 0);
-let expandedNodes = $state<Set<string>>(new Set());
-
-function toggleNodeDetails(nodeId: string): void {
-	const next = new Set(expandedNodes);
-	if (next.has(nodeId)) {
-		next.delete(nodeId);
-	} else {
-		next.add(nodeId);
-	}
-	expandedNodes = next;
-}
-	
-	// Use actual storage_size_megabytes from API if available, otherwise fall back to estimate
-	const estimatedMemory = $derived(
-		model.storage_size_megabytes 
-			? Math.round(model.storage_size_megabytes / 1024)
-			: estimateMemoryGB(model.id, model.name)
-	);
-	
-	function getDeviceType(name: string): 'macbook' | 'studio' | 'mini' | 'unknown' {
-		const lower = name.toLowerCase();
-		if (lower.includes('macbook')) return 'macbook';
-		if (lower.includes('studio')) return 'studio';
-		if (lower.includes('mini')) return 'mini';
-		return 'unknown';
-	}
-	
-	const clampPercent = (value: number): number => Math.min(100, Math.max(0, value));
-	const huggingFaceModelId = $derived(modelIdOverride ?? model.id);
-
-	// Get node list in the same order as the topology graph (insertion order of
-	// topology nodes), while still ensuring preview nodes render even if the
-	// topology payload is missing them. Topology order is preserved exactly so
-	// that the mini preview matches the main TopologyGraph layout.
-	const nodeList = $derived(() => {
-		const nodesFromTopology = Object.keys(nodes).map((id) => {
-			const info = nodes[id];
-			const totalBytes = info.macmon_info?.memory?.ram_total ?? info.system_info?.memory ?? 0;
-			const usedBytes = info.macmon_info?.memory?.ram_usage ?? 0;
-			const availableBytes = Math.max(totalBytes - usedBytes, 0);
-			const totalGB = totalBytes / (1024 * 1024 * 1024);
-			const availableGB = availableBytes / (1024 * 1024 * 1024);
-			const usedGB = Math.max(totalGB - availableGB, 0);
-			const deviceName = info.system_info?.model_id ?? 'Unknown';
-			const deviceType = getDeviceType(deviceName);
-
-			return { id, totalGB, availableGB, usedGB, deviceName, deviceType, usedBytes, totalBytes };
-		});
-
-		const previewEntries = apiPreview?.memory_delta_by_node ?? null;
-		const previewIds = previewEntries ? Object.keys(previewEntries) : [];
-
-		if (previewIds.length === 0) return nodesFromTopology;
-
-		// Append any preview-only nodes (not in topology) at the end
-		const topologyIds = new Set(nodesFromTopology.map((n) => n.id));
-		const extraPreviewNodes = previewIds
-			.filter((id) => !topologyIds.has(id))
-			.map((id) => {
-				const deltaBytes = previewEntries?.[id] ?? 0;
-				const deltaGB = deltaBytes / (1024 * 1024 * 1024);
-				const totalGB = Math.max(deltaGB * 1.2, 1);
-				const usedGB = Math.max(totalGB - deltaGB, 0);
-
-				return {
-					id,
-					totalGB,
-					availableGB: Math.max(totalGB - usedGB, 0),
-					usedGB,
-					deviceName: 'Unknown',
-					deviceType: 'unknown' as const,
-					usedBytes: usedGB * 1024 * 1024 * 1024,
-					totalBytes: totalGB * 1024 * 1024 * 1024
-				};
-			});
-
-		return [...nodesFromTopology, ...extraPreviewNodes];
-	});
-	
-	// Calculate placement preview with all SVG metrics pre-computed
-	// Uses API preview data when available, falls back to local estimation
-	const placementPreview = $derived(() => {
-		const nodeArray = nodeList();
-		if (nodeArray.length === 0) return { nodes: [], canFit: false, totalAvailable: 0, topoWidth: 260, topoHeight: 90, error: null };
-		
-		const numNodes = nodeArray.length;
-		const iconSize = numNodes === 1 ? 50 : 36;
-		const topoWidth = 260;
-		const topoHeight = numNodes === 1 ? 90 : numNodes === 2 ? 140 : numNodes * 50 + 20;
-		const centerX = topoWidth / 2;
-		const centerY = topoHeight / 2;
-		const radius = numNodes === 1 ? 0 : numNodes === 2 ? 45 : Math.min(topoWidth, topoHeight) * 0.32;
-		
-		// Only use API preview data - no local estimation
-		const hasApiPreview = apiPreview !== null && apiPreview.error === null && apiPreview.memory_delta_by_node !== null;
-		const error = apiPreview?.error ?? null;
-		
-		let placementNodes: Array<{ 
-			id: string;
-			deviceName: string;
-			deviceType: 'macbook' | 'studio' | 'mini' | 'unknown';
-			totalGB: number; 
-			currentUsedGB: number;
-			modelUsageGB: number;
-			currentPercent: number;
-			newPercent: number;
-			isUsed: boolean;
-			x: number;
-			y: number;
-			iconSize: number;
-			screenHeight: number;
-			currentFillHeight: number;
-			modelFillHeight: number;
-		}> = [];
-		
-		// Use API placement data directly
-		const memoryDelta = apiPreview?.memory_delta_by_node ?? {};
-		placementNodes = nodeArray.map((n, i) => {
-			const deltaBytes = memoryDelta[n.id] ?? 0;
-			const modelUsageGB = deltaBytes / (1024 * 1024 * 1024);
-			const isUsed = deltaBytes > 0;
-			const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
-			const safeTotal = Math.max(n.totalGB, 0.001);
-			const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
-			const newPercent = clampPercent(((n.usedGB + modelUsageGB) / safeTotal) * 100);
-			const screenHeight = iconSize * 0.58;
-			
-			return {
-				id: n.id,
-				deviceName: n.deviceName,
-				deviceType: n.deviceType,
-				totalGB: n.totalGB,
-				currentUsedGB: n.usedGB,
-				modelUsageGB,
-				currentPercent,
-				newPercent,
-				isUsed,
-				x: centerX + Math.cos(angle) * radius,
-				y: centerY + Math.sin(angle) * radius,
-				iconSize,
-				screenHeight,
-				currentFillHeight: screenHeight * (currentPercent / 100),
-				modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100)
-			};
-		});
-		
-		const totalAvailable = nodeArray.reduce((sum, n) => sum + n.availableGB, 0);
-		return { nodes: placementNodes, canFit: hasApiPreview, totalAvailable, topoWidth, topoHeight, error };
-	});
-	
-	const canFit = $derived(apiPreview ? apiPreview.error === null : placementPreview().canFit);
-	const placementError = $derived(apiPreview?.error ?? null);
-	const nodeCount = $derived(nodeList().length);
-	const filterId = $derived(model.id.replace(/[^a-zA-Z0-9]/g, ''));
-	
-	// Debug mode state
-	const isDebugMode = $derived(debugMode());
-	const topology = $derived(topologyData());
-	const isRdma = $derived(runtime === 'MlxIbv' || runtime === 'MlxJaccl');
-	
-	// Get interface name for an IP from node data
-	function getInterfaceForIp(nodeId: string, ip?: string): string | null {
-		if (!ip || !topology?.nodes) return null;
-		
-		// Strip port if present
-		const cleanIp = ip.includes(':') && !ip.includes('[') ? ip.split(':')[0] : ip;
-		
-		// Check specified node first
-		const node = topology.nodes[nodeId];
-		if (node) {
-			const match = node.network_interfaces?.find((iface) =>
-				(iface.addresses || []).some((addr) => addr === cleanIp || addr === ip)
-			);
-			if (match?.name) return match.name;
-			
-			const mapped = node.ip_to_interface?.[cleanIp] || node.ip_to_interface?.[ip];
-			if (mapped) return mapped;
-		}
-		
-		// Fallback: check all nodes
-		for (const [, otherNode] of Object.entries(topology.nodes)) {
-			if (!otherNode) continue;
-			const match = otherNode.network_interfaces?.find((iface) =>
-				(iface.addresses || []).some((addr) => addr === cleanIp || addr === ip)
-			);
-			if (match?.name) return match.name;
-			
-			const mapped = otherNode.ip_to_interface?.[cleanIp] || otherNode.ip_to_interface?.[ip];
-			if (mapped) return mapped;
-		}
-		
-		return null;
-	}
-	
-	// Get directional arrow based on node positions
-	function getArrow(fromNode: { x: number; y: number }, toNode: { x: number; y: number }): string {
-		const dx = toNode.x - fromNode.x;
-		const dy = toNode.y - fromNode.y;
-		const absX = Math.abs(dx);
-		const absY = Math.abs(dy);
-		
-		if (absX > absY * 2) {
-			return dx > 0 ? '→' : '←';
-		} else if (absY > absX * 2) {
-			return dy > 0 ? '↓' : '↑';
-		} else {
-			if (dx > 0 && dy > 0) return '↘';
-			if (dx > 0 && dy < 0) return '↗';
-			if (dx < 0 && dy > 0) return '↙';
-			return '↖';
-		}
-	}
-
-	// Get connection info for edges between two nodes
-	// Returns exactly one connection per direction (A→B and B→A), preferring non-loopback
-	function getConnectionInfo(nodeId1: string, nodeId2: string): Array<{ ip: string; iface: string | null; from: string; to: string }> {
-		if (!topology?.edges) return [];
-		
-		// Collect candidates for each direction
-		const aToBCandidates: Array<{ ip: string; iface: string | null }> = [];
-		const bToACandidates: Array<{ ip: string; iface: string | null }> = [];
-		
-		for (const edge of topology.edges) {
-			const ip = edge.sendBackIp || '?';
-			const iface = edge.sendBackInterface || getInterfaceForIp(edge.source, ip);
-			
-			if (edge.source === nodeId1 && edge.target === nodeId2) {
-				aToBCandidates.push({ ip, iface });
-			} else if (edge.source === nodeId2 && edge.target === nodeId1) {
-				bToACandidates.push({ ip, iface });
-			}
-		}
-		
-		// Pick best (prefer non-loopback)
-		const pickBest = (candidates: Array<{ ip: string; iface: string | null }>) => {
-			if (candidates.length === 0) return null;
-			return candidates.find(c => !c.ip.startsWith('127.')) || candidates[0];
-		};
-		
-		const result: Array<{ ip: string; iface: string | null; from: string; to: string }> = [];
-		
-		const bestAtoB = pickBest(aToBCandidates);
-		if (bestAtoB) result.push({ ...bestAtoB, from: nodeId1, to: nodeId2 });
-		
-		const bestBtoA = pickBest(bToACandidates);
-		if (bestBtoA) result.push({ ...bestBtoA, from: nodeId2, to: nodeId1 });
-		
-		return result;
-	}
+  import type {
+    DownloadProgress,
+    NodeInfo,
+    PlacementPreview,
+    TopologyEdge,
+  } from "$lib/stores/app.svelte";
+  import { debugMode, topologyData } from "$lib/stores/app.svelte";
+
+  interface Props {
+    model: { id: string; name?: string; storage_size_megabytes?: number };
+    isLaunching?: boolean;
+    downloadStatus?: {
+      isDownloading: boolean;
+      progress: DownloadProgress | null;
+      perNode?: Array<{
+        nodeId: string;
+        nodeName: string;
+        progress: DownloadProgress;
+      }>;
+    } | null;
+    nodes?: Record<string, NodeInfo>;
+    sharding?: "Pipeline" | "Tensor";
+    runtime?: "MlxRing" | "MlxIbv" | "MlxJaccl";
+    onLaunch?: () => void;
+    tags?: string[];
+    apiPreview?: PlacementPreview | null;
+    modelIdOverride?: string | null;
+  }
+
+  let {
+    model,
+    isLaunching = false,
+    downloadStatus = null,
+    nodes = {},
+    sharding = "Pipeline",
+    runtime = "MlxRing",
+    onLaunch,
+    tags = [],
+    apiPreview = null,
+    modelIdOverride = null,
+  }: Props = $props();
+
+  // Estimate memory requirements from model name
+  // Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
+  function estimateMemoryGB(modelId: string, modelName?: string): number {
+    // Check both ID and name for quantization info
+    const combined = `${modelId} ${modelName || ""}`.toLowerCase();
+
+    // Detect quantization level - affects memory by roughly 2x between levels
+    const is4bit =
+      combined.includes("4bit") ||
+      combined.includes("4-bit") ||
+      combined.includes(":4bit");
+    const is8bit =
+      combined.includes("8bit") ||
+      combined.includes("8-bit") ||
+      combined.includes(":8bit");
+    // 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
+    const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
+    const id = modelId.toLowerCase();
+
+    // Known large models that don't follow the standard naming pattern
+    // DeepSeek V3 has 685B parameters
+    if (id.includes("deepseek-v3")) {
+      return Math.round(685 * quantMultiplier);
+    }
+    // DeepSeek V2 has 236B parameters
+    if (id.includes("deepseek-v2")) {
+      return Math.round(236 * quantMultiplier);
+    }
+    // Llama 4 Scout/Maverick are large models
+    if (id.includes("llama-4")) {
+      return Math.round(400 * quantMultiplier);
+    }
+
+    // Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
+    const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
+    if (paramMatch) {
+      const params = parseFloat(paramMatch[1]);
+      return Math.max(4, Math.round(params * quantMultiplier));
+    }
+
+    // Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
+    if (id.includes("405b") || id.includes("400b"))
+      return Math.round(405 * quantMultiplier);
+    if (id.includes("180b")) return Math.round(180 * quantMultiplier);
+    if (id.includes("141b") || id.includes("140b"))
+      return Math.round(140 * quantMultiplier);
+    if (id.includes("123b") || id.includes("120b"))
+      return Math.round(123 * quantMultiplier);
+    if (id.includes("72b") || id.includes("70b"))
+      return Math.round(70 * quantMultiplier);
+    if (id.includes("67b") || id.includes("65b"))
+      return Math.round(65 * quantMultiplier);
+    if (
+      id.includes("35b") ||
+      id.includes("34b") ||
+      id.includes("32b") ||
+      id.includes("30b")
+    )
+      return Math.round(32 * quantMultiplier);
+    if (id.includes("27b") || id.includes("26b") || id.includes("22b"))
+      return Math.round(24 * quantMultiplier);
+    if (id.includes("14b") || id.includes("13b") || id.includes("15b"))
+      return Math.round(14 * quantMultiplier);
+    if (id.includes("8b") || id.includes("9b") || id.includes("7b"))
+      return Math.round(8 * quantMultiplier);
+    if (id.includes("3b") || id.includes("3.8b"))
+      return Math.round(4 * quantMultiplier);
+    if (
+      id.includes("2b") ||
+      id.includes("1b") ||
+      id.includes("1.5b") ||
+      id.includes("0.5b")
+    )
+      return Math.round(2 * quantMultiplier);
+
+    return 16; // Default fallback
+  }
+
+  function formatBytes(bytes: number, decimals = 1): string {
+    if (!bytes || bytes === 0) return "0 B";
+    const k = 1024;
+    const sizes = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return (
+      parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]
+    );
+  }
+
+  function formatSpeed(bps: number): string {
+    if (!bps || bps <= 0) return "0 B/s";
+    return formatBytes(bps) + "/s";
+  }
+
+  function formatEta(ms: number): string {
+    if (!ms || ms <= 0) return "--";
+    const totalSeconds = Math.round(ms / 1000);
+    const s = totalSeconds % 60;
+    const m = Math.floor(totalSeconds / 60) % 60;
+    const h = Math.floor(totalSeconds / 3600);
+    if (h > 0) return `${h}h ${m}m`;
+    if (m > 0) return `${m}m ${s}s`;
+    return `${s}s`;
+  }
+
+  const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
+  const progress = $derived(downloadStatus?.progress);
+  const percentage = $derived(progress?.percentage ?? 0);
+  let expandedNodes = $state<Set<string>>(new Set());
+
+  function toggleNodeDetails(nodeId: string): void {
+    const next = new Set(expandedNodes);
+    if (next.has(nodeId)) {
+      next.delete(nodeId);
+    } else {
+      next.add(nodeId);
+    }
+    expandedNodes = next;
+  }
+
+  // Use actual storage_size_megabytes from API if available, otherwise fall back to estimate
+  const estimatedMemory = $derived(
+    model.storage_size_megabytes
+      ? Math.round(model.storage_size_megabytes / 1024)
+      : estimateMemoryGB(model.id, model.name),
+  );
+
+  function getDeviceType(
+    name: string,
+  ): "macbook" | "studio" | "mini" | "unknown" {
+    const lower = name.toLowerCase();
+    if (lower.includes("macbook")) return "macbook";
+    if (lower.includes("studio")) return "studio";
+    if (lower.includes("mini")) return "mini";
+    return "unknown";
+  }
+
+  const clampPercent = (value: number): number =>
+    Math.min(100, Math.max(0, value));
+  const huggingFaceModelId = $derived(modelIdOverride ?? model.id);
+
+  // Get node list in the same order as the topology graph (insertion order of
+  // topology nodes), while still ensuring preview nodes render even if the
+  // topology payload is missing them. Topology order is preserved exactly so
+  // that the mini preview matches the main TopologyGraph layout.
+  const nodeList = $derived(() => {
+    const nodesFromTopology = Object.keys(nodes).map((id) => {
+      const info = nodes[id];
+      const totalBytes =
+        info.macmon_info?.memory?.ram_total ?? info.system_info?.memory ?? 0;
+      const usedBytes = info.macmon_info?.memory?.ram_usage ?? 0;
+      const availableBytes = Math.max(totalBytes - usedBytes, 0);
+      const totalGB = totalBytes / (1024 * 1024 * 1024);
+      const availableGB = availableBytes / (1024 * 1024 * 1024);
+      const usedGB = Math.max(totalGB - availableGB, 0);
+      const deviceName = info.system_info?.model_id ?? "Unknown";
+      const deviceType = getDeviceType(deviceName);
+
+      return {
+        id,
+        totalGB,
+        availableGB,
+        usedGB,
+        deviceName,
+        deviceType,
+        usedBytes,
+        totalBytes,
+      };
+    });
+
+    const previewEntries = apiPreview?.memory_delta_by_node ?? null;
+    const previewIds = previewEntries ? Object.keys(previewEntries) : [];
+
+    if (previewIds.length === 0) return nodesFromTopology;
+
+    // Append any preview-only nodes (not in topology) at the end
+    const topologyIds = new Set(nodesFromTopology.map((n) => n.id));
+    const extraPreviewNodes = previewIds
+      .filter((id) => !topologyIds.has(id))
+      .map((id) => {
+        const deltaBytes = previewEntries?.[id] ?? 0;
+        const deltaGB = deltaBytes / (1024 * 1024 * 1024);
+        const totalGB = Math.max(deltaGB * 1.2, 1);
+        const usedGB = Math.max(totalGB - deltaGB, 0);
+
+        return {
+          id,
+          totalGB,
+          availableGB: Math.max(totalGB - usedGB, 0),
+          usedGB,
+          deviceName: "Unknown",
+          deviceType: "unknown" as const,
+          usedBytes: usedGB * 1024 * 1024 * 1024,
+          totalBytes: totalGB * 1024 * 1024 * 1024,
+        };
+      });
+
+    return [...nodesFromTopology, ...extraPreviewNodes];
+  });
+
+  // Calculate placement preview with all SVG metrics pre-computed
+  // Uses API preview data when available, falls back to local estimation
+  const placementPreview = $derived(() => {
+    const nodeArray = nodeList();
+    if (nodeArray.length === 0)
+      return {
+        nodes: [],
+        canFit: false,
+        totalAvailable: 0,
+        topoWidth: 260,
+        topoHeight: 90,
+        error: null,
+      };
+
+    const numNodes = nodeArray.length;
+    const iconSize = numNodes === 1 ? 50 : 36;
+    const topoWidth = 260;
+    const topoHeight =
+      numNodes === 1 ? 90 : numNodes === 2 ? 140 : numNodes * 50 + 20;
+    const centerX = topoWidth / 2;
+    const centerY = topoHeight / 2;
+    const radius =
+      numNodes === 1
+        ? 0
+        : numNodes === 2
+          ? 45
+          : Math.min(topoWidth, topoHeight) * 0.32;
+
+    // Only use API preview data - no local estimation
+    const hasApiPreview =
+      apiPreview !== null &&
+      apiPreview.error === null &&
+      apiPreview.memory_delta_by_node !== null;
+    const error = apiPreview?.error ?? null;
+
+    let placementNodes: Array<{
+      id: string;
+      deviceName: string;
+      deviceType: "macbook" | "studio" | "mini" | "unknown";
+      totalGB: number;
+      currentUsedGB: number;
+      modelUsageGB: number;
+      currentPercent: number;
+      newPercent: number;
+      isUsed: boolean;
+      x: number;
+      y: number;
+      iconSize: number;
+      screenHeight: number;
+      currentFillHeight: number;
+      modelFillHeight: number;
+    }> = [];
+
+    // Use API placement data directly
+    const memoryDelta = apiPreview?.memory_delta_by_node ?? {};
+    placementNodes = nodeArray.map((n, i) => {
+      const deltaBytes = memoryDelta[n.id] ?? 0;
+      const modelUsageGB = deltaBytes / (1024 * 1024 * 1024);
+      const isUsed = deltaBytes > 0;
+      const angle =
+        numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+      const safeTotal = Math.max(n.totalGB, 0.001);
+      const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+      const newPercent = clampPercent(
+        ((n.usedGB + modelUsageGB) / safeTotal) * 100,
+      );
+      const screenHeight = iconSize * 0.58;
+
+      return {
+        id: n.id,
+        deviceName: n.deviceName,
+        deviceType: n.deviceType,
+        totalGB: n.totalGB,
+        currentUsedGB: n.usedGB,
+        modelUsageGB,
+        currentPercent,
+        newPercent,
+        isUsed,
+        x: centerX + Math.cos(angle) * radius,
+        y: centerY + Math.sin(angle) * radius,
+        iconSize,
+        screenHeight,
+        currentFillHeight: screenHeight * (currentPercent / 100),
+        modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100),
+      };
+    });
+
+    const totalAvailable = nodeArray.reduce((sum, n) => sum + n.availableGB, 0);
+    return {
+      nodes: placementNodes,
+      canFit: hasApiPreview,
+      totalAvailable,
+      topoWidth,
+      topoHeight,
+      error,
+    };
+  });
+
+  const canFit = $derived(
+    apiPreview ? apiPreview.error === null : placementPreview().canFit,
+  );
+  const placementError = $derived(apiPreview?.error ?? null);
+  const nodeCount = $derived(nodeList().length);
+  const filterId = $derived(model.id.replace(/[^a-zA-Z0-9]/g, ""));
+
+  // Debug mode state
+  const isDebugMode = $derived(debugMode());
+  const topology = $derived(topologyData());
+  const isRdma = $derived(runtime === "MlxIbv" || runtime === "MlxJaccl");
+
+  // Get interface name for an IP from node data
+  function getInterfaceForIp(nodeId: string, ip?: string): string | null {
+    if (!ip || !topology?.nodes) return null;
+
+    // Strip port if present
+    const cleanIp =
+      ip.includes(":") && !ip.includes("[") ? ip.split(":")[0] : ip;
+
+    // Check specified node first
+    const node = topology.nodes[nodeId];
+    if (node) {
+      const match = node.network_interfaces?.find((iface) =>
+        (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip),
+      );
+      if (match?.name) return match.name;
+
+      const mapped =
+        node.ip_to_interface?.[cleanIp] || node.ip_to_interface?.[ip];
+      if (mapped) return mapped;
+    }
+
+    // Fallback: check all nodes
+    for (const [, otherNode] of Object.entries(topology.nodes)) {
+      if (!otherNode) continue;
+      const match = otherNode.network_interfaces?.find((iface) =>
+        (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip),
+      );
+      if (match?.name) return match.name;
+
+      const mapped =
+        otherNode.ip_to_interface?.[cleanIp] || otherNode.ip_to_interface?.[ip];
+      if (mapped) return mapped;
+    }
+
+    return null;
+  }
+
+  // Get directional arrow based on node positions
+  function getArrow(
+    fromNode: { x: number; y: number },
+    toNode: { x: number; y: number },
+  ): string {
+    const dx = toNode.x - fromNode.x;
+    const dy = toNode.y - fromNode.y;
+    const absX = Math.abs(dx);
+    const absY = Math.abs(dy);
+
+    if (absX > absY * 2) {
+      return dx > 0 ? "→" : "←";
+    } else if (absY > absX * 2) {
+      return dy > 0 ? "↓" : "↑";
+    } else {
+      if (dx > 0 && dy > 0) return "↘";
+      if (dx > 0 && dy < 0) return "↗";
+      if (dx < 0 && dy > 0) return "↙";
+      return "↖";
+    }
+  }
+
+  // Get connection info for edges between two nodes
+  // Returns exactly one connection per direction (A→B and B→A), preferring non-loopback
+  function getConnectionInfo(
+    nodeId1: string,
+    nodeId2: string,
+  ): Array<{ ip: string; iface: string | null; from: string; to: string }> {
+    if (!topology?.edges) return [];
+
+    // Collect candidates for each direction
+    const aToBCandidates: Array<{ ip: string; iface: string | null }> = [];
+    const bToACandidates: Array<{ ip: string; iface: string | null }> = [];
+
+    for (const edge of topology.edges) {
+      const ip = edge.sendBackIp || "?";
+      const iface =
+        edge.sendBackInterface || getInterfaceForIp(edge.source, ip);
+
+      if (edge.source === nodeId1 && edge.target === nodeId2) {
+        aToBCandidates.push({ ip, iface });
+      } else if (edge.source === nodeId2 && edge.target === nodeId1) {
+        bToACandidates.push({ ip, iface });
+      }
+    }
+
+    // Pick best (prefer non-loopback)
+    const pickBest = (
+      candidates: Array<{ ip: string; iface: string | null }>,
+    ) => {
+      if (candidates.length === 0) return null;
+      return candidates.find((c) => !c.ip.startsWith("127.")) || candidates[0];
+    };
+
+    const result: Array<{
+      ip: string;
+      iface: string | null;
+      from: string;
+      to: string;
+    }> = [];
+
+    const bestAtoB = pickBest(aToBCandidates);
+    if (bestAtoB) result.push({ ...bestAtoB, from: nodeId1, to: nodeId2 });
+
+    const bestBtoA = pickBest(bToACandidates);
+    if (bestBtoA) result.push({ ...bestBtoA, from: nodeId2, to: nodeId1 });
+
+    return result;
+  }
 </script>
 
 <div class="relative group">
-	<!-- Corner accents -->
-	<div class="absolute -top-px -left-px w-2 h-2 border-l border-t {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
-	<div class="absolute -top-px -right-px w-2 h-2 border-r border-t {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
-	<div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
-	<div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
-	
-	<div class="bg-exo-dark-gray/60 border {canFit ? 'border-exo-yellow/20 group-hover:border-exo-yellow/40' : 'border-red-500/20'} p-3 transition-all duration-200 group-hover:shadow-[0_0_15px_rgba(255,215,0,0.1)]">
-		<!-- Model Name & Memory Required -->
-		<div class="flex items-start justify-between gap-2 mb-2">
-			<div class="flex-1 min-w-0">
-			<div class="flex items-center gap-2">
-				<div class="text-exo-yellow text-xs font-mono tracking-wide truncate" title={model.name || model.id}>
-					{model.name || model.id}
-				</div>
-				{#if huggingFaceModelId}
-					<a
-						class="shrink-0 text-white/60 hover:text-exo-yellow transition-colors"
-						href={`https://huggingface.co/${huggingFaceModelId}`}
-						target="_blank"
-						rel="noreferrer noopener"
-						aria-label="View model on Hugging Face"
-					>
-						<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-							<path d="M14 3h7v7"/>
-							<path d="M10 14l11-11"/>
-							<path d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"/>
-						</svg>
-					</a>
-				{/if}
-					{#if tags.length > 0}
-						<div class="flex gap-1 flex-shrink-0">
-							{#each tags as tag}
-								<span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase rounded {tag === 'FASTEST' ? 'bg-green-500/20 text-green-400 border border-green-500/30' : 'bg-purple-500/20 text-purple-400 border border-purple-500/30'}">
-									{tag}
-								</span>
-							{/each}
-						</div>
-					{/if}
-				</div>
-				{#if model.name && model.name !== model.id}
-					<div class="text-xs text-exo-light-gray font-mono truncate mt-0.5" title={model.id}>
-						{model.id}
-					</div>
-				{/if}
-			</div>
-			<div class="flex-shrink-0 text-right">
-				<div class="text-xs font-mono {canFit ? 'text-exo-yellow' : 'text-red-400'}">
-					{estimatedMemory}GB
-				</div>
-			</div>
-		</div>
-		
-		<!-- Configuration Badge -->
-		<div class="flex items-center gap-1.5 mb-2">
-			<span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40">
-				{sharding}
-			</span>
-		<span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40">
-			{runtime === 'MlxRing' ? 'MLX Ring' : runtime === 'MlxIbv' || runtime === 'MlxJaccl' ? 'MLX RDMA' : runtime}
-		</span>
-		</div>
-		
-		<!-- Mini Topology Preview -->
-		{#if placementPreview().nodes.length > 0}
-			{@const preview = placementPreview()}
-			<div class="mb-3 bg-exo-black/60 rounded border border-exo-medium-gray/20 p-2 relative overflow-hidden">
-				<!-- Scanline effect -->
-				<div class="absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(255,215,0,0.02)_2px,rgba(255,215,0,0.02)_4px)] pointer-events-none"></div>
-				
-				<svg width="100%" height={preview.topoHeight} viewBox="0 0 {preview.topoWidth} {preview.topoHeight}" class="overflow-visible">
-					<defs>
-						<!-- Glow filter for active nodes -->
-						<filter id="nodeGlow-{filterId}" x="-50%" y="-50%" width="200%" height="200%">
-							<feGaussianBlur stdDeviation="2" result="blur"/>
-							<feMerge>
-								<feMergeNode in="blur"/>
-								<feMergeNode in="SourceGraphic"/>
-							</feMerge>
-						</filter>
-						
-						<!-- Strong glow for new memory -->
-						<filter id="memGlow-{filterId}" x="-100%" y="-100%" width="300%" height="300%">
-							<feGaussianBlur stdDeviation="3" result="blur"/>
-							<feComposite in="SourceGraphic" in2="blur" operator="over"/>
-						</filter>
-					</defs>
-					
-					<!-- Connection lines between nodes (if multiple) -->
-					{#if preview.nodes.length > 1}
-						{@const usedNodes = preview.nodes.filter(n => n.isUsed)}
-						{@const nodePositions = Object.fromEntries(preview.nodes.map(n => [n.id, { x: n.x, y: n.y }]))}
-						{@const allConnections = isDebugMode && usedNodes.length > 1 ? (() => {
-							const conns: Array<{ ip: string; iface: string | null; from: string; to: string; midX: number; midY: number; arrow: string }> = [];
-							for (let i = 0; i < usedNodes.length; i++) {
-								for (let j = i + 1; j < usedNodes.length; j++) {
-									const n1 = usedNodes[i];
-									const n2 = usedNodes[j];
-									const midX = (n1.x + n2.x) / 2;
-									const midY = (n1.y + n2.y) / 2;
-									for (const c of getConnectionInfo(n1.id, n2.id)) {
-										const fromPos = nodePositions[c.from];
-										const toPos = nodePositions[c.to];
-										const arrow = fromPos && toPos ? getArrow(fromPos, toPos) : '→';
-										conns.push({ ...c, midX, midY, arrow });
-									}
-								}
-							}
-							return conns;
-						})() : []}
-						{#each preview.nodes as node, i}
-							{#each preview.nodes.slice(i + 1) as node2}
-								<line 
-									x1={node.x} y1={node.y} x2={node2.x} y2={node2.y}
-									stroke={node.isUsed && node2.isUsed ? '#FFD700' : '#374151'}
-									stroke-width="1"
-									stroke-dasharray={node.isUsed && node2.isUsed ? '4,2' : '2,4'}
-									opacity={node.isUsed && node2.isUsed ? 0.4 : 0.15}
-								/>
-							{/each}
-						{/each}
-						<!-- Debug: Show connection IPs/interfaces in corners -->
-						{#if isDebugMode && allConnections.length > 0}
-							{@const centerX = preview.topoWidth / 2}
-							{@const centerY = preview.topoHeight / 2}
-							{@const quadrants = {
-								topLeft: allConnections.filter(c => c.midX < centerX && c.midY < centerY),
-								topRight: allConnections.filter(c => c.midX >= centerX && c.midY < centerY),
-								bottomLeft: allConnections.filter(c => c.midX < centerX && c.midY >= centerY),
-								bottomRight: allConnections.filter(c => c.midX >= centerX && c.midY >= centerY)
-							}}
-							{@const padding = 4}
-							{@const lineHeight = 8}
-							<!-- Top Left -->
-							{#each quadrants.topLeft as conn, idx}
-								<text x={padding} y={padding + idx * lineHeight} text-anchor="start" dominant-baseline="hanging" font-size="6" font-family="SF Mono, Monaco, monospace" fill={conn.iface ? 'rgba(255,255,255,0.85)' : 'rgba(248,113,113,0.85)'}>
-									{conn.arrow} {isRdma ? (conn.iface || '?') : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ''}`}
-								</text>
-							{/each}
-							<!-- Top Right -->
-							{#each quadrants.topRight as conn, idx}
-								<text x={preview.topoWidth - padding} y={padding + idx * lineHeight} text-anchor="end" dominant-baseline="hanging" font-size="6" font-family="SF Mono, Monaco, monospace" fill={conn.iface ? 'rgba(255,255,255,0.85)' : 'rgba(248,113,113,0.85)'}>
-									{conn.arrow} {isRdma ? (conn.iface || '?') : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ''}`}
-								</text>
-							{/each}
-							<!-- Bottom Left -->
-							{#each quadrants.bottomLeft as conn, idx}
-								<text x={padding} y={preview.topoHeight - padding - (quadrants.bottomLeft.length - 1 - idx) * lineHeight} text-anchor="start" dominant-baseline="auto" font-size="6" font-family="SF Mono, Monaco, monospace" fill={conn.iface ? 'rgba(255,255,255,0.85)' : 'rgba(248,113,113,0.85)'}>
-									{conn.arrow} {isRdma ? (conn.iface || '?') : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ''}`}
-								</text>
-							{/each}
-							<!-- Bottom Right -->
-							{#each quadrants.bottomRight as conn, idx}
-								<text x={preview.topoWidth - padding} y={preview.topoHeight - padding - (quadrants.bottomRight.length - 1 - idx) * lineHeight} text-anchor="end" dominant-baseline="auto" font-size="6" font-family="SF Mono, Monaco, monospace" fill={conn.iface ? 'rgba(255,255,255,0.85)' : 'rgba(248,113,113,0.85)'}>
-									{conn.arrow} {isRdma ? (conn.iface || '?') : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ''}`}
-								</text>
-							{/each}
-						{/if}
-					{/if}
-					
-					{#each preview.nodes as node}
-						<g 
-							transform="translate({node.x}, {node.y})"
-							opacity={node.isUsed ? 1 : 0.25}
-							filter={node.isUsed ? `url(#nodeGlow-${filterId})` : 'none'}
-						>
-							<!-- Device icon based on type -->
-							{#if node.deviceType === 'macbook'}
-								<!-- MacBook Pro icon with memory fill -->
-								<g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
-									<!-- Screen bezel -->
-									<rect 
-										x="2" y="0" 
-										width={node.iconSize - 4} height={node.iconSize * 0.65} 
-										rx="2"
-										fill="none"
-										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
-										stroke-width="1.5"
-									/>
-									<!-- Screen area (memory fill container) -->
-									<rect 
-										x="4" y="2" 
-										width={node.iconSize - 8} height={node.screenHeight}
-										fill="#0a0a0a"
-									/>
-									<!-- Current memory fill (gray) -->
-									<rect 
-										x="4" 
-										y={2 + node.screenHeight - node.currentFillHeight}
-										width={node.iconSize - 8} 
-										height={node.currentFillHeight}
-										fill="#374151"
-									/>
-									<!-- New model memory fill (glowing yellow) -->
-									{#if node.modelUsageGB > 0 && node.isUsed}
-										<rect 
-											x="4" 
-											y={2 + node.screenHeight - node.currentFillHeight - node.modelFillHeight}
-											width={node.iconSize - 8} 
-											height={node.modelFillHeight}
-											fill="#FFD700"
-											filter="url(#memGlow-{filterId})"
-											class="animate-pulse-slow"
-										/>
-									{/if}
-									<!-- Base/keyboard -->
-									<path 
-										d="M 0 {node.iconSize * 0.68} L {node.iconSize} {node.iconSize * 0.68} L {node.iconSize - 2} {node.iconSize * 0.78} L 2 {node.iconSize * 0.78} Z"
-										fill="none"
-										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
-										stroke-width="1.5"
-									/>
-								</g>
-							{:else if node.deviceType === 'studio'}
-								<!-- Mac Studio icon -->
-								<g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
-									<rect 
-										x="2" y="2" 
-										width={node.iconSize - 4} height={node.iconSize - 4} 
-										rx="4"
-										fill="none"
-										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
-										stroke-width="1.5"
-									/>
-									<!-- Memory fill background -->
-									<rect 
-										x="4" y="4" 
-										width={node.iconSize - 8} height={node.iconSize - 8}
-										fill="#0a0a0a"
-									/>
-									<!-- Current memory fill -->
-									<rect 
-										x="4" 
-										y={4 + (node.iconSize - 8) * (1 - node.currentPercent / 100)}
-										width={node.iconSize - 8} 
-										height={(node.iconSize - 8) * (node.currentPercent / 100)}
-										fill="#374151"
-									/>
-									<!-- New model memory fill -->
-									{#if node.modelUsageGB > 0 && node.isUsed}
-										<rect 
-											x="4" 
-											y={4 + (node.iconSize - 8) * (1 - node.newPercent / 100)}
-											width={node.iconSize - 8} 
-											height={(node.iconSize - 8) * ((node.newPercent - node.currentPercent) / 100)}
-											fill="#FFD700"
-											filter="url(#memGlow-{filterId})"
-											class="animate-pulse-slow"
-										/>
-									{/if}
-								</g>
-							{:else if node.deviceType === 'mini'}
-								<!-- Mac Mini icon -->
-								<g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
-									<rect 
-										x="2" y={node.iconSize * 0.3} 
-										width={node.iconSize - 4} height={node.iconSize * 0.4} 
-										rx="3"
-										fill="none"
-										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
-										stroke-width="1.5"
-									/>
-									<!-- Memory fill background -->
-									<rect 
-										x="4" y={node.iconSize * 0.32} 
-										width={node.iconSize - 8} height={node.iconSize * 0.36}
-										fill="#0a0a0a"
-									/>
-									<!-- Current memory fill -->
-									<rect 
-										x="4" 
-										y={node.iconSize * 0.32 + (node.iconSize * 0.36) * (1 - node.currentPercent / 100)}
-										width={node.iconSize - 8} 
-										height={(node.iconSize * 0.36) * (node.currentPercent / 100)}
-										fill="#374151"
-									/>
-									<!-- New model memory fill -->
-									{#if node.modelUsageGB > 0 && node.isUsed}
-										<rect 
-											x="4" 
-											y={node.iconSize * 0.32 + (node.iconSize * 0.36) * (1 - node.newPercent / 100)}
-											width={node.iconSize - 8} 
-											height={(node.iconSize * 0.36) * ((node.newPercent - node.currentPercent) / 100)}
-											fill="#FFD700"
-											filter="url(#memGlow-{filterId})"
-											class="animate-pulse-slow"
-										/>
-									{/if}
-								</g>
-							{:else}
-								<!-- Unknown device - hexagon -->
-								<g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
-									<polygon 
-										points="{node.iconSize/2},0 {node.iconSize},{node.iconSize*0.25} {node.iconSize},{node.iconSize*0.75} {node.iconSize/2},{node.iconSize} 0,{node.iconSize*0.75} 0,{node.iconSize*0.25}"
-										fill={node.isUsed ? 'rgba(255,215,0,0.1)' : '#0a0a0a'}
-										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
-										stroke-width="1.5"
-									/>
-								</g>
-							{/if}
-							
-							<!-- Percentage label -->
-							<text 
-								y={node.iconSize/2 + 12}
-								text-anchor="middle"
-								font-size="8"
-								font-family="SF Mono, Monaco, monospace"
-								fill={node.isUsed ? (node.newPercent > 90 ? '#f87171' : '#FFD700') : '#4B5563'}
-							>
-								{node.newPercent.toFixed(0)}%
-							</text>
-						</g>
-					{/each}
-				</svg>
-			</div>
-		{/if}
-
-		<!-- Launch Button -->
-		<button
-			onclick={onLaunch}
-			disabled={isLaunching || !canFit}
-			class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200 
-				{isLaunching 
-					? 'bg-transparent text-exo-yellow border-exo-yellow/50 cursor-wait' 
-					: !canFit
-						? 'bg-red-500/10 text-red-400/70 border-red-500/30 cursor-not-allowed'
-						: 'bg-transparent text-exo-light-gray border-exo-light-gray/40 hover:text-exo-yellow hover:border-exo-yellow/50 cursor-pointer'
-				}"
-		>
-			{#if isLaunching}
-				<span class="flex items-center justify-center gap-1.5">
-					<span class="w-2 h-2 border border-exo-yellow border-t-transparent rounded-full animate-spin"></span>
-					LAUNCHING...
-				</span>
-			{:else if !canFit}
-				INSUFFICIENT MEMORY
-			{:else}
-				▸ LAUNCH
-			{/if}
-		</button>
-	</div>
+  <!-- Corner accents -->
+  <div
+    class="absolute -top-px -left-px w-2 h-2 border-l border-t {canFit
+      ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
+      : 'border-red-500/30'} transition-colors"
+  ></div>
+  <div
+    class="absolute -top-px -right-px w-2 h-2 border-r border-t {canFit
+      ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
+      : 'border-red-500/30'} transition-colors"
+  ></div>
+  <div
+    class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {canFit
+      ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
+      : 'border-red-500/30'} transition-colors"
+  ></div>
+  <div
+    class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {canFit
+      ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
+      : 'border-red-500/30'} transition-colors"
+  ></div>
+
+  <div
+    class="bg-exo-dark-gray/60 border {canFit
+      ? 'border-exo-yellow/20 group-hover:border-exo-yellow/40'
+      : 'border-red-500/20'} p-3 transition-all duration-200 group-hover:shadow-[0_0_15px_rgba(255,215,0,0.1)]"
+  >
+    <!-- Model Name & Memory Required -->
+    <div class="flex items-start justify-between gap-2 mb-2">
+      <div class="flex-1 min-w-0">
+        <div class="flex items-center gap-2">
+          <div
+            class="text-exo-yellow text-xs font-mono tracking-wide truncate"
+            title={model.name || model.id}
+          >
+            {model.name || model.id}
+          </div>
+          {#if huggingFaceModelId}
+            <a
+              class="shrink-0 text-white/60 hover:text-exo-yellow transition-colors"
+              href={`https://huggingface.co/${huggingFaceModelId}`}
+              target="_blank"
+              rel="noreferrer noopener"
+              aria-label="View model on Hugging Face"
+            >
+              <svg
+                class="w-3.5 h-3.5"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="2"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              >
+                <path d="M14 3h7v7" />
+                <path d="M10 14l11-11" />
+                <path
+                  d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
+                />
+              </svg>
+            </a>
+          {/if}
+          {#if tags.length > 0}
+            <div class="flex gap-1 flex-shrink-0">
+              {#each tags as tag}
+                <span
+                  class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase rounded {tag ===
+                  'FASTEST'
+                    ? 'bg-green-500/20 text-green-400 border border-green-500/30'
+                    : 'bg-purple-500/20 text-purple-400 border border-purple-500/30'}"
+                >
+                  {tag}
+                </span>
+              {/each}
+            </div>
+          {/if}
+        </div>
+        {#if model.name && model.name !== model.id}
+          <div
+            class="text-xs text-exo-light-gray font-mono truncate mt-0.5"
+            title={model.id}
+          >
+            {model.id}
+          </div>
+        {/if}
+      </div>
+      <div class="flex-shrink-0 text-right">
+        <div
+          class="text-xs font-mono {canFit
+            ? 'text-exo-yellow'
+            : 'text-red-400'}"
+        >
+          {estimatedMemory}GB
+        </div>
+      </div>
+    </div>
+
+    <!-- Configuration Badge -->
+    <div class="flex items-center gap-1.5 mb-2">
+      <span
+        class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40"
+      >
+        {sharding}
+      </span>
+      <span
+        class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40"
+      >
+        {runtime === "MlxRing"
+          ? "MLX Ring"
+          : runtime === "MlxIbv" || runtime === "MlxJaccl"
+            ? "MLX RDMA"
+            : runtime}
+      </span>
+    </div>
+
+    <!-- Mini Topology Preview -->
+    {#if placementPreview().nodes.length > 0}
+      {@const preview = placementPreview()}
+      <div
+        class="mb-3 bg-exo-black/60 rounded border border-exo-medium-gray/20 p-2 relative overflow-hidden"
+      >
+        <!-- Scanline effect -->
+        <div
+          class="absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(255,215,0,0.02)_2px,rgba(255,215,0,0.02)_4px)] pointer-events-none"
+        ></div>
+
+        <svg
+          width="100%"
+          height={preview.topoHeight}
+          viewBox="0 0 {preview.topoWidth} {preview.topoHeight}"
+          class="overflow-visible"
+        >
+          <defs>
+            <!-- Glow filter for active nodes -->
+            <filter
+              id="nodeGlow-{filterId}"
+              x="-50%"
+              y="-50%"
+              width="200%"
+              height="200%"
+            >
+              <feGaussianBlur stdDeviation="2" result="blur" />
+              <feMerge>
+                <feMergeNode in="blur" />
+                <feMergeNode in="SourceGraphic" />
+              </feMerge>
+            </filter>
+
+            <!-- Strong glow for new memory -->
+            <filter
+              id="memGlow-{filterId}"
+              x="-100%"
+              y="-100%"
+              width="300%"
+              height="300%"
+            >
+              <feGaussianBlur stdDeviation="3" result="blur" />
+              <feComposite in="SourceGraphic" in2="blur" operator="over" />
+            </filter>
+          </defs>
+
+          <!-- Connection lines between nodes (if multiple) -->
+          {#if preview.nodes.length > 1}
+            {@const usedNodes = preview.nodes.filter((n) => n.isUsed)}
+            {@const nodePositions = Object.fromEntries(
+              preview.nodes.map((n) => [n.id, { x: n.x, y: n.y }]),
+            )}
+            {@const allConnections =
+              isDebugMode && usedNodes.length > 1
+                ? (() => {
+                    const conns: Array<{
+                      ip: string;
+                      iface: string | null;
+                      from: string;
+                      to: string;
+                      midX: number;
+                      midY: number;
+                      arrow: string;
+                    }> = [];
+                    for (let i = 0; i < usedNodes.length; i++) {
+                      for (let j = i + 1; j < usedNodes.length; j++) {
+                        const n1 = usedNodes[i];
+                        const n2 = usedNodes[j];
+                        const midX = (n1.x + n2.x) / 2;
+                        const midY = (n1.y + n2.y) / 2;
+                        for (const c of getConnectionInfo(n1.id, n2.id)) {
+                          const fromPos = nodePositions[c.from];
+                          const toPos = nodePositions[c.to];
+                          const arrow =
+                            fromPos && toPos ? getArrow(fromPos, toPos) : "→";
+                          conns.push({ ...c, midX, midY, arrow });
+                        }
+                      }
+                    }
+                    return conns;
+                  })()
+                : []}
+            {#each preview.nodes as node, i}
+              {#each preview.nodes.slice(i + 1) as node2}
+                <line
+                  x1={node.x}
+                  y1={node.y}
+                  x2={node2.x}
+                  y2={node2.y}
+                  stroke={node.isUsed && node2.isUsed ? "#FFD700" : "#374151"}
+                  stroke-width="1"
+                  stroke-dasharray={node.isUsed && node2.isUsed ? "4,2" : "2,4"}
+                  opacity={node.isUsed && node2.isUsed ? 0.4 : 0.15}
+                />
+              {/each}
+            {/each}
+            <!-- Debug: Show connection IPs/interfaces in corners -->
+            {#if isDebugMode && allConnections.length > 0}
+              {@const centerX = preview.topoWidth / 2}
+              {@const centerY = preview.topoHeight / 2}
+              {@const quadrants = {
+                topLeft: allConnections.filter(
+                  (c) => c.midX < centerX && c.midY < centerY,
+                ),
+                topRight: allConnections.filter(
+                  (c) => c.midX >= centerX && c.midY < centerY,
+                ),
+                bottomLeft: allConnections.filter(
+                  (c) => c.midX < centerX && c.midY >= centerY,
+                ),
+                bottomRight: allConnections.filter(
+                  (c) => c.midX >= centerX && c.midY >= centerY,
+                ),
+              }}
+              {@const padding = 4}
+              {@const lineHeight = 8}
+              <!-- Top Left -->
+              {#each quadrants.topLeft as conn, idx}
+                <text
+                  x={padding}
+                  y={padding + idx * lineHeight}
+                  text-anchor="start"
+                  dominant-baseline="hanging"
+                  font-size="6"
+                  font-family="SF Mono, Monaco, monospace"
+                  fill={conn.iface
+                    ? "rgba(255,255,255,0.85)"
+                    : "rgba(248,113,113,0.85)"}
+                >
+                  {conn.arrow}
+                  {isRdma
+                    ? conn.iface || "?"
+                    : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
+                </text>
+              {/each}
+              <!-- Top Right -->
+              {#each quadrants.topRight as conn, idx}
+                <text
+                  x={preview.topoWidth - padding}
+                  y={padding + idx * lineHeight}
+                  text-anchor="end"
+                  dominant-baseline="hanging"
+                  font-size="6"
+                  font-family="SF Mono, Monaco, monospace"
+                  fill={conn.iface
+                    ? "rgba(255,255,255,0.85)"
+                    : "rgba(248,113,113,0.85)"}
+                >
+                  {conn.arrow}
+                  {isRdma
+                    ? conn.iface || "?"
+                    : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
+                </text>
+              {/each}
+              <!-- Bottom Left -->
+              {#each quadrants.bottomLeft as conn, idx}
+                <text
+                  x={padding}
+                  y={preview.topoHeight -
+                    padding -
+                    (quadrants.bottomLeft.length - 1 - idx) * lineHeight}
+                  text-anchor="start"
+                  dominant-baseline="auto"
+                  font-size="6"
+                  font-family="SF Mono, Monaco, monospace"
+                  fill={conn.iface
+                    ? "rgba(255,255,255,0.85)"
+                    : "rgba(248,113,113,0.85)"}
+                >
+                  {conn.arrow}
+                  {isRdma
+                    ? conn.iface || "?"
+                    : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
+                </text>
+              {/each}
+              <!-- Bottom Right -->
+              {#each quadrants.bottomRight as conn, idx}
+                <text
+                  x={preview.topoWidth - padding}
+                  y={preview.topoHeight -
+                    padding -
+                    (quadrants.bottomRight.length - 1 - idx) * lineHeight}
+                  text-anchor="end"
+                  dominant-baseline="auto"
+                  font-size="6"
+                  font-family="SF Mono, Monaco, monospace"
+                  fill={conn.iface
+                    ? "rgba(255,255,255,0.85)"
+                    : "rgba(248,113,113,0.85)"}
+                >
+                  {conn.arrow}
+                  {isRdma
+                    ? conn.iface || "?"
+                    : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
+                </text>
+              {/each}
+            {/if}
+          {/if}
+
+          {#each preview.nodes as node}
+            <g
+              transform="translate({node.x}, {node.y})"
+              opacity={node.isUsed ? 1 : 0.25}
+              filter={node.isUsed ? `url(#nodeGlow-${filterId})` : "none"}
+            >
+              <!-- Device icon based on type -->
+              {#if node.deviceType === "macbook"}
+                <!-- MacBook Pro icon with memory fill -->
+                <g
+                  transform="translate({-node.iconSize / 2}, {-node.iconSize /
+                    2})"
+                >
+                  <!-- Screen bezel -->
+                  <rect
+                    x="2"
+                    y="0"
+                    width={node.iconSize - 4}
+                    height={node.iconSize * 0.65}
+                    rx="2"
+                    fill="none"
+                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+                    stroke-width="1.5"
+                  />
+                  <!-- Screen area (memory fill container) -->
+                  <rect
+                    x="4"
+                    y="2"
+                    width={node.iconSize - 8}
+                    height={node.screenHeight}
+                    fill="#0a0a0a"
+                  />
+                  <!-- Current memory fill (gray) -->
+                  <rect
+                    x="4"
+                    y={2 + node.screenHeight - node.currentFillHeight}
+                    width={node.iconSize - 8}
+                    height={node.currentFillHeight}
+                    fill="#374151"
+                  />
+                  <!-- New model memory fill (glowing yellow) -->
+                  {#if node.modelUsageGB > 0 && node.isUsed}
+                    <rect
+                      x="4"
+                      y={2 +
+                        node.screenHeight -
+                        node.currentFillHeight -
+                        node.modelFillHeight}
+                      width={node.iconSize - 8}
+                      height={node.modelFillHeight}
+                      fill="#FFD700"
+                      filter="url(#memGlow-{filterId})"
+                      class="animate-pulse-slow"
+                    />
+                  {/if}
+                  <!-- Base/keyboard -->
+                  <path
+                    d="M 0 {node.iconSize *
+                      0.68} L {node.iconSize} {node.iconSize *
+                      0.68} L {node.iconSize - 2} {node.iconSize *
+                      0.78} L 2 {node.iconSize * 0.78} Z"
+                    fill="none"
+                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+                    stroke-width="1.5"
+                  />
+                </g>
+              {:else if node.deviceType === "studio"}
+                <!-- Mac Studio icon -->
+                <g
+                  transform="translate({-node.iconSize / 2}, {-node.iconSize /
+                    2})"
+                >
+                  <rect
+                    x="2"
+                    y="2"
+                    width={node.iconSize - 4}
+                    height={node.iconSize - 4}
+                    rx="4"
+                    fill="none"
+                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+                    stroke-width="1.5"
+                  />
+                  <!-- Memory fill background -->
+                  <rect
+                    x="4"
+                    y="4"
+                    width={node.iconSize - 8}
+                    height={node.iconSize - 8}
+                    fill="#0a0a0a"
+                  />
+                  <!-- Current memory fill -->
+                  <rect
+                    x="4"
+                    y={4 +
+                      (node.iconSize - 8) * (1 - node.currentPercent / 100)}
+                    width={node.iconSize - 8}
+                    height={(node.iconSize - 8) * (node.currentPercent / 100)}
+                    fill="#374151"
+                  />
+                  <!-- New model memory fill -->
+                  {#if node.modelUsageGB > 0 && node.isUsed}
+                    <rect
+                      x="4"
+                      y={4 + (node.iconSize - 8) * (1 - node.newPercent / 100)}
+                      width={node.iconSize - 8}
+                      height={(node.iconSize - 8) *
+                        ((node.newPercent - node.currentPercent) / 100)}
+                      fill="#FFD700"
+                      filter="url(#memGlow-{filterId})"
+                      class="animate-pulse-slow"
+                    />
+                  {/if}
+                </g>
+              {:else if node.deviceType === "mini"}
+                <!-- Mac Mini icon -->
+                <g
+                  transform="translate({-node.iconSize / 2}, {-node.iconSize /
+                    2})"
+                >
+                  <rect
+                    x="2"
+                    y={node.iconSize * 0.3}
+                    width={node.iconSize - 4}
+                    height={node.iconSize * 0.4}
+                    rx="3"
+                    fill="none"
+                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+                    stroke-width="1.5"
+                  />
+                  <!-- Memory fill background -->
+                  <rect
+                    x="4"
+                    y={node.iconSize * 0.32}
+                    width={node.iconSize - 8}
+                    height={node.iconSize * 0.36}
+                    fill="#0a0a0a"
+                  />
+                  <!-- Current memory fill -->
+                  <rect
+                    x="4"
+                    y={node.iconSize * 0.32 +
+                      node.iconSize * 0.36 * (1 - node.currentPercent / 100)}
+                    width={node.iconSize - 8}
+                    height={node.iconSize * 0.36 * (node.currentPercent / 100)}
+                    fill="#374151"
+                  />
+                  <!-- New model memory fill -->
+                  {#if node.modelUsageGB > 0 && node.isUsed}
+                    <rect
+                      x="4"
+                      y={node.iconSize * 0.32 +
+                        node.iconSize * 0.36 * (1 - node.newPercent / 100)}
+                      width={node.iconSize - 8}
+                      height={node.iconSize *
+                        0.36 *
+                        ((node.newPercent - node.currentPercent) / 100)}
+                      fill="#FFD700"
+                      filter="url(#memGlow-{filterId})"
+                      class="animate-pulse-slow"
+                    />
+                  {/if}
+                </g>
+              {:else}
+                <!-- Unknown device - hexagon -->
+                <g
+                  transform="translate({-node.iconSize / 2}, {-node.iconSize /
+                    2})"
+                >
+                  <polygon
+                    points="{node.iconSize /
+                      2},0 {node.iconSize},{node.iconSize *
+                      0.25} {node.iconSize},{node.iconSize *
+                      0.75} {node.iconSize /
+                      2},{node.iconSize} 0,{node.iconSize *
+                      0.75} 0,{node.iconSize * 0.25}"
+                    fill={node.isUsed ? "rgba(255,215,0,0.1)" : "#0a0a0a"}
+                    stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+                    stroke-width="1.5"
+                  />
+                </g>
+              {/if}
+
+              <!-- Percentage label -->
+              <text
+                y={node.iconSize / 2 + 12}
+                text-anchor="middle"
+                font-size="8"
+                font-family="SF Mono, Monaco, monospace"
+                fill={node.isUsed
+                  ? node.newPercent > 90
+                    ? "#f87171"
+                    : "#FFD700"
+                  : "#4B5563"}
+              >
+                {node.newPercent.toFixed(0)}%
+              </text>
+            </g>
+          {/each}
+        </svg>
+      </div>
+    {/if}
+
+    <!-- Launch Button -->
+    <button
+      onclick={onLaunch}
+      disabled={isLaunching || !canFit}
+      class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200
+				{isLaunching
+        ? 'bg-transparent text-exo-yellow border-exo-yellow/50 cursor-wait'
+        : !canFit
+          ? 'bg-red-500/10 text-red-400/70 border-red-500/30 cursor-not-allowed'
+          : 'bg-transparent text-exo-light-gray border-exo-light-gray/40 hover:text-exo-yellow hover:border-exo-yellow/50 cursor-pointer'}"
+    >
+      {#if isLaunching}
+        <span class="flex items-center justify-center gap-1.5">
+          <span
+            class="w-2 h-2 border border-exo-yellow border-t-transparent rounded-full animate-spin"
+          ></span>
+          LAUNCHING...
+        </span>
+      {:else if !canFit}
+        INSUFFICIENT MEMORY
+      {:else}
+        ▸ LAUNCH
+      {/if}
+    </button>
+  </div>
 </div>
 
 <style>
-	@keyframes pulse-slow {
-		0%, 100% { opacity: 0.8; }
-		50% { opacity: 1; }
-	}
-	.animate-pulse-slow {
-		animation: pulse-slow 1.5s ease-in-out infinite;
-	}
+  @keyframes pulse-slow {
+    0%,
+    100% {
+      opacity: 0.8;
+    }
+    50% {
+      opacity: 1;
+    }
+  }
+  .animate-pulse-slow {
+    animation: pulse-slow 1.5s ease-in-out infinite;
+  }
 </style>
diff --git a/dashboard/src/lib/components/TopologyGraph.svelte b/dashboard/src/lib/components/TopologyGraph.svelte
index c4d9b041..41748977 100644
--- a/dashboard/src/lib/components/TopologyGraph.svelte
+++ b/dashboard/src/lib/components/TopologyGraph.svelte
@@ -1,983 +1,1116 @@
 <script lang="ts">
-	import { onMount, onDestroy } from 'svelte';
-	import * as d3 from 'd3';
-import { topologyData, isTopologyMinimized, debugMode, type NodeInfo } from '$lib/stores/app.svelte';
-
-	interface Props {
-		class?: string;
-		highlightedNodes?: Set<string>;
-	}
-
-	let { class: className = '', highlightedNodes = new Set() }: Props = $props();
-
-	let svgContainer: SVGSVGElement | undefined = $state();
-	let resizeObserver: ResizeObserver | undefined;
-
-const isMinimized = $derived(isTopologyMinimized());
-const data = $derived(topologyData());
-const debugEnabled = $derived(debugMode());
-
-function getNodeLabel(nodeId: string): string {
-	const node = data?.nodes?.[nodeId];
-	return node?.friendly_name || nodeId.slice(0, 8);
-}
-
-function getInterfaceLabel(nodeId: string, ip?: string): { label: string; missing: boolean } {
-	if (!ip) return { label: '?', missing: true };
-
-	// Strip port if present (e.g., "192.168.1.1:8080" -> "192.168.1.1")
-	const cleanIp = ip.includes(':') && !ip.includes('[') ? ip.split(':')[0] : ip;
-
-	// Helper to check a node's interfaces
-	function checkNode(node: NodeInfo | undefined): string | null {
-		if (!node) return null;
-
-		const matchFromInterfaces = node.network_interfaces?.find((iface) =>
-			(iface.addresses || []).some((addr) => addr === cleanIp || addr === ip)
-		);
-		if (matchFromInterfaces?.name) {
-			return matchFromInterfaces.name;
-		}
-
-		if (node.ip_to_interface) {
-			const mapped = node.ip_to_interface[cleanIp] || (ip ? node.ip_to_interface[ip] : undefined);
-			if (mapped && mapped.trim().length > 0) {
-				return mapped;
-			}
-		}
-		return null;
-	}
-
-	// Try specified node first
-	const result = checkNode(data?.nodes?.[nodeId]);
-	if (result) return { label: result, missing: false };
-
-	// Fallback: search all nodes for this IP
-	for (const [, otherNode] of Object.entries(data?.nodes || {})) {
-		const otherResult = checkNode(otherNode);
-		if (otherResult) return { label: otherResult, missing: false };
-	}
-
-	return { label: '?', missing: true };
-}
-
-function wrapLine(text: string, maxLen: number): string[] {
-	if (text.length <= maxLen) return [text];
-	const words = text.split(' ');
-	const lines: string[] = [];
-	let current = '';
-	for (const word of words) {
-		if (word.length > maxLen) {
-			if (current) {
-				lines.push(current);
-				current = '';
-			}
-			for (let i = 0; i < word.length; i += maxLen) {
-				lines.push(word.slice(i, i + maxLen));
-			}
-		} else if ((current + ' ' + word).trim().length > maxLen) {
-			lines.push(current);
-			current = word;
-		} else {
-			current = current ? `${current} ${word}` : word;
-		}
-	}
-	if (current) lines.push(current);
-	return lines;
-}
-
-
-	// Apple logo path for MacBook Pro screen
-	const APPLE_LOGO_PATH = "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z";
-	const LOGO_NATIVE_WIDTH = 814;
-	const LOGO_NATIVE_HEIGHT = 1000;
-
-	function formatBytes(bytes: number, decimals = 1): string {
-		if (!bytes || bytes === 0) return '0B';
-		const k = 1024;
-		const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
-		const i = Math.floor(Math.log(bytes) / Math.log(k));
-		return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
-	}
-
-	function getTemperatureColor(temp: number): string {
-		// Default for N/A temp - light gray
-		if (isNaN(temp) || temp === null) return 'rgba(179, 179, 179, 0.8)';
-		
-		const coolTemp = 45;  // Temp for pure blue
-		const midTemp = 57.5; // Temp for pure yellow
-		const hotTemp = 75;   // Temp for pure red
-		
-		const coolColor = { r: 93, g: 173, b: 226 };  // #5DADE2 (Blue)
-		const midColor = { r: 255, g: 215, b: 0 };    // #FFD700 (Yellow)
-		const hotColor = { r: 244, g: 67, b: 54 };    // #F44336 (Red)
-		
-		let r: number, g: number, b: number;
-		
-		if (temp <= coolTemp) {
-			({ r, g, b } = coolColor);
-		} else if (temp <= midTemp) {
-			const ratio = (temp - coolTemp) / (midTemp - coolTemp);
-			r = Math.round(coolColor.r * (1 - ratio) + midColor.r * ratio);
-			g = Math.round(coolColor.g * (1 - ratio) + midColor.g * ratio);
-			b = Math.round(coolColor.b * (1 - ratio) + midColor.b * ratio);
-		} else if (temp < hotTemp) {
-			const ratio = (temp - midTemp) / (hotTemp - midTemp);
-			r = Math.round(midColor.r * (1 - ratio) + hotColor.r * ratio);
-			g = Math.round(midColor.g * (1 - ratio) + hotColor.g * ratio);
-			b = Math.round(midColor.b * (1 - ratio) + hotColor.b * ratio);
-		} else {
-			({ r, g, b } = hotColor);
-		}
-		
-		return `rgb(${r}, ${g}, ${b})`;
-	}
-
-	function renderGraph() {
-		if (!svgContainer || !data) return;
-
-		d3.select(svgContainer).selectAll('*').remove();
-
-		const nodes = data.nodes || {};
-		const edges = data.edges || [];
-		const nodeIds = Object.keys(nodes);
-
-		const rect = svgContainer.getBoundingClientRect();
-		const width = rect.width;
-		const height = rect.height;
-		const centerX = width / 2;
-		const centerY = height / 2;
-
-		const svg = d3.select(svgContainer);
-
-		// Add defs for clip paths and filters
-		const defs = svg.append('defs');
-		
-		// Glow filter
-		const glowFilter = defs.append('filter')
-			.attr('id', 'glow')
-			.attr('x', '-50%')
-			.attr('y', '-50%')
-			.attr('width', '200%')
-			.attr('height', '200%');
-		glowFilter.append('feGaussianBlur')
-			.attr('stdDeviation', '2')
-			.attr('result', 'coloredBlur');
-		const glowMerge = glowFilter.append('feMerge');
-		glowMerge.append('feMergeNode').attr('in', 'coloredBlur');
-		glowMerge.append('feMergeNode').attr('in', 'SourceGraphic');
-
-		// Arrowhead marker for directional edges
-		const marker = defs.append('marker')
-			.attr('id', 'arrowhead')
-			.attr('viewBox', '0 0 10 10')
-			.attr('refX', '10')
-			.attr('refY', '5')
-			.attr('markerWidth', '11')
-			.attr('markerHeight', '11')
-			.attr('orient', 'auto-start-reverse');
-		marker.append('path')
-			.attr('d', 'M 0 0 L 10 5 L 0 10')
-			.attr('fill', 'none')
-			.attr('stroke', 'var(--exo-light-gray, #B3B3B3)')
-			.attr('stroke-width', '1.6')
-			.attr('stroke-linecap', 'round')
-			.attr('stroke-linejoin', 'round')
-			.style('animation', 'none');
-
-		if (nodeIds.length === 0) {
-			svg.append('text')
-				.attr('x', centerX)
-				.attr('y', centerY)
-				.attr('text-anchor', 'middle')
-				.attr('dominant-baseline', 'middle')
-				.attr('fill', 'rgba(255,215,0,0.4)')
-				.attr('font-size', isMinimized ? 10 : 12)
-				.attr('font-family', 'SF Mono, monospace')
-				.attr('letter-spacing', '0.1em')
-				.text('AWAITING NODES');
-			return;
-		}
-
-		const numNodes = nodeIds.length;
-		const minDimension = Math.min(width, height);
-		
-		// Dynamic scaling - larger nodes for big displays
-		const sizeScale = numNodes === 1 ? 1 : Math.max(0.6, 1 - (numNodes - 1) * 0.10);
-		const baseNodeRadius = isMinimized
-			? Math.max(36, Math.min(60, minDimension * 0.22))
-			: Math.min(120, minDimension * 0.20);
-		const nodeRadius = baseNodeRadius * sizeScale;
-		
-		// Orbit radius - balanced spacing for nodes
-		const circumference = numNodes * nodeRadius * 4;
-		const radiusFromCircumference = circumference / (2 * Math.PI);
-		const minOrbitRadius = Math.max(radiusFromCircumference, minDimension * 0.18);
-		const maxOrbitRadius = minDimension * 0.30;
-		const orbitRadius = isMinimized 
-			? Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * 0.26))
-			: Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * (0.22 + numNodes * 0.02)));
-		
-		// Determine display mode based on space and node count
-		const showFullLabels = !isMinimized && numNodes <= 4;
-		const showCompactLabels = !isMinimized && numNodes > 4;
-
-		// Add padding for labels (top/bottom)
-		const topPadding = 70; // Space for "NETWORK TOPOLOGY" label and node names
-		const bottomPadding = 70; // Space for stats and bottom label
-		const safeCenterY = topPadding + (height - topPadding - bottomPadding) / 2;
-		
-		// Calculate node positions
-		const nodesWithPositions = nodeIds.map((id, index) => {
-			if (numNodes === 1) {
-				// Single node: center it
-				return {
-					id,
-					data: nodes[id],
-					x: centerX,
-					y: safeCenterY
-				};
-			}
-			// Distribute nodes around the orbit
-			// Start from top (-90 degrees) and go clockwise
-			const angle = (index / numNodes) * 2 * Math.PI - (Math.PI / 2);
-			return {
-				id,
-				data: nodes[id],
-				x: centerX + orbitRadius * Math.cos(angle),
-				y: safeCenterY + orbitRadius * Math.sin(angle)
-			};
-		});
-
-		const positionById: Record<string, { x: number; y: number }> = {};
-		nodesWithPositions.forEach(n => { positionById[n.id] = { x: n.x, y: n.y }; });
-
-		// Draw edges
-		const linksGroup = svg.append('g').attr('class', 'links-group');
-		const arrowsGroup = svg.append('g').attr('class', 'arrows-group');
-		const debugLabelsGroup = svg.append('g').attr('class', 'debug-edge-labels');
-
-		type ConnectionInfo = { from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean };
-		type PairEntry = { a: string; b: string; aToB: boolean; bToA: boolean; connections: ConnectionInfo[] };
-		type DebugEdgeLabelEntry = { connections: ConnectionInfo[]; isLeft: boolean; isTop: boolean; mx: number; my: number };
-		const pairMap = new Map<string, PairEntry>();
-		const debugEdgeLabels: DebugEdgeLabelEntry[] = [];
-		edges.forEach(edge => {
-			if (!edge.source || !edge.target || edge.source === edge.target) return;
-			if (!positionById[edge.source] || !positionById[edge.target]) return;
-
-			const a = edge.source < edge.target ? edge.source : edge.target;
-			const b = edge.source < edge.target ? edge.target : edge.source;
-			const key = `${a}|${b}`;
-			const entry = pairMap.get(key) || { a, b, aToB: false, bToA: false, connections: [] };
-
-			if (edge.source === a) entry.aToB = true;
-			else entry.bToA = true;
-
-			const ip = edge.sendBackIp || '?';
-			const ifaceInfo = getInterfaceLabel(edge.source, ip);
-			entry.connections.push({
-				from: edge.source,
-				to: edge.target,
-				ip,
-				ifaceLabel: ifaceInfo.label,
-				missingIface: ifaceInfo.missing
-			});
-			pairMap.set(key, entry);
-		});
-
-		pairMap.forEach(entry => {
-			const posA = positionById[entry.a];
-			const posB = positionById[entry.b];
-			if (!posA || !posB) return;
-
-			// Base dashed line
-			linksGroup.append('line')
-				.attr('x1', posA.x)
-				.attr('y1', posA.y)
-				.attr('x2', posB.x)
-				.attr('y2', posB.y)
-				.attr('class', 'graph-link');
-
-			// Calculate midpoint and direction for arrows
-			const dx = posB.x - posA.x;
-			const dy = posB.y - posA.y;
-			const len = Math.hypot(dx, dy) || 1;
-			const ux = dx / len;
-			const uy = dy / len;
-			const mx = (posA.x + posB.x) / 2;
-			const my = (posA.y + posB.y) / 2;
-			const tipOffset = 16; // Distance from center for arrow tips
-			const carrier = 2; // Short segment length for arrow orientation
-
-			// Arrow A -> B (if connection exists in that direction)
-			if (entry.aToB) {
-				const tipX = mx - ux * tipOffset;
-				const tipY = my - uy * tipOffset;
-				arrowsGroup.append('line')
-					.attr('x1', tipX - ux * carrier)
-					.attr('y1', tipY - uy * carrier)
-					.attr('x2', tipX)
-					.attr('y2', tipY)
-					.attr('stroke', 'none')
-					.attr('fill', 'none')
-					.attr('marker-end', 'url(#arrowhead)');
-			}
-
-			// Arrow B -> A (if connection exists in that direction)
-			if (entry.bToA) {
-				const tipX = mx + ux * tipOffset;
-				const tipY = my + uy * tipOffset;
-				arrowsGroup.append('line')
-					.attr('x1', tipX + ux * carrier)
-					.attr('y1', tipY + uy * carrier)
-					.attr('x2', tipX)
-					.attr('y2', tipY)
-					.attr('stroke', 'none')
-					.attr('fill', 'none')
-					.attr('marker-end', 'url(#arrowhead)');
-			}
-
-			// Collect debug labels for later positioning at edges
-			if (debugEnabled && entry.connections.length > 0) {
-				// Determine which side of viewport based on edge midpoint
-				const isLeft = mx < centerX;
-				const isTop = my < safeCenterY;
-
-				// Store for batch rendering after all edges processed
-				debugEdgeLabels.push({
-					connections: entry.connections,
-					isLeft,
-					isTop,
-					mx,
-					my
-				});
-			}
-		});
-
-		// Render debug labels at viewport edges/corners
-		if (debugEdgeLabels && debugEdgeLabels.length > 0) {
-			const fontSize = isMinimized ? 10 : 12;
-			const lineHeight = fontSize + 4;
-			const padding = 10;
-			
-			// Helper to get arrow based on direction vector
-			function getArrow(fromId: string, toId: string): string {
-				const fromPos = positionById[fromId];
-				const toPos = positionById[toId];
-				if (!fromPos || !toPos) return '→';
-				
-				const dirX = toPos.x - fromPos.x;
-				const dirY = toPos.y - fromPos.y;
-				const absX = Math.abs(dirX);
-				const absY = Math.abs(dirY);
-				
-				if (absX > absY * 2) {
-					return dirX > 0 ? '→' : '←';
-				} else if (absY > absX * 2) {
-					return dirY > 0 ? '↓' : '↑';
-				} else {
-					if (dirX > 0 && dirY > 0) return '↘';
-					if (dirX > 0 && dirY < 0) return '↗';
-					if (dirX < 0 && dirY > 0) return '↙';
-					return '↖';
-				}
-			}
-			
-			// Group by quadrant: topLeft, topRight, bottomLeft, bottomRight
-			const quadrants: Record<string, DebugEdgeLabelEntry[]> = {
-				topLeft: [],
-				topRight: [],
-				bottomLeft: [],
-				bottomRight: []
-			};
-
-			debugEdgeLabels.forEach(edge => {
-				const key = (edge.isTop ? 'top' : 'bottom') + (edge.isLeft ? 'Left' : 'Right');
-				quadrants[key].push(edge);
-			});
-
-			// Render each quadrant
-			Object.entries(quadrants).forEach(([quadrant, quadrantEdges]) => {
-				if (quadrantEdges.length === 0) return;
-
-				const isLeft = quadrant.includes('Left');
-				const isTop = quadrant.includes('top');
-
-				let baseX = isLeft ? padding : width - padding;
-				let baseY = isTop ? padding : height - padding;
-				const textAnchor = isLeft ? 'start' : 'end';
-
-				let currentY = baseY;
-
-				quadrantEdges.forEach(edge => {
-					edge.connections.forEach(conn => {
-						const arrow = getArrow(conn.from, conn.to);
-						const label = `${arrow} ${conn.ip} ${conn.ifaceLabel}`;
-						debugLabelsGroup.append('text')
-							.attr('x', baseX)
-							.attr('y', currentY)
-							.attr('text-anchor', textAnchor)
-							.attr('dominant-baseline', isTop ? 'hanging' : 'auto')
-							.attr('font-size', fontSize)
-							.attr('font-family', 'SF Mono, monospace')
-							.attr('fill', conn.missingIface ? 'rgba(248,113,113,0.9)' : 'rgba(255,255,255,0.85)')
-							.text(label);
-						currentY += isTop ? lineHeight : -lineHeight;
-					});
-				});
-			});
-		}
-
-		// Draw nodes
-		const nodesGroup = svg.append('g').attr('class', 'nodes-group');
-
-		nodesWithPositions.forEach(nodeInfo => {
-			const node = nodeInfo.data;
-			const macmon = node.macmon_info;
-			const modelId = node.system_info?.model_id || 'Unknown';
-			const friendlyName = node.friendly_name || modelId;
-
-			let ramUsagePercent = 0;
-			let gpuTemp = NaN;
-			let ramTotal = 0;
-			let ramUsed = 0;
-			let gpuUsagePercent = 0;
-			let sysPower: number | null = null;
-
-			if (macmon) {
-				if (macmon.memory && macmon.memory.ram_total > 0) {
-					ramUsagePercent = (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
-					ramTotal = macmon.memory.ram_total;
-					ramUsed = macmon.memory.ram_usage;
-				}
-				if (macmon.temp && typeof macmon.temp.gpu_temp_avg === 'number') {
-					gpuTemp = Math.max(30, macmon.temp.gpu_temp_avg);
-				}
-				if (macmon.gpu_usage) {
-					gpuUsagePercent = macmon.gpu_usage[1] * 100;
-				}
-				if (macmon.sys_power) {
-					sysPower = macmon.sys_power;
-				}
-			}
-
-			const nodeG = nodesGroup.append('g')
-				.attr('class', 'graph-node')
-				.style('cursor', 'pointer');
-
-			// Add tooltip
-			nodeG.append('title')
-				.text(`${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`);
-
-			let iconBaseWidth = nodeRadius * 1.2;
-			let iconBaseHeight = nodeRadius * 1.0;
-			const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
-
-			const modelLower = modelId.toLowerCase();
-			
-			// Check if this node should be highlighted (from hovered instance)
-			const isHighlighted = highlightedNodes.has(nodeInfo.id);
-			
-		// Holographic wireframe colors - yellow border when highlighted
-		const wireColor = isHighlighted ? 'rgba(255,215,0,0.9)' : 'rgba(179,179,179,0.8)';
-		const wireColorBright = 'rgba(255,255,255,0.9)';
-		const fillColor = isHighlighted ? 'rgba(255,215,0,0.15)' : 'rgba(255,215,0,0.08)';
-		const strokeWidth = isHighlighted ? 2.5 : 1.5;
-			const screenFill = 'rgba(0,20,40,0.9)';
-			const glowColor = 'rgba(255,215,0,0.3)';
-
-			if (modelLower === 'mac studio') {
-				// Mac Studio - classic cube with memory fill
-				iconBaseWidth = nodeRadius * 1.25;
-				iconBaseHeight = nodeRadius * 0.85;
-				const x = nodeInfo.x - iconBaseWidth / 2;
-				const y = nodeInfo.y - iconBaseHeight / 2;
-				const cornerRadius = 4;
-				const topSurfaceHeight = iconBaseHeight * 0.15;
-
-				// Create clip path for memory fill area (front body)
-				const studioClipId = `studio-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
-				defs.append('clipPath')
-					.attr('id', studioClipId)
-					.append('rect')
-					.attr('x', x)
-					.attr('y', y + topSurfaceHeight)
-					.attr('width', iconBaseWidth)
-					.attr('height', iconBaseHeight - topSurfaceHeight)
-					.attr('rx', cornerRadius - 1);
-
-				// Main body (uniform color)
-				nodeG.append('rect')
-					.attr('x', x)
-					.attr('y', y)
-					.attr('width', iconBaseWidth)
-					.attr('height', iconBaseHeight)
-					.attr('rx', cornerRadius)
-					.attr('fill', '#1a1a1a')
-					.attr('stroke', wireColor)
-					.attr('stroke-width', strokeWidth);
-
-				// Memory fill (fills from bottom up)
-				if (ramUsagePercent > 0) {
-					const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
-					const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
-					nodeG.append('rect')
-						.attr('x', x)
-						.attr('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight))
-						.attr('width', iconBaseWidth)
-						.attr('height', memFillActualHeight)
-						.attr('fill', 'rgba(255,215,0,0.75)')
-						.attr('clip-path', `url(#${studioClipId})`);
-				}
-
-				// Front panel details - vertical slots
-				const detailColor = 'rgba(0,0,0,0.35)';
-				const slotHeight = iconBaseHeight * 0.14;
-				const vSlotWidth = iconBaseWidth * 0.05;
-				const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.6;
-				const vSlot1X = x + iconBaseWidth * 0.18;
-				const vSlot2X = x + iconBaseWidth * 0.28;
-
-				[vSlot1X, vSlot2X].forEach(vx => {
-					nodeG.append('rect')
-						.attr('x', vx - vSlotWidth / 2)
-						.attr('y', vSlotY)
-						.attr('width', vSlotWidth)
-						.attr('height', slotHeight)
-						.attr('fill', detailColor)
-						.attr('rx', 1.5);
-				});
-
-				// Horizontal slot (SD card)
-				const hSlotWidth = iconBaseWidth * 0.2;
-				const hSlotX = x + iconBaseWidth * 0.5 - hSlotWidth / 2;
-				nodeG.append('rect')
-					.attr('x', hSlotX)
-					.attr('y', vSlotY)
-					.attr('width', hSlotWidth)
-					.attr('height', slotHeight * 0.6)
-					.attr('fill', detailColor)
-					.attr('rx', 1);
-
-			} else if (modelLower === 'mac mini') {
-				// Mac Mini - classic flat box with memory fill
-				iconBaseWidth = nodeRadius * 1.3;
-				iconBaseHeight = nodeRadius * 0.7;
-				const x = nodeInfo.x - iconBaseWidth / 2;
-				const y = nodeInfo.y - iconBaseHeight / 2;
-				const cornerRadius = 3;
-				const topSurfaceHeight = iconBaseHeight * 0.20;
-
-				// Create clip path for memory fill area
-				const miniClipId = `mini-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
-				defs.append('clipPath')
-					.attr('id', miniClipId)
-					.append('rect')
-					.attr('x', x)
-					.attr('y', y + topSurfaceHeight)
-					.attr('width', iconBaseWidth)
-					.attr('height', iconBaseHeight - topSurfaceHeight)
-					.attr('rx', cornerRadius - 1);
-
-				// Main body (uniform color)
-				nodeG.append('rect')
-					.attr('x', x)
-					.attr('y', y)
-					.attr('width', iconBaseWidth)
-					.attr('height', iconBaseHeight)
-					.attr('rx', cornerRadius)
-					.attr('fill', '#1a1a1a')
-					.attr('stroke', wireColor)
-					.attr('stroke-width', strokeWidth);
-
-				// Memory fill (fills from bottom up)
-				if (ramUsagePercent > 0) {
-					const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
-					const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
-					nodeG.append('rect')
-						.attr('x', x)
-						.attr('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight))
-						.attr('width', iconBaseWidth)
-						.attr('height', memFillActualHeight)
-						.attr('fill', 'rgba(255,215,0,0.75)')
-						.attr('clip-path', `url(#${miniClipId})`);
-				}
-
-				// Front panel details - vertical slots (no horizontal slot for Mini)
-				const detailColor = 'rgba(0,0,0,0.35)';
-				const slotHeight = iconBaseHeight * 0.20;
-				const vSlotWidth = iconBaseWidth * 0.045;
-				const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.45;
-				const vSlot1X = x + iconBaseWidth * 0.20;
-				const vSlot2X = x + iconBaseWidth * 0.30;
-
-				[vSlot1X, vSlot2X].forEach(vx => {
-					nodeG.append('rect')
-						.attr('x', vx - vSlotWidth / 2)
-						.attr('y', vSlotY)
-						.attr('width', vSlotWidth)
-						.attr('height', slotHeight)
-						.attr('fill', detailColor)
-						.attr('rx', 1.2);
-				});
-
-			} else if (modelLower === 'macbook pro' || modelLower.includes('macbook')) {
-				// MacBook Pro - classic style with memory fill on screen
-				iconBaseWidth = nodeRadius * 1.6;
-				iconBaseHeight = nodeRadius * 1.15;
-				const x = nodeInfo.x - iconBaseWidth / 2;
-				const y = nodeInfo.y - iconBaseHeight / 2;
-
-				const screenHeight = iconBaseHeight * 0.70;
-				const baseHeight = iconBaseHeight * 0.30;
-				const screenWidth = iconBaseWidth * 0.85;
-				const screenX = nodeInfo.x - screenWidth / 2;
-				const screenBezel = 3;
-
-				// Create clip path for screen content
-				const screenClipId = `screen-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
-				defs.append('clipPath')
-					.attr('id', screenClipId)
-					.append('rect')
-					.attr('x', screenX + screenBezel)
-					.attr('y', y + screenBezel)
-					.attr('width', screenWidth - screenBezel * 2)
-					.attr('height', screenHeight - screenBezel * 2)
-					.attr('rx', 2);
-
-				// Screen outer frame
-				nodeG.append('rect')
-					.attr('x', screenX)
-					.attr('y', y)
-					.attr('width', screenWidth)
-					.attr('height', screenHeight)
-					.attr('rx', 3)
-					.attr('fill', '#1a1a1a')
-					.attr('stroke', wireColor)
-					.attr('stroke-width', strokeWidth);
-
-				// Screen inner (dark background)
-				nodeG.append('rect')
-					.attr('x', screenX + screenBezel)
-					.attr('y', y + screenBezel)
-					.attr('width', screenWidth - screenBezel * 2)
-					.attr('height', screenHeight - screenBezel * 2)
-					.attr('rx', 2)
-					.attr('fill', '#0a0a12');
-
-				// Memory fill on screen (fills from bottom up - classic style)
-				if (ramUsagePercent > 0) {
-					const memFillTotalHeight = screenHeight - screenBezel * 2;
-					const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
-					nodeG.append('rect')
-						.attr('x', screenX + screenBezel)
-						.attr('y', y + screenBezel + (memFillTotalHeight - memFillActualHeight))
-						.attr('width', screenWidth - screenBezel * 2)
-						.attr('height', memFillActualHeight)
-						.attr('fill', 'rgba(255,215,0,0.85)')
-						.attr('clip-path', `url(#${screenClipId})`);
-				}
-
-				// Apple logo on screen (centered, on top of memory fill)
-				const targetLogoHeight = screenHeight * 0.22;
-				const logoScale = targetLogoHeight / LOGO_NATIVE_HEIGHT;
-				const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale / 2);
-				const logoY = y + screenHeight / 2 - (LOGO_NATIVE_HEIGHT * logoScale / 2);
-				nodeG.append('path')
-					.attr('d', APPLE_LOGO_PATH)
-					.attr('transform', `translate(${logoX}, ${logoY}) scale(${logoScale})`)
-					.attr('fill', '#FFFFFF')
-					.attr('opacity', 0.9);
-
-				// Base (keyboard) - trapezoidal
-				const baseY = y + screenHeight;
-				const baseTopWidth = screenWidth;
-				const baseBottomWidth = iconBaseWidth;
-				const baseTopX = nodeInfo.x - baseTopWidth / 2;
-				const baseBottomX = nodeInfo.x - baseBottomWidth / 2;
-
-				nodeG.append('path')
-					.attr('d', `M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`)
-					.attr('fill', '#2c2c2c')
-					.attr('stroke', wireColor)
-					.attr('stroke-width', 1);
-
-				// Keyboard area
-				const keyboardX = baseTopX + 6;
-				const keyboardY = baseY + 3;
-				const keyboardWidth = baseTopWidth - 12;
-				const keyboardHeight = baseHeight * 0.55;
-				nodeG.append('rect')
-					.attr('x', keyboardX)
-					.attr('y', keyboardY)
-					.attr('width', keyboardWidth)
-					.attr('height', keyboardHeight)
-					.attr('fill', 'rgba(0,0,0,0.2)')
-					.attr('rx', 2);
-
-				// Trackpad
-				const trackpadWidth = baseTopWidth * 0.4;
-				const trackpadX = nodeInfo.x - trackpadWidth / 2;
-				const trackpadY = baseY + keyboardHeight + 5;
-				const trackpadHeight = baseHeight * 0.30;
-				nodeG.append('rect')
-					.attr('x', trackpadX)
-					.attr('y', trackpadY)
-					.attr('width', trackpadWidth)
-					.attr('height', trackpadHeight)
-					.attr('fill', 'rgba(255,255,255,0.08)')
-					.attr('rx', 2);
-
-			} else {
-				// Default/Unknown - holographic hexagon
-				const hexRadius = nodeRadius * 0.6;
-				const hexPoints = Array.from({ length: 6 }, (_, i) => {
-					const angle = (i * 60 - 30) * Math.PI / 180;
-					return `${nodeInfo.x + hexRadius * Math.cos(angle)},${nodeInfo.y + hexRadius * Math.sin(angle)}`;
-				}).join(' ');
-
-				// Main shape
-				nodeG.append('polygon')
-					.attr('points', hexPoints)
-					.attr('fill', fillColor)
-					.attr('stroke', wireColor)
-					.attr('stroke-width', strokeWidth);
-			}
-
-			// --- Vertical GPU Bar (right side of icon) ---
-			// Show in both full mode and minimized mode (scaled appropriately)
-			if (showFullLabels || isMinimized) {
-				const gpuBarWidth = isMinimized ? Math.max(16, nodeRadius * 0.32) : Math.max(28, nodeRadius * 0.30);
-				const gpuBarHeight = iconBaseHeight * 0.95;
-				const barXOffset = iconBaseWidth / 2 + (isMinimized ? 5 : 10);
-				const gpuBarX = nodeInfo.x + barXOffset;
-				const gpuBarY = nodeInfo.y - gpuBarHeight / 2;
-
-				// GPU Bar Background (grey, no border)
-				nodeG.append('rect')
-					.attr('x', gpuBarX)
-					.attr('y', gpuBarY)
-					.attr('width', gpuBarWidth)
-					.attr('height', gpuBarHeight)
-					.attr('fill', 'rgba(80, 80, 90, 0.7)')
-					.attr('rx', 2);
-
-				// GPU Bar Fill (from bottom up, colored by temperature)
-				if (gpuUsagePercent > 0) {
-					const fillHeight = (gpuUsagePercent / 100) * gpuBarHeight;
-					const gpuFillColor = getTemperatureColor(gpuTemp);
-					nodeG.append('rect')
-						.attr('x', gpuBarX)
-						.attr('y', gpuBarY + (gpuBarHeight - fillHeight))
-						.attr('width', gpuBarWidth)
-						.attr('height', fillHeight)
-						.attr('fill', gpuFillColor)
-						.attr('opacity', 0.9)
-						.attr('rx', 2);
-				}
-
-				// GPU Stats Text (centered on bar, multiline, bigger and bold)
-				const gpuTextX = gpuBarX + gpuBarWidth / 2;
-				const gpuTextY = gpuBarY + gpuBarHeight / 2;
-				const gpuTextFontSize = isMinimized ? Math.max(10, gpuBarWidth * 0.6) : Math.min(16, Math.max(12, gpuBarWidth * 0.55));
-				const lineSpacing = gpuTextFontSize * 1.25;
-
-				const gpuUsageText = `${gpuUsagePercent.toFixed(0)}%`;
-				const tempText = !isNaN(gpuTemp) ? `${gpuTemp.toFixed(0)}°C` : '-';
-				const powerText = sysPower !== null ? `${sysPower.toFixed(0)}W` : '-';
-
-				// GPU Usage %
-				nodeG.append('text')
-					.attr('x', gpuTextX)
-					.attr('y', gpuTextY - lineSpacing)
-					.attr('text-anchor', 'middle')
-					.attr('dominant-baseline', 'middle')
-					.attr('fill', '#FFFFFF')
-					.attr('font-size', gpuTextFontSize)
-					.attr('font-weight', '700')
-					.attr('font-family', 'SF Mono, Monaco, monospace')
-					.text(gpuUsageText);
-
-				// Temperature
-				nodeG.append('text')
-					.attr('x', gpuTextX)
-					.attr('y', gpuTextY)
-					.attr('text-anchor', 'middle')
-					.attr('dominant-baseline', 'middle')
-					.attr('fill', '#FFFFFF')
-					.attr('font-size', gpuTextFontSize)
-					.attr('font-weight', '700')
-					.attr('font-family', 'SF Mono, Monaco, monospace')
-					.text(tempText);
-
-				// Power (Watts)
-				nodeG.append('text')
-					.attr('x', gpuTextX)
-					.attr('y', gpuTextY + lineSpacing)
-					.attr('text-anchor', 'middle')
-					.attr('dominant-baseline', 'middle')
-					.attr('fill', '#FFFFFF')
-					.attr('font-size', gpuTextFontSize)
-					.attr('font-weight', '700')
-					.attr('font-family', 'SF Mono, Monaco, monospace')
-					.text(powerText);
-			}
-
-			// Labels - adapt based on mode
-			if (showFullLabels) {
-				// FULL MODE: Name above, memory info below (1-4 nodes)
-				const nameY = nodeInfo.y - iconBaseHeight / 2 - 15;
-				const fontSize = Math.max(10, nodeRadius * 0.16);
-				
-				// Truncate name based on node count
-				const maxNameLen = numNodes === 1 ? 22 : (numNodes === 2 ? 18 : numNodes === 3 ? 16 : 14);
-				const displayName = friendlyName.length > maxNameLen 
-					? friendlyName.slice(0, maxNameLen - 2) + '..'
-					: friendlyName;
-				
-				// Name label above
-				nodeG.append('text')
-					.attr('x', nodeInfo.x)
-					.attr('y', nameY)
-					.attr('text-anchor', 'middle')
-					.attr('dominant-baseline', 'middle')
-					.attr('fill', '#FFD700')
-					.attr('font-size', fontSize)
-					.attr('font-weight', 500)
-					.attr('font-family', 'SF Mono, Monaco, monospace')
-					.text(displayName);
-
-				// Memory info below - used in grey, total in yellow
-				const infoY = nodeInfo.y + iconBaseHeight / 2 + 16;
-				const memText = nodeG.append('text')
-					.attr('x', nodeInfo.x)
-					.attr('y', infoY)
-					.attr('text-anchor', 'middle')
-					.attr('font-size', fontSize * 0.85)
-					.attr('font-family', 'SF Mono, Monaco, monospace');
-				memText.append('tspan')
-					.attr('fill', 'rgba(255,215,0,0.9)')
-					.text(`${formatBytes(ramUsed)}`);
-				memText.append('tspan')
-					.attr('fill', 'rgba(179,179,179,0.9)')
-					.text(`/${formatBytes(ramTotal)}`);
-				memText.append('tspan')
-					.attr('fill', 'rgba(179,179,179,0.7)')
-					.text(` (${ramUsagePercent.toFixed(0)}%)`);
-
-			} else if (showCompactLabels) {
-				// COMPACT MODE: Just name and basic info (4+ nodes)
-				const fontSize = Math.max(7, nodeRadius * 0.11);
-				
-				// Very compact name below icon
-				const nameY = nodeInfo.y + iconBaseHeight / 2 + 9;
-				const shortName = friendlyName.length > 10 
-					? friendlyName.slice(0, 8) + '..'
-					: friendlyName;
-				nodeG.append('text')
-					.attr('x', nodeInfo.x)
-					.attr('y', nameY)
-					.attr('text-anchor', 'middle')
-					.attr('fill', '#FFD700')
-					.attr('font-size', fontSize)
-					.attr('font-family', 'SF Mono, Monaco, monospace')
-					.text(shortName);
-
-				// Single line of key stats
-				const statsY = nameY + 9;
-				nodeG.append('text')
-					.attr('x', nodeInfo.x)
-					.attr('y', statsY)
-					.attr('text-anchor', 'middle')
-					.attr('fill', 'rgba(255,215,0,0.7)')
-					.attr('font-size', fontSize * 0.85)
-					.attr('font-family', 'SF Mono, Monaco, monospace')
-					.text(`${ramUsagePercent.toFixed(0)}%${!isNaN(gpuTemp) ? ' ' + gpuTemp.toFixed(0) + '°C' : ''}`);
-
-			} else {
-				// MINIMIZED MODE: Show name above and memory info below (like main topology)
-				const fontSize = 8;
-				
-				// Friendly name (shortened) above icon
-				const nameY = nodeInfo.y - iconBaseHeight / 2 - 8;
-				const shortName = friendlyName.length > 12 
-					? friendlyName.slice(0, 10) + '..'
-					: friendlyName;
-				nodeG.append('text')
-					.attr('x', nodeInfo.x)
-					.attr('y', nameY)
-					.attr('text-anchor', 'middle')
-					.attr('fill', '#FFD700')
-					.attr('font-size', fontSize)
-					.attr('font-weight', '500')
-					.attr('font-family', 'SF Mono, Monaco, monospace')
-					.text(shortName);
-
-				// Memory info below icon - used in grey, total in yellow (same as main topology)
-				const infoY = nodeInfo.y + iconBaseHeight / 2 + 10;
-				const memTextMini = nodeG.append('text')
-					.attr('x', nodeInfo.x)
-					.attr('y', infoY)
-					.attr('text-anchor', 'middle')
-					.attr('font-size', fontSize * 0.85)
-					.attr('font-family', 'SF Mono, Monaco, monospace');
-				memTextMini.append('tspan')
-					.attr('fill', 'rgba(255,215,0,0.9)')
-					.text(`${formatBytes(ramUsed)}`);
-				memTextMini.append('tspan')
-					.attr('fill', 'rgba(179,179,179,0.9)')
-					.text(`/${formatBytes(ramTotal)}`);
-				memTextMini.append('tspan')
-					.attr('fill', 'rgba(179,179,179,0.7)')
-					.text(` (${ramUsagePercent.toFixed(0)}%)`);
-			}
-		});
-
-	}
-
-	$effect(() => {
-		if (data) {
-			renderGraph();
-		}
-	});
-
-	onMount(() => {
-		if (svgContainer) {
-			resizeObserver = new ResizeObserver(() => {
-				renderGraph();
-			});
-			resizeObserver.observe(svgContainer);
-		}
-	});
-
-	onDestroy(() => {
-		resizeObserver?.disconnect();
-	});
+  import { onMount, onDestroy } from "svelte";
+  import * as d3 from "d3";
+  import {
+    topologyData,
+    isTopologyMinimized,
+    debugMode,
+    type NodeInfo,
+  } from "$lib/stores/app.svelte";
+
+  interface Props {
+    class?: string;
+    highlightedNodes?: Set<string>;
+  }
+
+  let { class: className = "", highlightedNodes = new Set() }: Props = $props();
+
+  let svgContainer: SVGSVGElement | undefined = $state();
+  let resizeObserver: ResizeObserver | undefined;
+
+  const isMinimized = $derived(isTopologyMinimized());
+  const data = $derived(topologyData());
+  const debugEnabled = $derived(debugMode());
+
+  function getNodeLabel(nodeId: string): string {
+    const node = data?.nodes?.[nodeId];
+    return node?.friendly_name || nodeId.slice(0, 8);
+  }
+
+  function getInterfaceLabel(
+    nodeId: string,
+    ip?: string,
+  ): { label: string; missing: boolean } {
+    if (!ip) return { label: "?", missing: true };
+
+    // Strip port if present (e.g., "192.168.1.1:8080" -> "192.168.1.1")
+    const cleanIp =
+      ip.includes(":") && !ip.includes("[") ? ip.split(":")[0] : ip;
+
+    // Helper to check a node's interfaces
+    function checkNode(node: NodeInfo | undefined): string | null {
+      if (!node) return null;
+
+      const matchFromInterfaces = node.network_interfaces?.find((iface) =>
+        (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip),
+      );
+      if (matchFromInterfaces?.name) {
+        return matchFromInterfaces.name;
+      }
+
+      if (node.ip_to_interface) {
+        const mapped =
+          node.ip_to_interface[cleanIp] ||
+          (ip ? node.ip_to_interface[ip] : undefined);
+        if (mapped && mapped.trim().length > 0) {
+          return mapped;
+        }
+      }
+      return null;
+    }
+
+    // Try specified node first
+    const result = checkNode(data?.nodes?.[nodeId]);
+    if (result) return { label: result, missing: false };
+
+    // Fallback: search all nodes for this IP
+    for (const [, otherNode] of Object.entries(data?.nodes || {})) {
+      const otherResult = checkNode(otherNode);
+      if (otherResult) return { label: otherResult, missing: false };
+    }
+
+    return { label: "?", missing: true };
+  }
+
+  function wrapLine(text: string, maxLen: number): string[] {
+    if (text.length <= maxLen) return [text];
+    const words = text.split(" ");
+    const lines: string[] = [];
+    let current = "";
+    for (const word of words) {
+      if (word.length > maxLen) {
+        if (current) {
+          lines.push(current);
+          current = "";
+        }
+        for (let i = 0; i < word.length; i += maxLen) {
+          lines.push(word.slice(i, i + maxLen));
+        }
+      } else if ((current + " " + word).trim().length > maxLen) {
+        lines.push(current);
+        current = word;
+      } else {
+        current = current ? `${current} ${word}` : word;
+      }
+    }
+    if (current) lines.push(current);
+    return lines;
+  }
+
+  // Apple logo path for MacBook Pro screen
+  const APPLE_LOGO_PATH =
+    "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z";
+  const LOGO_NATIVE_WIDTH = 814;
+  const LOGO_NATIVE_HEIGHT = 1000;
+
+  function formatBytes(bytes: number, decimals = 1): string {
+    if (!bytes || bytes === 0) return "0B";
+    const k = 1024;
+    const sizes = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
+  }
+
+  function getTemperatureColor(temp: number): string {
+    // Default for N/A temp - light gray
+    if (isNaN(temp) || temp === null) return "rgba(179, 179, 179, 0.8)";
+
+    const coolTemp = 45; // Temp for pure blue
+    const midTemp = 57.5; // Temp for pure yellow
+    const hotTemp = 75; // Temp for pure red
+
+    const coolColor = { r: 93, g: 173, b: 226 }; // #5DADE2 (Blue)
+    const midColor = { r: 255, g: 215, b: 0 }; // #FFD700 (Yellow)
+    const hotColor = { r: 244, g: 67, b: 54 }; // #F44336 (Red)
+
+    let r: number, g: number, b: number;
+
+    if (temp <= coolTemp) {
+      ({ r, g, b } = coolColor);
+    } else if (temp <= midTemp) {
+      const ratio = (temp - coolTemp) / (midTemp - coolTemp);
+      r = Math.round(coolColor.r * (1 - ratio) + midColor.r * ratio);
+      g = Math.round(coolColor.g * (1 - ratio) + midColor.g * ratio);
+      b = Math.round(coolColor.b * (1 - ratio) + midColor.b * ratio);
+    } else if (temp < hotTemp) {
+      const ratio = (temp - midTemp) / (hotTemp - midTemp);
+      r = Math.round(midColor.r * (1 - ratio) + hotColor.r * ratio);
+      g = Math.round(midColor.g * (1 - ratio) + hotColor.g * ratio);
+      b = Math.round(midColor.b * (1 - ratio) + hotColor.b * ratio);
+    } else {
+      ({ r, g, b } = hotColor);
+    }
+
+    return `rgb(${r}, ${g}, ${b})`;
+  }
+
+  function renderGraph() {
+    if (!svgContainer || !data) return;
+
+    d3.select(svgContainer).selectAll("*").remove();
+
+    const nodes = data.nodes || {};
+    const edges = data.edges || [];
+    const nodeIds = Object.keys(nodes);
+
+    const rect = svgContainer.getBoundingClientRect();
+    const width = rect.width;
+    const height = rect.height;
+    const centerX = width / 2;
+    const centerY = height / 2;
+
+    const svg = d3.select(svgContainer);
+
+    // Add defs for clip paths and filters
+    const defs = svg.append("defs");
+
+    // Glow filter
+    const glowFilter = defs
+      .append("filter")
+      .attr("id", "glow")
+      .attr("x", "-50%")
+      .attr("y", "-50%")
+      .attr("width", "200%")
+      .attr("height", "200%");
+    glowFilter
+      .append("feGaussianBlur")
+      .attr("stdDeviation", "2")
+      .attr("result", "coloredBlur");
+    const glowMerge = glowFilter.append("feMerge");
+    glowMerge.append("feMergeNode").attr("in", "coloredBlur");
+    glowMerge.append("feMergeNode").attr("in", "SourceGraphic");
+
+    // Arrowhead marker for directional edges
+    const marker = defs
+      .append("marker")
+      .attr("id", "arrowhead")
+      .attr("viewBox", "0 0 10 10")
+      .attr("refX", "10")
+      .attr("refY", "5")
+      .attr("markerWidth", "11")
+      .attr("markerHeight", "11")
+      .attr("orient", "auto-start-reverse");
+    marker
+      .append("path")
+      .attr("d", "M 0 0 L 10 5 L 0 10")
+      .attr("fill", "none")
+      .attr("stroke", "var(--exo-light-gray, #B3B3B3)")
+      .attr("stroke-width", "1.6")
+      .attr("stroke-linecap", "round")
+      .attr("stroke-linejoin", "round")
+      .style("animation", "none");
+
+    if (nodeIds.length === 0) {
+      svg
+        .append("text")
+        .attr("x", centerX)
+        .attr("y", centerY)
+        .attr("text-anchor", "middle")
+        .attr("dominant-baseline", "middle")
+        .attr("fill", "rgba(255,215,0,0.4)")
+        .attr("font-size", isMinimized ? 10 : 12)
+        .attr("font-family", "SF Mono, monospace")
+        .attr("letter-spacing", "0.1em")
+        .text("AWAITING NODES");
+      return;
+    }
+
+    const numNodes = nodeIds.length;
+    const minDimension = Math.min(width, height);
+
+    // Dynamic scaling - larger nodes for big displays
+    const sizeScale =
+      numNodes === 1 ? 1 : Math.max(0.6, 1 - (numNodes - 1) * 0.1);
+    const baseNodeRadius = isMinimized
+      ? Math.max(36, Math.min(60, minDimension * 0.22))
+      : Math.min(120, minDimension * 0.2);
+    const nodeRadius = baseNodeRadius * sizeScale;
+
+    // Orbit radius - balanced spacing for nodes
+    const circumference = numNodes * nodeRadius * 4;
+    const radiusFromCircumference = circumference / (2 * Math.PI);
+    const minOrbitRadius = Math.max(
+      radiusFromCircumference,
+      minDimension * 0.18,
+    );
+    const maxOrbitRadius = minDimension * 0.3;
+    const orbitRadius = isMinimized
+      ? Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * 0.26))
+      : Math.min(
+          maxOrbitRadius,
+          Math.max(minOrbitRadius, minDimension * (0.22 + numNodes * 0.02)),
+        );
+
+    // Determine display mode based on space and node count
+    const showFullLabels = !isMinimized && numNodes <= 4;
+    const showCompactLabels = !isMinimized && numNodes > 4;
+
+    // Add padding for labels (top/bottom)
+    const topPadding = 70; // Space for "NETWORK TOPOLOGY" label and node names
+    const bottomPadding = 70; // Space for stats and bottom label
+    const safeCenterY = topPadding + (height - topPadding - bottomPadding) / 2;
+
+    // Calculate node positions
+    const nodesWithPositions = nodeIds.map((id, index) => {
+      if (numNodes === 1) {
+        // Single node: center it
+        return {
+          id,
+          data: nodes[id],
+          x: centerX,
+          y: safeCenterY,
+        };
+      }
+      // Distribute nodes around the orbit
+      // Start from top (-90 degrees) and go clockwise
+      const angle = (index / numNodes) * 2 * Math.PI - Math.PI / 2;
+      return {
+        id,
+        data: nodes[id],
+        x: centerX + orbitRadius * Math.cos(angle),
+        y: safeCenterY + orbitRadius * Math.sin(angle),
+      };
+    });
+
+    const positionById: Record<string, { x: number; y: number }> = {};
+    nodesWithPositions.forEach((n) => {
+      positionById[n.id] = { x: n.x, y: n.y };
+    });
+
+    // Draw edges
+    const linksGroup = svg.append("g").attr("class", "links-group");
+    const arrowsGroup = svg.append("g").attr("class", "arrows-group");
+    const debugLabelsGroup = svg.append("g").attr("class", "debug-edge-labels");
+
+    type ConnectionInfo = {
+      from: string;
+      to: string;
+      ip: string;
+      ifaceLabel: string;
+      missingIface: boolean;
+    };
+    type PairEntry = {
+      a: string;
+      b: string;
+      aToB: boolean;
+      bToA: boolean;
+      connections: ConnectionInfo[];
+    };
+    type DebugEdgeLabelEntry = {
+      connections: ConnectionInfo[];
+      isLeft: boolean;
+      isTop: boolean;
+      mx: number;
+      my: number;
+    };
+    const pairMap = new Map<string, PairEntry>();
+    const debugEdgeLabels: DebugEdgeLabelEntry[] = [];
+    edges.forEach((edge) => {
+      if (!edge.source || !edge.target || edge.source === edge.target) return;
+      if (!positionById[edge.source] || !positionById[edge.target]) return;
+
+      const a = edge.source < edge.target ? edge.source : edge.target;
+      const b = edge.source < edge.target ? edge.target : edge.source;
+      const key = `${a}|${b}`;
+      const entry = pairMap.get(key) || {
+        a,
+        b,
+        aToB: false,
+        bToA: false,
+        connections: [],
+      };
+
+      if (edge.source === a) entry.aToB = true;
+      else entry.bToA = true;
+
+      const ip = edge.sendBackIp || "?";
+      const ifaceInfo = getInterfaceLabel(edge.source, ip);
+      entry.connections.push({
+        from: edge.source,
+        to: edge.target,
+        ip,
+        ifaceLabel: ifaceInfo.label,
+        missingIface: ifaceInfo.missing,
+      });
+      pairMap.set(key, entry);
+    });
+
+    pairMap.forEach((entry) => {
+      const posA = positionById[entry.a];
+      const posB = positionById[entry.b];
+      if (!posA || !posB) return;
+
+      // Base dashed line
+      linksGroup
+        .append("line")
+        .attr("x1", posA.x)
+        .attr("y1", posA.y)
+        .attr("x2", posB.x)
+        .attr("y2", posB.y)
+        .attr("class", "graph-link");
+
+      // Calculate midpoint and direction for arrows
+      const dx = posB.x - posA.x;
+      const dy = posB.y - posA.y;
+      const len = Math.hypot(dx, dy) || 1;
+      const ux = dx / len;
+      const uy = dy / len;
+      const mx = (posA.x + posB.x) / 2;
+      const my = (posA.y + posB.y) / 2;
+      const tipOffset = 16; // Distance from center for arrow tips
+      const carrier = 2; // Short segment length for arrow orientation
+
+      // Arrow A -> B (if connection exists in that direction)
+      if (entry.aToB) {
+        const tipX = mx - ux * tipOffset;
+        const tipY = my - uy * tipOffset;
+        arrowsGroup
+          .append("line")
+          .attr("x1", tipX - ux * carrier)
+          .attr("y1", tipY - uy * carrier)
+          .attr("x2", tipX)
+          .attr("y2", tipY)
+          .attr("stroke", "none")
+          .attr("fill", "none")
+          .attr("marker-end", "url(#arrowhead)");
+      }
+
+      // Arrow B -> A (if connection exists in that direction)
+      if (entry.bToA) {
+        const tipX = mx + ux * tipOffset;
+        const tipY = my + uy * tipOffset;
+        arrowsGroup
+          .append("line")
+          .attr("x1", tipX + ux * carrier)
+          .attr("y1", tipY + uy * carrier)
+          .attr("x2", tipX)
+          .attr("y2", tipY)
+          .attr("stroke", "none")
+          .attr("fill", "none")
+          .attr("marker-end", "url(#arrowhead)");
+      }
+
+      // Collect debug labels for later positioning at edges
+      if (debugEnabled && entry.connections.length > 0) {
+        // Determine which side of viewport based on edge midpoint
+        const isLeft = mx < centerX;
+        const isTop = my < safeCenterY;
+
+        // Store for batch rendering after all edges processed
+        debugEdgeLabels.push({
+          connections: entry.connections,
+          isLeft,
+          isTop,
+          mx,
+          my,
+        });
+      }
+    });
+
+    // Render debug labels at viewport edges/corners
+    if (debugEdgeLabels && debugEdgeLabels.length > 0) {
+      const fontSize = isMinimized ? 10 : 12;
+      const lineHeight = fontSize + 4;
+      const padding = 10;
+
+      // Helper to get arrow based on direction vector
+      function getArrow(fromId: string, toId: string): string {
+        const fromPos = positionById[fromId];
+        const toPos = positionById[toId];
+        if (!fromPos || !toPos) return "→";
+
+        const dirX = toPos.x - fromPos.x;
+        const dirY = toPos.y - fromPos.y;
+        const absX = Math.abs(dirX);
+        const absY = Math.abs(dirY);
+
+        if (absX > absY * 2) {
+          return dirX > 0 ? "→" : "←";
+        } else if (absY > absX * 2) {
+          return dirY > 0 ? "↓" : "↑";
+        } else {
+          if (dirX > 0 && dirY > 0) return "↘";
+          if (dirX > 0 && dirY < 0) return "↗";
+          if (dirX < 0 && dirY > 0) return "↙";
+          return "↖";
+        }
+      }
+
+      // Group by quadrant: topLeft, topRight, bottomLeft, bottomRight
+      const quadrants: Record<string, DebugEdgeLabelEntry[]> = {
+        topLeft: [],
+        topRight: [],
+        bottomLeft: [],
+        bottomRight: [],
+      };
+
+      debugEdgeLabels.forEach((edge) => {
+        const key =
+          (edge.isTop ? "top" : "bottom") + (edge.isLeft ? "Left" : "Right");
+        quadrants[key].push(edge);
+      });
+
+      // Render each quadrant
+      Object.entries(quadrants).forEach(([quadrant, quadrantEdges]) => {
+        if (quadrantEdges.length === 0) return;
+
+        const isLeft = quadrant.includes("Left");
+        const isTop = quadrant.includes("top");
+
+        let baseX = isLeft ? padding : width - padding;
+        let baseY = isTop ? padding : height - padding;
+        const textAnchor = isLeft ? "start" : "end";
+
+        let currentY = baseY;
+
+        quadrantEdges.forEach((edge) => {
+          edge.connections.forEach((conn) => {
+            const arrow = getArrow(conn.from, conn.to);
+            const label = `${arrow} ${conn.ip} ${conn.ifaceLabel}`;
+            debugLabelsGroup
+              .append("text")
+              .attr("x", baseX)
+              .attr("y", currentY)
+              .attr("text-anchor", textAnchor)
+              .attr("dominant-baseline", isTop ? "hanging" : "auto")
+              .attr("font-size", fontSize)
+              .attr("font-family", "SF Mono, monospace")
+              .attr(
+                "fill",
+                conn.missingIface
+                  ? "rgba(248,113,113,0.9)"
+                  : "rgba(255,255,255,0.85)",
+              )
+              .text(label);
+            currentY += isTop ? lineHeight : -lineHeight;
+          });
+        });
+      });
+    }
+
+    // Draw nodes
+    const nodesGroup = svg.append("g").attr("class", "nodes-group");
+
+    nodesWithPositions.forEach((nodeInfo) => {
+      const node = nodeInfo.data;
+      const macmon = node.macmon_info;
+      const modelId = node.system_info?.model_id || "Unknown";
+      const friendlyName = node.friendly_name || modelId;
+
+      let ramUsagePercent = 0;
+      let gpuTemp = NaN;
+      let ramTotal = 0;
+      let ramUsed = 0;
+      let gpuUsagePercent = 0;
+      let sysPower: number | null = null;
+
+      if (macmon) {
+        if (macmon.memory && macmon.memory.ram_total > 0) {
+          ramUsagePercent =
+            (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
+          ramTotal = macmon.memory.ram_total;
+          ramUsed = macmon.memory.ram_usage;
+        }
+        if (macmon.temp && typeof macmon.temp.gpu_temp_avg === "number") {
+          gpuTemp = Math.max(30, macmon.temp.gpu_temp_avg);
+        }
+        if (macmon.gpu_usage) {
+          gpuUsagePercent = macmon.gpu_usage[1] * 100;
+        }
+        if (macmon.sys_power) {
+          sysPower = macmon.sys_power;
+        }
+      }
+
+      const nodeG = nodesGroup
+        .append("g")
+        .attr("class", "graph-node")
+        .style("cursor", "pointer");
+
+      // Add tooltip
+      nodeG
+        .append("title")
+        .text(
+          `${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`,
+        );
+
+      let iconBaseWidth = nodeRadius * 1.2;
+      let iconBaseHeight = nodeRadius * 1.0;
+      const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
+
+      const modelLower = modelId.toLowerCase();
+
+      // Check if this node should be highlighted (from hovered instance)
+      const isHighlighted = highlightedNodes.has(nodeInfo.id);
+
+      // Holographic wireframe colors - yellow border when highlighted
+      const wireColor = isHighlighted
+        ? "rgba(255,215,0,0.9)"
+        : "rgba(179,179,179,0.8)";
+      const wireColorBright = "rgba(255,255,255,0.9)";
+      const fillColor = isHighlighted
+        ? "rgba(255,215,0,0.15)"
+        : "rgba(255,215,0,0.08)";
+      const strokeWidth = isHighlighted ? 2.5 : 1.5;
+      const screenFill = "rgba(0,20,40,0.9)";
+      const glowColor = "rgba(255,215,0,0.3)";
+
+      if (modelLower === "mac studio") {
+        // Mac Studio - classic cube with memory fill
+        iconBaseWidth = nodeRadius * 1.25;
+        iconBaseHeight = nodeRadius * 0.85;
+        const x = nodeInfo.x - iconBaseWidth / 2;
+        const y = nodeInfo.y - iconBaseHeight / 2;
+        const cornerRadius = 4;
+        const topSurfaceHeight = iconBaseHeight * 0.15;
+
+        // Create clip path for memory fill area (front body)
+        const studioClipId = `studio-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
+        defs
+          .append("clipPath")
+          .attr("id", studioClipId)
+          .append("rect")
+          .attr("x", x)
+          .attr("y", y + topSurfaceHeight)
+          .attr("width", iconBaseWidth)
+          .attr("height", iconBaseHeight - topSurfaceHeight)
+          .attr("rx", cornerRadius - 1);
+
+        // Main body (uniform color)
+        nodeG
+          .append("rect")
+          .attr("x", x)
+          .attr("y", y)
+          .attr("width", iconBaseWidth)
+          .attr("height", iconBaseHeight)
+          .attr("rx", cornerRadius)
+          .attr("fill", "#1a1a1a")
+          .attr("stroke", wireColor)
+          .attr("stroke-width", strokeWidth);
+
+        // Memory fill (fills from bottom up)
+        if (ramUsagePercent > 0) {
+          const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
+          const memFillActualHeight =
+            (ramUsagePercent / 100) * memFillTotalHeight;
+          nodeG
+            .append("rect")
+            .attr("x", x)
+            .attr(
+              "y",
+              y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight),
+            )
+            .attr("width", iconBaseWidth)
+            .attr("height", memFillActualHeight)
+            .attr("fill", "rgba(255,215,0,0.75)")
+            .attr("clip-path", `url(#${studioClipId})`);
+        }
+
+        // Front panel details - vertical slots
+        const detailColor = "rgba(0,0,0,0.35)";
+        const slotHeight = iconBaseHeight * 0.14;
+        const vSlotWidth = iconBaseWidth * 0.05;
+        const vSlotY =
+          y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.6;
+        const vSlot1X = x + iconBaseWidth * 0.18;
+        const vSlot2X = x + iconBaseWidth * 0.28;
+
+        [vSlot1X, vSlot2X].forEach((vx) => {
+          nodeG
+            .append("rect")
+            .attr("x", vx - vSlotWidth / 2)
+            .attr("y", vSlotY)
+            .attr("width", vSlotWidth)
+            .attr("height", slotHeight)
+            .attr("fill", detailColor)
+            .attr("rx", 1.5);
+        });
+
+        // Horizontal slot (SD card)
+        const hSlotWidth = iconBaseWidth * 0.2;
+        const hSlotX = x + iconBaseWidth * 0.5 - hSlotWidth / 2;
+        nodeG
+          .append("rect")
+          .attr("x", hSlotX)
+          .attr("y", vSlotY)
+          .attr("width", hSlotWidth)
+          .attr("height", slotHeight * 0.6)
+          .attr("fill", detailColor)
+          .attr("rx", 1);
+      } else if (modelLower === "mac mini") {
+        // Mac Mini - classic flat box with memory fill
+        iconBaseWidth = nodeRadius * 1.3;
+        iconBaseHeight = nodeRadius * 0.7;
+        const x = nodeInfo.x - iconBaseWidth / 2;
+        const y = nodeInfo.y - iconBaseHeight / 2;
+        const cornerRadius = 3;
+        const topSurfaceHeight = iconBaseHeight * 0.2;
+
+        // Create clip path for memory fill area
+        const miniClipId = `mini-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
+        defs
+          .append("clipPath")
+          .attr("id", miniClipId)
+          .append("rect")
+          .attr("x", x)
+          .attr("y", y + topSurfaceHeight)
+          .attr("width", iconBaseWidth)
+          .attr("height", iconBaseHeight - topSurfaceHeight)
+          .attr("rx", cornerRadius - 1);
+
+        // Main body (uniform color)
+        nodeG
+          .append("rect")
+          .attr("x", x)
+          .attr("y", y)
+          .attr("width", iconBaseWidth)
+          .attr("height", iconBaseHeight)
+          .attr("rx", cornerRadius)
+          .attr("fill", "#1a1a1a")
+          .attr("stroke", wireColor)
+          .attr("stroke-width", strokeWidth);
+
+        // Memory fill (fills from bottom up)
+        if (ramUsagePercent > 0) {
+          const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
+          const memFillActualHeight =
+            (ramUsagePercent / 100) * memFillTotalHeight;
+          nodeG
+            .append("rect")
+            .attr("x", x)
+            .attr(
+              "y",
+              y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight),
+            )
+            .attr("width", iconBaseWidth)
+            .attr("height", memFillActualHeight)
+            .attr("fill", "rgba(255,215,0,0.75)")
+            .attr("clip-path", `url(#${miniClipId})`);
+        }
+
+        // Front panel details - vertical slots (no horizontal slot for Mini)
+        const detailColor = "rgba(0,0,0,0.35)";
+        const slotHeight = iconBaseHeight * 0.2;
+        const vSlotWidth = iconBaseWidth * 0.045;
+        const vSlotY =
+          y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.45;
+        const vSlot1X = x + iconBaseWidth * 0.2;
+        const vSlot2X = x + iconBaseWidth * 0.3;
+
+        [vSlot1X, vSlot2X].forEach((vx) => {
+          nodeG
+            .append("rect")
+            .attr("x", vx - vSlotWidth / 2)
+            .attr("y", vSlotY)
+            .attr("width", vSlotWidth)
+            .attr("height", slotHeight)
+            .attr("fill", detailColor)
+            .attr("rx", 1.2);
+        });
+      } else if (
+        modelLower === "macbook pro" ||
+        modelLower.includes("macbook")
+      ) {
+        // MacBook Pro - classic style with memory fill on screen
+        iconBaseWidth = nodeRadius * 1.6;
+        iconBaseHeight = nodeRadius * 1.15;
+        const x = nodeInfo.x - iconBaseWidth / 2;
+        const y = nodeInfo.y - iconBaseHeight / 2;
+
+        const screenHeight = iconBaseHeight * 0.7;
+        const baseHeight = iconBaseHeight * 0.3;
+        const screenWidth = iconBaseWidth * 0.85;
+        const screenX = nodeInfo.x - screenWidth / 2;
+        const screenBezel = 3;
+
+        // Create clip path for screen content
+        const screenClipId = `screen-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
+        defs
+          .append("clipPath")
+          .attr("id", screenClipId)
+          .append("rect")
+          .attr("x", screenX + screenBezel)
+          .attr("y", y + screenBezel)
+          .attr("width", screenWidth - screenBezel * 2)
+          .attr("height", screenHeight - screenBezel * 2)
+          .attr("rx", 2);
+
+        // Screen outer frame
+        nodeG
+          .append("rect")
+          .attr("x", screenX)
+          .attr("y", y)
+          .attr("width", screenWidth)
+          .attr("height", screenHeight)
+          .attr("rx", 3)
+          .attr("fill", "#1a1a1a")
+          .attr("stroke", wireColor)
+          .attr("stroke-width", strokeWidth);
+
+        // Screen inner (dark background)
+        nodeG
+          .append("rect")
+          .attr("x", screenX + screenBezel)
+          .attr("y", y + screenBezel)
+          .attr("width", screenWidth - screenBezel * 2)
+          .attr("height", screenHeight - screenBezel * 2)
+          .attr("rx", 2)
+          .attr("fill", "#0a0a12");
+
+        // Memory fill on screen (fills from bottom up - classic style)
+        if (ramUsagePercent > 0) {
+          const memFillTotalHeight = screenHeight - screenBezel * 2;
+          const memFillActualHeight =
+            (ramUsagePercent / 100) * memFillTotalHeight;
+          nodeG
+            .append("rect")
+            .attr("x", screenX + screenBezel)
+            .attr(
+              "y",
+              y + screenBezel + (memFillTotalHeight - memFillActualHeight),
+            )
+            .attr("width", screenWidth - screenBezel * 2)
+            .attr("height", memFillActualHeight)
+            .attr("fill", "rgba(255,215,0,0.85)")
+            .attr("clip-path", `url(#${screenClipId})`);
+        }
+
+        // Apple logo on screen (centered, on top of memory fill)
+        const targetLogoHeight = screenHeight * 0.22;
+        const logoScale = targetLogoHeight / LOGO_NATIVE_HEIGHT;
+        const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale) / 2;
+        const logoY =
+          y + screenHeight / 2 - (LOGO_NATIVE_HEIGHT * logoScale) / 2;
+        nodeG
+          .append("path")
+          .attr("d", APPLE_LOGO_PATH)
+          .attr(
+            "transform",
+            `translate(${logoX}, ${logoY}) scale(${logoScale})`,
+          )
+          .attr("fill", "#FFFFFF")
+          .attr("opacity", 0.9);
+
+        // Base (keyboard) - trapezoidal
+        const baseY = y + screenHeight;
+        const baseTopWidth = screenWidth;
+        const baseBottomWidth = iconBaseWidth;
+        const baseTopX = nodeInfo.x - baseTopWidth / 2;
+        const baseBottomX = nodeInfo.x - baseBottomWidth / 2;
+
+        nodeG
+          .append("path")
+          .attr(
+            "d",
+            `M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`,
+          )
+          .attr("fill", "#2c2c2c")
+          .attr("stroke", wireColor)
+          .attr("stroke-width", 1);
+
+        // Keyboard area
+        const keyboardX = baseTopX + 6;
+        const keyboardY = baseY + 3;
+        const keyboardWidth = baseTopWidth - 12;
+        const keyboardHeight = baseHeight * 0.55;
+        nodeG
+          .append("rect")
+          .attr("x", keyboardX)
+          .attr("y", keyboardY)
+          .attr("width", keyboardWidth)
+          .attr("height", keyboardHeight)
+          .attr("fill", "rgba(0,0,0,0.2)")
+          .attr("rx", 2);
+
+        // Trackpad
+        const trackpadWidth = baseTopWidth * 0.4;
+        const trackpadX = nodeInfo.x - trackpadWidth / 2;
+        const trackpadY = baseY + keyboardHeight + 5;
+        const trackpadHeight = baseHeight * 0.3;
+        nodeG
+          .append("rect")
+          .attr("x", trackpadX)
+          .attr("y", trackpadY)
+          .attr("width", trackpadWidth)
+          .attr("height", trackpadHeight)
+          .attr("fill", "rgba(255,255,255,0.08)")
+          .attr("rx", 2);
+      } else {
+        // Default/Unknown - holographic hexagon
+        const hexRadius = nodeRadius * 0.6;
+        const hexPoints = Array.from({ length: 6 }, (_, i) => {
+          const angle = ((i * 60 - 30) * Math.PI) / 180;
+          return `${nodeInfo.x + hexRadius * Math.cos(angle)},${nodeInfo.y + hexRadius * Math.sin(angle)}`;
+        }).join(" ");
+
+        // Main shape
+        nodeG
+          .append("polygon")
+          .attr("points", hexPoints)
+          .attr("fill", fillColor)
+          .attr("stroke", wireColor)
+          .attr("stroke-width", strokeWidth);
+      }
+
+      // --- Vertical GPU Bar (right side of icon) ---
+      // Show in both full mode and minimized mode (scaled appropriately)
+      if (showFullLabels || isMinimized) {
+        const gpuBarWidth = isMinimized
+          ? Math.max(16, nodeRadius * 0.32)
+          : Math.max(28, nodeRadius * 0.3);
+        const gpuBarHeight = iconBaseHeight * 0.95;
+        const barXOffset = iconBaseWidth / 2 + (isMinimized ? 5 : 10);
+        const gpuBarX = nodeInfo.x + barXOffset;
+        const gpuBarY = nodeInfo.y - gpuBarHeight / 2;
+
+        // GPU Bar Background (grey, no border)
+        nodeG
+          .append("rect")
+          .attr("x", gpuBarX)
+          .attr("y", gpuBarY)
+          .attr("width", gpuBarWidth)
+          .attr("height", gpuBarHeight)
+          .attr("fill", "rgba(80, 80, 90, 0.7)")
+          .attr("rx", 2);
+
+        // GPU Bar Fill (from bottom up, colored by temperature)
+        if (gpuUsagePercent > 0) {
+          const fillHeight = (gpuUsagePercent / 100) * gpuBarHeight;
+          const gpuFillColor = getTemperatureColor(gpuTemp);
+          nodeG
+            .append("rect")
+            .attr("x", gpuBarX)
+            .attr("y", gpuBarY + (gpuBarHeight - fillHeight))
+            .attr("width", gpuBarWidth)
+            .attr("height", fillHeight)
+            .attr("fill", gpuFillColor)
+            .attr("opacity", 0.9)
+            .attr("rx", 2);
+        }
+
+        // GPU Stats Text (centered on bar, multiline, bigger and bold)
+        const gpuTextX = gpuBarX + gpuBarWidth / 2;
+        const gpuTextY = gpuBarY + gpuBarHeight / 2;
+        const gpuTextFontSize = isMinimized
+          ? Math.max(10, gpuBarWidth * 0.6)
+          : Math.min(16, Math.max(12, gpuBarWidth * 0.55));
+        const lineSpacing = gpuTextFontSize * 1.25;
+
+        const gpuUsageText = `${gpuUsagePercent.toFixed(0)}%`;
+        const tempText = !isNaN(gpuTemp) ? `${gpuTemp.toFixed(0)}°C` : "-";
+        const powerText = sysPower !== null ? `${sysPower.toFixed(0)}W` : "-";
+
+        // GPU Usage %
+        nodeG
+          .append("text")
+          .attr("x", gpuTextX)
+          .attr("y", gpuTextY - lineSpacing)
+          .attr("text-anchor", "middle")
+          .attr("dominant-baseline", "middle")
+          .attr("fill", "#FFFFFF")
+          .attr("font-size", gpuTextFontSize)
+          .attr("font-weight", "700")
+          .attr("font-family", "SF Mono, Monaco, monospace")
+          .text(gpuUsageText);
+
+        // Temperature
+        nodeG
+          .append("text")
+          .attr("x", gpuTextX)
+          .attr("y", gpuTextY)
+          .attr("text-anchor", "middle")
+          .attr("dominant-baseline", "middle")
+          .attr("fill", "#FFFFFF")
+          .attr("font-size", gpuTextFontSize)
+          .attr("font-weight", "700")
+          .attr("font-family", "SF Mono, Monaco, monospace")
+          .text(tempText);
+
+        // Power (Watts)
+        nodeG
+          .append("text")
+          .attr("x", gpuTextX)
+          .attr("y", gpuTextY + lineSpacing)
+          .attr("text-anchor", "middle")
+          .attr("dominant-baseline", "middle")
+          .attr("fill", "#FFFFFF")
+          .attr("font-size", gpuTextFontSize)
+          .attr("font-weight", "700")
+          .attr("font-family", "SF Mono, Monaco, monospace")
+          .text(powerText);
+      }
+
+      // Labels - adapt based on mode
+      if (showFullLabels) {
+        // FULL MODE: Name above, memory info below (1-4 nodes)
+        const nameY = nodeInfo.y - iconBaseHeight / 2 - 15;
+        const fontSize = Math.max(10, nodeRadius * 0.16);
+
+        // Truncate name based on node count
+        const maxNameLen =
+          numNodes === 1 ? 22 : numNodes === 2 ? 18 : numNodes === 3 ? 16 : 14;
+        const displayName =
+          friendlyName.length > maxNameLen
+            ? friendlyName.slice(0, maxNameLen - 2) + ".."
+            : friendlyName;
+
+        // Name label above
+        nodeG
+          .append("text")
+          .attr("x", nodeInfo.x)
+          .attr("y", nameY)
+          .attr("text-anchor", "middle")
+          .attr("dominant-baseline", "middle")
+          .attr("fill", "#FFD700")
+          .attr("font-size", fontSize)
+          .attr("font-weight", 500)
+          .attr("font-family", "SF Mono, Monaco, monospace")
+          .text(displayName);
+
+        // Memory info below - used in grey, total in yellow
+        const infoY = nodeInfo.y + iconBaseHeight / 2 + 16;
+        const memText = nodeG
+          .append("text")
+          .attr("x", nodeInfo.x)
+          .attr("y", infoY)
+          .attr("text-anchor", "middle")
+          .attr("font-size", fontSize * 0.85)
+          .attr("font-family", "SF Mono, Monaco, monospace");
+        memText
+          .append("tspan")
+          .attr("fill", "rgba(255,215,0,0.9)")
+          .text(`${formatBytes(ramUsed)}`);
+        memText
+          .append("tspan")
+          .attr("fill", "rgba(179,179,179,0.9)")
+          .text(`/${formatBytes(ramTotal)}`);
+        memText
+          .append("tspan")
+          .attr("fill", "rgba(179,179,179,0.7)")
+          .text(` (${ramUsagePercent.toFixed(0)}%)`);
+      } else if (showCompactLabels) {
+        // COMPACT MODE: Just name and basic info (4+ nodes)
+        const fontSize = Math.max(7, nodeRadius * 0.11);
+
+        // Very compact name below icon
+        const nameY = nodeInfo.y + iconBaseHeight / 2 + 9;
+        const shortName =
+          friendlyName.length > 10
+            ? friendlyName.slice(0, 8) + ".."
+            : friendlyName;
+        nodeG
+          .append("text")
+          .attr("x", nodeInfo.x)
+          .attr("y", nameY)
+          .attr("text-anchor", "middle")
+          .attr("fill", "#FFD700")
+          .attr("font-size", fontSize)
+          .attr("font-family", "SF Mono, Monaco, monospace")
+          .text(shortName);
+
+        // Single line of key stats
+        const statsY = nameY + 9;
+        nodeG
+          .append("text")
+          .attr("x", nodeInfo.x)
+          .attr("y", statsY)
+          .attr("text-anchor", "middle")
+          .attr("fill", "rgba(255,215,0,0.7)")
+          .attr("font-size", fontSize * 0.85)
+          .attr("font-family", "SF Mono, Monaco, monospace")
+          .text(
+            `${ramUsagePercent.toFixed(0)}%${!isNaN(gpuTemp) ? " " + gpuTemp.toFixed(0) + "°C" : ""}`,
+          );
+      } else {
+        // MINIMIZED MODE: Show name above and memory info below (like main topology)
+        const fontSize = 8;
+
+        // Friendly name (shortened) above icon
+        const nameY = nodeInfo.y - iconBaseHeight / 2 - 8;
+        const shortName =
+          friendlyName.length > 12
+            ? friendlyName.slice(0, 10) + ".."
+            : friendlyName;
+        nodeG
+          .append("text")
+          .attr("x", nodeInfo.x)
+          .attr("y", nameY)
+          .attr("text-anchor", "middle")
+          .attr("fill", "#FFD700")
+          .attr("font-size", fontSize)
+          .attr("font-weight", "500")
+          .attr("font-family", "SF Mono, Monaco, monospace")
+          .text(shortName);
+
+        // Memory info below icon - used in grey, total in yellow (same as main topology)
+        const infoY = nodeInfo.y + iconBaseHeight / 2 + 10;
+        const memTextMini = nodeG
+          .append("text")
+          .attr("x", nodeInfo.x)
+          .attr("y", infoY)
+          .attr("text-anchor", "middle")
+          .attr("font-size", fontSize * 0.85)
+          .attr("font-family", "SF Mono, Monaco, monospace");
+        memTextMini
+          .append("tspan")
+          .attr("fill", "rgba(255,215,0,0.9)")
+          .text(`${formatBytes(ramUsed)}`);
+        memTextMini
+          .append("tspan")
+          .attr("fill", "rgba(179,179,179,0.9)")
+          .text(`/${formatBytes(ramTotal)}`);
+        memTextMini
+          .append("tspan")
+          .attr("fill", "rgba(179,179,179,0.7)")
+          .text(` (${ramUsagePercent.toFixed(0)}%)`);
+      }
+    });
+  }
+
+  $effect(() => {
+    if (data) {
+      renderGraph();
+    }
+  });
+
+  onMount(() => {
+    if (svgContainer) {
+      resizeObserver = new ResizeObserver(() => {
+        renderGraph();
+      });
+      resizeObserver.observe(svgContainer);
+    }
+  });
+
+  onDestroy(() => {
+    resizeObserver?.disconnect();
+  });
 </script>
 
-<svg 
-	bind:this={svgContainer}
-	class="w-full h-full {className}"
-></svg>
+<svg bind:this={svgContainer} class="w-full h-full {className}"></svg>
 
 <style>
-	:global(.graph-node) {
-		transition: transform 0.2s ease, opacity 0.2s ease;
-	}
-	:global(.graph-node:hover) {
-		filter: brightness(1.1);
-	}
-	:global(.graph-link) {
-		stroke: var(--exo-light-gray, #B3B3B3);
-		stroke-width: 1px;
-		stroke-dasharray: 4, 4;
-		opacity: 0.8;
-		animation: flowAnimation 0.75s linear infinite;
-	}
-	@keyframes flowAnimation {
-		from { stroke-dashoffset: 0; }
-		to { stroke-dashoffset: -10; }
-	}
-
+  :global(.graph-node) {
+    transition:
+      transform 0.2s ease,
+      opacity 0.2s ease;
+  }
+  :global(.graph-node:hover) {
+    filter: brightness(1.1);
+  }
+  :global(.graph-link) {
+    stroke: var(--exo-light-gray, #b3b3b3);
+    stroke-width: 1px;
+    stroke-dasharray: 4, 4;
+    opacity: 0.8;
+    animation: flowAnimation 0.75s linear infinite;
+  }
+  @keyframes flowAnimation {
+    from {
+      stroke-dashoffset: 0;
+    }
+    to {
+      stroke-dashoffset: -10;
+    }
+  }
 </style>
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
index b67c103e..0d5d9b28 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -11,114 +11,114 @@ import { browser } from "$app/environment";
 
 // UUID generation fallback for browsers without crypto.randomUUID
 function generateUUID(): string {
-	if (
-		typeof crypto !== "undefined" &&
-		typeof crypto.randomUUID === "function"
-	) {
-		return crypto.randomUUID();
-	}
-	// Fallback implementation
-	return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
-		const r = (Math.random() * 16) | 0;
-		const v = c === "x" ? r : (r & 0x3) | 0x8;
-		return v.toString(16);
-	});
+  if (
+    typeof crypto !== "undefined" &&
+    typeof crypto.randomUUID === "function"
+  ) {
+    return crypto.randomUUID();
+  }
+  // Fallback implementation
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+    const r = (Math.random() * 16) | 0;
+    const v = c === "x" ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
 }
 
 export interface NodeInfo {
-	system_info?: {
-		model_id?: string;
-		chip?: string;
-		memory?: number;
-	};
-	network_interfaces?: Array<{
-		name?: string;
-		addresses?: string[];
-	}>;
-	ip_to_interface?: Record<string, string>;
-	macmon_info?: {
-		memory?: {
-			ram_usage: number;
-			ram_total: number;
-		};
-		temp?: {
-			gpu_temp_avg: number;
-		};
-		gpu_usage?: [number, number];
-		sys_power?: number;
-	};
-	last_macmon_update: number;
-	friendly_name?: string;
+  system_info?: {
+    model_id?: string;
+    chip?: string;
+    memory?: number;
+  };
+  network_interfaces?: Array<{
+    name?: string;
+    addresses?: string[];
+  }>;
+  ip_to_interface?: Record<string, string>;
+  macmon_info?: {
+    memory?: {
+      ram_usage: number;
+      ram_total: number;
+    };
+    temp?: {
+      gpu_temp_avg: number;
+    };
+    gpu_usage?: [number, number];
+    sys_power?: number;
+  };
+  last_macmon_update: number;
+  friendly_name?: string;
 }
 
 export interface TopologyEdge {
-	source: string;
-	target: string;
-	sendBackIp?: string;
-	sendBackInterface?: string;
+  source: string;
+  target: string;
+  sendBackIp?: string;
+  sendBackInterface?: string;
 }
 
 export interface TopologyData {
-	nodes: Record<string, NodeInfo>;
-	edges: TopologyEdge[];
+  nodes: Record<string, NodeInfo>;
+  edges: TopologyEdge[];
 }
 
 export interface Instance {
-	shardAssignments?: {
-		modelId?: string;
-		runnerToShard?: Record<string, unknown>;
-		nodeToRunner?: Record<string, string>;
-	};
+  shardAssignments?: {
+    modelId?: string;
+    runnerToShard?: Record<string, unknown>;
+    nodeToRunner?: Record<string, string>;
+  };
 }
 
 // Granular node state types from the new state structure
 interface RawNodeIdentity {
-	modelId?: string;
-	chipId?: string;
-	friendlyName?: string;
+  modelId?: string;
+  chipId?: string;
+  friendlyName?: string;
 }
 
 interface RawMemoryUsage {
-	ramTotal?: { inBytes: number };
-	ramAvailable?: { inBytes: number };
-	swapTotal?: { inBytes: number };
-	swapAvailable?: { inBytes: number };
+  ramTotal?: { inBytes: number };
+  ramAvailable?: { inBytes: number };
+  swapTotal?: { inBytes: number };
+  swapAvailable?: { inBytes: number };
 }
 
 interface RawSystemPerformanceProfile {
-	gpuUsage?: number;
-	temp?: number;
-	sysPower?: number;
-	pcpuUsage?: number;
-	ecpuUsage?: number;
+  gpuUsage?: number;
+  temp?: number;
+  sysPower?: number;
+  pcpuUsage?: number;
+  ecpuUsage?: number;
 }
 
 interface RawNetworkInterfaceInfo {
-	name?: string;
-	ipAddress?: string;
-	addresses?: Array<{ address?: string } | string>;
-	ipv4?: string;
-	ipv6?: string;
-	ipAddresses?: string[];
-	ips?: string[];
+  name?: string;
+  ipAddress?: string;
+  addresses?: Array<{ address?: string } | string>;
+  ipv4?: string;
+  ipv6?: string;
+  ipAddresses?: string[];
+  ips?: string[];
 }
 
 interface RawNodeNetworkInfo {
-	interfaces?: RawNetworkInterfaceInfo[];
+  interfaces?: RawNetworkInterfaceInfo[];
 }
 
 interface RawSocketConnection {
-	sinkMultiaddr?: {
-		address?: string;
-		ip_address?: string;
-		address_type?: string;
-		port?: number;
-	};
+  sinkMultiaddr?: {
+    address?: string;
+    ip_address?: string;
+    address_type?: string;
+    port?: number;
+  };
 }
 
 interface RawRDMAConnection {
-	sourceRdmaIface?: string;
-	sinkRdmaIface?: string;
+  sourceRdmaIface?: string;
+  sinkRdmaIface?: string;
 }
 
 type RawConnectionEdge = RawSocketConnection | RawRDMAConnection;
@@ -127,99 +127,99 @@ type RawConnectionEdge = RawSocketConnection | RawRDMAConnection;
 type RawConnectionsMap = Record<string, Record<string, RawConnectionEdge[]>>;
 
 interface RawTopology {
-	nodes: string[];
-	connections?: RawConnectionsMap;
+  nodes: string[];
+  connections?: RawConnectionsMap;
 }
 
 export interface DownloadProgress {
-	totalBytes: number;
-	downloadedBytes: number;
-	speed: number;
-	etaMs: number;
-	percentage: number;
-	completedFiles: number;
-	totalFiles: number;
-	files: Array<{
-		name: string;
-		totalBytes: number;
-		downloadedBytes: number;
-		speed: number;
-		etaMs: number;
-		percentage: number;
-	}>;
+  totalBytes: number;
+  downloadedBytes: number;
+  speed: number;
+  etaMs: number;
+  percentage: number;
+  completedFiles: number;
+  totalFiles: number;
+  files: Array<{
+    name: string;
+    totalBytes: number;
+    downloadedBytes: number;
+    speed: number;
+    etaMs: number;
+    percentage: number;
+  }>;
 }
 
 export interface ModelDownloadStatus {
-	isDownloading: boolean;
-	progress: DownloadProgress | null;
-	nodeDetails: Array<{
-		nodeId: string;
-		nodeName: string;
-		progress: DownloadProgress;
-	}>;
+  isDownloading: boolean;
+  progress: DownloadProgress | null;
+  nodeDetails: Array<{
+    nodeId: string;
+    nodeName: string;
+    progress: DownloadProgress;
+  }>;
 }
 
 // Placement preview from the API
 export interface PlacementPreview {
-	model_id: string;
-	sharding: "Pipeline" | "Tensor";
-	instance_meta: "MlxRing" | "MlxIbv" | "MlxJaccl";
-	instance: unknown | null;
-	memory_delta_by_node: Record<string, number> | null;
-	error: string | null;
+  model_id: string;
+  sharding: "Pipeline" | "Tensor";
+  instance_meta: "MlxRing" | "MlxIbv" | "MlxJaccl";
+  instance: unknown | null;
+  memory_delta_by_node: Record<string, number> | null;
+  error: string | null;
 }
 
 export interface PlacementPreviewResponse {
-	previews: PlacementPreview[];
+  previews: PlacementPreview[];
 }
 
 interface RawStateResponse {
-	topology?: RawTopology;
-	instances?: Record<
-		string,
-		{
-			MlxRingInstance?: Instance;
-			MlxIbvInstance?: Instance;
-			MlxJacclInstance?: Instance;
-		}
-	>;
-	runners?: Record<string, unknown>;
-	downloads?: Record<string, unknown[]>;
-	// New granular node state fields
-	nodeIdentities?: Record<string, RawNodeIdentity>;
-	nodeMemory?: Record<string, RawMemoryUsage>;
-	nodeSystem?: Record<string, RawSystemPerformanceProfile>;
-	nodeNetwork?: Record<string, RawNodeNetworkInfo>;
+  topology?: RawTopology;
+  instances?: Record<
+    string,
+    {
+      MlxRingInstance?: Instance;
+      MlxIbvInstance?: Instance;
+      MlxJacclInstance?: Instance;
+    }
+  >;
+  runners?: Record<string, unknown>;
+  downloads?: Record<string, unknown[]>;
+  // New granular node state fields
+  nodeIdentities?: Record<string, RawNodeIdentity>;
+  nodeMemory?: Record<string, RawMemoryUsage>;
+  nodeSystem?: Record<string, RawSystemPerformanceProfile>;
+  nodeNetwork?: Record<string, RawNodeNetworkInfo>;
 }
 
 export interface MessageAttachment {
-	type: "image" | "text" | "file" | "generated-image";
-	name: string;
-	content?: string;
-	preview?: string;
-	mimeType?: string;
+  type: "image" | "text" | "file" | "generated-image";
+  name: string;
+  content?: string;
+  preview?: string;
+  mimeType?: string;
 }
 
 export interface Message {
-	id: string;
-	role: "user" | "assistant" | "system";
-	content: string;
-	timestamp: number;
-	thinking?: string;
-	attachments?: MessageAttachment[];
-	ttftMs?: number; // Time to first token in ms (for assistant messages)
-	tps?: number; // Tokens per second (for assistant messages)
+  id: string;
+  role: "user" | "assistant" | "system";
+  content: string;
+  timestamp: number;
+  thinking?: string;
+  attachments?: MessageAttachment[];
+  ttftMs?: number; // Time to first token in ms (for assistant messages)
+  tps?: number; // Tokens per second (for assistant messages)
 }
 
 export interface Conversation {
-	id: string;
-	name: string;
-	messages: Message[];
-	createdAt: number;
-	updatedAt: number;
-	modelId: string | null;
-	sharding: string | null;
-	instanceType: string | null;
+  id: string;
+  name: string;
+  messages: Message[];
+  createdAt: number;
+  updatedAt: number;
+  modelId: string | null;
+  sharding: string | null;
+  instanceType: string | null;
 }
 
 const STORAGE_KEY = "exo-conversations";
@@ -227,1823 +227,1823 @@ const IMAGE_PARAMS_STORAGE_KEY = "exo-image-generation-params";
 
 // Image generation params interface matching backend API
 export interface ImageGenerationParams {
-	// Basic params
-	size: "512x512" | "768x768" | "1024x1024" | "1024x768" | "768x1024";
-	quality: "low" | "medium" | "high";
-	outputFormat: "png" | "jpeg";
-	// Advanced params
-	seed: number | null;
-	numInferenceSteps: number | null;
-	guidance: number | null;
-	negativePrompt: string | null;
-	// Edit mode params
-	inputFidelity: "low" | "high";
+  // Basic params
+  size: "512x512" | "768x768" | "1024x1024" | "1024x768" | "768x1024";
+  quality: "low" | "medium" | "high";
+  outputFormat: "png" | "jpeg";
+  // Advanced params
+  seed: number | null;
+  numInferenceSteps: number | null;
+  guidance: number | null;
+  negativePrompt: string | null;
+  // Edit mode params
+  inputFidelity: "low" | "high";
 }
 
 // Image being edited
 export interface EditingImage {
-	imageDataUrl: string;
-	sourceMessage: Message;
+  imageDataUrl: string;
+  sourceMessage: Message;
 }
 
 const DEFAULT_IMAGE_PARAMS: ImageGenerationParams = {
-	size: "1024x1024",
-	quality: "medium",
-	outputFormat: "png",
-	seed: null,
-	numInferenceSteps: null,
-	guidance: null,
-	negativePrompt: null,
-	inputFidelity: "low",
+  size: "1024x1024",
+  quality: "medium",
+  outputFormat: "png",
+  seed: null,
+  numInferenceSteps: null,
+  guidance: null,
+  negativePrompt: null,
+  inputFidelity: "low",
 };
 
 interface GranularNodeState {
-	nodeIdentities?: Record<string, RawNodeIdentity>;
-	nodeMemory?: Record<string, RawMemoryUsage>;
-	nodeSystem?: Record<string, RawSystemPerformanceProfile>;
-	nodeNetwork?: Record<string, RawNodeNetworkInfo>;
+  nodeIdentities?: Record<string, RawNodeIdentity>;
+  nodeMemory?: Record<string, RawMemoryUsage>;
+  nodeSystem?: Record<string, RawSystemPerformanceProfile>;
+  nodeNetwork?: Record<string, RawNodeNetworkInfo>;
 }
 
 function transformNetworkInterface(iface: RawNetworkInterfaceInfo): {
-	name?: string;
-	addresses: string[];
+  name?: string;
+  addresses: string[];
 } {
-	const addresses: string[] = [];
-	if (iface.ipAddress && typeof iface.ipAddress === "string") {
-		addresses.push(iface.ipAddress);
-	}
-	if (Array.isArray(iface.addresses)) {
-		for (const addr of iface.addresses) {
-			if (typeof addr === "string") addresses.push(addr);
-			else if (addr && typeof addr === "object" && addr.address)
-				addresses.push(addr.address);
-		}
-	}
-	if (Array.isArray(iface.ipAddresses)) {
-		addresses.push(
-			...iface.ipAddresses.filter((a): a is string => typeof a === "string"),
-		);
-	}
-	if (Array.isArray(iface.ips)) {
-		addresses.push(
-			...iface.ips.filter((a): a is string => typeof a === "string"),
-		);
-	}
-	if (iface.ipv4 && typeof iface.ipv4 === "string") addresses.push(iface.ipv4);
-	if (iface.ipv6 && typeof iface.ipv6 === "string") addresses.push(iface.ipv6);
-
-	return {
-		name: iface.name,
-		addresses: Array.from(new Set(addresses)),
-	};
+  const addresses: string[] = [];
+  if (iface.ipAddress && typeof iface.ipAddress === "string") {
+    addresses.push(iface.ipAddress);
+  }
+  if (Array.isArray(iface.addresses)) {
+    for (const addr of iface.addresses) {
+      if (typeof addr === "string") addresses.push(addr);
+      else if (addr && typeof addr === "object" && addr.address)
+        addresses.push(addr.address);
+    }
+  }
+  if (Array.isArray(iface.ipAddresses)) {
+    addresses.push(
+      ...iface.ipAddresses.filter((a): a is string => typeof a === "string"),
+    );
+  }
+  if (Array.isArray(iface.ips)) {
+    addresses.push(
+      ...iface.ips.filter((a): a is string => typeof a === "string"),
+    );
+  }
+  if (iface.ipv4 && typeof iface.ipv4 === "string") addresses.push(iface.ipv4);
+  if (iface.ipv6 && typeof iface.ipv6 === "string") addresses.push(iface.ipv6);
+
+  return {
+    name: iface.name,
+    addresses: Array.from(new Set(addresses)),
+  };
 }
 
 function transformTopology(
-	raw: RawTopology,
-	granularState: GranularNodeState,
+  raw: RawTopology,
+  granularState: GranularNodeState,
 ): TopologyData {
-	const nodes: Record<string, NodeInfo> = {};
-	const edges: TopologyEdge[] = [];
-
-	for (const nodeId of raw.nodes || []) {
-		if (!nodeId) continue;
-
-		// Get data from granular state mappings
-		const identity = granularState.nodeIdentities?.[nodeId];
-		const memory = granularState.nodeMemory?.[nodeId];
-		const system = granularState.nodeSystem?.[nodeId];
-		const network = granularState.nodeNetwork?.[nodeId];
-
-		const ramTotal = memory?.ramTotal?.inBytes ?? 0;
-		const ramAvailable = memory?.ramAvailable?.inBytes ?? 0;
-		const ramUsage = Math.max(ramTotal - ramAvailable, 0);
-
-		const rawInterfaces = network?.interfaces || [];
-		const networkInterfaces = rawInterfaces.map(transformNetworkInterface);
-
-		const ipToInterface: Record<string, string> = {};
-		for (const iface of networkInterfaces) {
-			for (const addr of iface.addresses || []) {
-				ipToInterface[addr] = iface.name ?? "";
-			}
-		}
-
-		nodes[nodeId] = {
-			system_info: {
-				model_id: identity?.modelId ?? "Unknown",
-				chip: identity?.chipId,
-				memory: ramTotal,
-			},
-			network_interfaces: networkInterfaces,
-			ip_to_interface: ipToInterface,
-			macmon_info: {
-				memory: {
-					ram_usage: ramUsage,
-					ram_total: ramTotal,
-				},
-				temp:
-					system?.temp !== undefined
-						? { gpu_temp_avg: system.temp }
-						: undefined,
-				gpu_usage:
-					system?.gpuUsage !== undefined ? [0, system.gpuUsage] : undefined,
-				sys_power: system?.sysPower,
-			},
-			last_macmon_update: Date.now() / 1000,
-			friendly_name: identity?.friendlyName,
-		};
-	}
-
-	// Handle connections - nested mapping format { source: { sink: [edges] } }
-	const connections = raw.connections;
-	if (connections && typeof connections === "object") {
-		for (const [source, sinks] of Object.entries(connections)) {
-			if (!sinks || typeof sinks !== "object") continue;
-			for (const [sink, edgeList] of Object.entries(sinks)) {
-				if (!Array.isArray(edgeList)) continue;
-				for (const edge of edgeList) {
-					let sendBackIp: string | undefined;
-					if (edge && typeof edge === "object" && "sinkMultiaddr" in edge) {
-						const multiaddr = edge.sinkMultiaddr;
-						if (multiaddr) {
-							sendBackIp =
-								multiaddr.ip_address ||
-								extractIpFromMultiaddr(multiaddr.address);
-						}
-					}
-
-					if (nodes[source] && nodes[sink] && source !== sink) {
-						edges.push({ source, target: sink, sendBackIp });
-					}
-				}
-			}
-		}
-	}
-
-	return { nodes, edges };
+  const nodes: Record<string, NodeInfo> = {};
+  const edges: TopologyEdge[] = [];
+
+  for (const nodeId of raw.nodes || []) {
+    if (!nodeId) continue;
+
+    // Get data from granular state mappings
+    const identity = granularState.nodeIdentities?.[nodeId];
+    const memory = granularState.nodeMemory?.[nodeId];
+    const system = granularState.nodeSystem?.[nodeId];
+    const network = granularState.nodeNetwork?.[nodeId];
+
+    const ramTotal = memory?.ramTotal?.inBytes ?? 0;
+    const ramAvailable = memory?.ramAvailable?.inBytes ?? 0;
+    const ramUsage = Math.max(ramTotal - ramAvailable, 0);
+
+    const rawInterfaces = network?.interfaces || [];
+    const networkInterfaces = rawInterfaces.map(transformNetworkInterface);
+
+    const ipToInterface: Record<string, string> = {};
+    for (const iface of networkInterfaces) {
+      for (const addr of iface.addresses || []) {
+        ipToInterface[addr] = iface.name ?? "";
+      }
+    }
+
+    nodes[nodeId] = {
+      system_info: {
+        model_id: identity?.modelId ?? "Unknown",
+        chip: identity?.chipId,
+        memory: ramTotal,
+      },
+      network_interfaces: networkInterfaces,
+      ip_to_interface: ipToInterface,
+      macmon_info: {
+        memory: {
+          ram_usage: ramUsage,
+          ram_total: ramTotal,
+        },
+        temp:
+          system?.temp !== undefined
+            ? { gpu_temp_avg: system.temp }
+            : undefined,
+        gpu_usage:
+          system?.gpuUsage !== undefined ? [0, system.gpuUsage] : undefined,
+        sys_power: system?.sysPower,
+      },
+      last_macmon_update: Date.now() / 1000,
+      friendly_name: identity?.friendlyName,
+    };
+  }
+
+  // Handle connections - nested mapping format { source: { sink: [edges] } }
+  const connections = raw.connections;
+  if (connections && typeof connections === "object") {
+    for (const [source, sinks] of Object.entries(connections)) {
+      if (!sinks || typeof sinks !== "object") continue;
+      for (const [sink, edgeList] of Object.entries(sinks)) {
+        if (!Array.isArray(edgeList)) continue;
+        for (const edge of edgeList) {
+          let sendBackIp: string | undefined;
+          if (edge && typeof edge === "object" && "sinkMultiaddr" in edge) {
+            const multiaddr = edge.sinkMultiaddr;
+            if (multiaddr) {
+              sendBackIp =
+                multiaddr.ip_address ||
+                extractIpFromMultiaddr(multiaddr.address);
+            }
+          }
+
+          if (nodes[source] && nodes[sink] && source !== sink) {
+            edges.push({ source, target: sink, sendBackIp });
+          }
+        }
+      }
+    }
+  }
+
+  return { nodes, edges };
 }
 
 function extractIpFromMultiaddr(ma?: string): string | undefined {
-	if (!ma) return undefined;
-	const parts = ma.split("/");
-	const ip4Idx = parts.indexOf("ip4");
-	const ip6Idx = parts.indexOf("ip6");
-	const idx = ip4Idx >= 0 ? ip4Idx : ip6Idx;
-	if (idx >= 0 && parts.length > idx + 1) {
-		return parts[idx + 1];
-	}
-	return undefined;
+  if (!ma) return undefined;
+  const parts = ma.split("/");
+  const ip4Idx = parts.indexOf("ip4");
+  const ip6Idx = parts.indexOf("ip6");
+  const idx = ip4Idx >= 0 ? ip4Idx : ip6Idx;
+  if (idx >= 0 && parts.length > idx + 1) {
+    return parts[idx + 1];
+  }
+  return undefined;
 }
 
 class AppStore {
-	// Conversation state
-	conversations = $state<Conversation[]>([]);
-	activeConversationId = $state<string | null>(null);
-
-	// Chat state
-	hasStartedChat = $state(false);
-	messages = $state<Message[]>([]);
-	currentResponse = $state("");
-	isLoading = $state(false);
-
-	// Performance metrics
-	ttftMs = $state<number | null>(null); // Time to first token in ms
-	tps = $state<number | null>(null); // Tokens per second
-	totalTokens = $state<number>(0); // Total tokens in current response
-
-	// Topology state
-	topologyData = $state<TopologyData | null>(null);
-	instances = $state<Record<string, unknown>>({});
-	runners = $state<Record<string, unknown>>({});
-	downloads = $state<Record<string, unknown[]>>({});
-	placementPreviews = $state<PlacementPreview[]>([]);
-	selectedPreviewModelId = $state<string | null>(null);
-	isLoadingPreviews = $state(false);
-	lastUpdate = $state<number | null>(null);
-
-	// UI state
-	isTopologyMinimized = $state(false);
-	isSidebarOpen = $state(false); // Hidden by default, shown when in chat mode
-	debugMode = $state(false);
-	topologyOnlyMode = $state(false);
-	chatSidebarVisible = $state(true); // Shown by default
-
-	// Image generation params
-	imageGenerationParams = $state<ImageGenerationParams>({
-		...DEFAULT_IMAGE_PARAMS,
-	});
-
-	// Image editing state
-	editingImage = $state<EditingImage | null>(null);
-
-	private fetchInterval: ReturnType<typeof setInterval> | null = null;
-	private previewsInterval: ReturnType<typeof setInterval> | null = null;
-	private lastConversationPersistTs = 0;
-
-	constructor() {
-		if (browser) {
-			this.startPolling();
-			this.loadConversationsFromStorage();
-			this.loadDebugModeFromStorage();
-			this.loadTopologyOnlyModeFromStorage();
-			this.loadChatSidebarVisibleFromStorage();
-			this.loadImageGenerationParamsFromStorage();
-		}
-	}
-
-	/**
-	 * Load conversations from localStorage
-	 */
-	private loadConversationsFromStorage() {
-		try {
-			const stored = localStorage.getItem(STORAGE_KEY);
-			if (stored) {
-				const parsed = JSON.parse(stored) as Array<Partial<Conversation>>;
-				this.conversations = parsed.map((conversation) => ({
-					id: conversation.id ?? generateUUID(),
-					name: conversation.name ?? "Chat",
-					messages: conversation.messages ?? [],
-					createdAt: conversation.createdAt ?? Date.now(),
-					updatedAt: conversation.updatedAt ?? Date.now(),
-					modelId: conversation.modelId ?? null,
-					sharding: conversation.sharding ?? null,
-					instanceType: conversation.instanceType ?? null,
-				}));
-			}
-		} catch (error) {
-			console.error("Failed to load conversations:", error);
-		}
-	}
-
-	/**
-	 * Save conversations to localStorage
-	 */
-	private saveConversationsToStorage() {
-		try {
-			localStorage.setItem(STORAGE_KEY, JSON.stringify(this.conversations));
-		} catch (error) {
-			console.error("Failed to save conversations:", error);
-		}
-	}
-
-	private loadDebugModeFromStorage() {
-		try {
-			const stored = localStorage.getItem("exo-debug-mode");
-			if (stored !== null) {
-				this.debugMode = stored === "true";
-			}
-		} catch (error) {
-			console.error("Failed to load debug mode:", error);
-		}
-	}
-
-	private saveDebugModeToStorage() {
-		try {
-			localStorage.setItem("exo-debug-mode", this.debugMode ? "true" : "false");
-		} catch (error) {
-			console.error("Failed to save debug mode:", error);
-		}
-	}
-
-	private loadTopologyOnlyModeFromStorage() {
-		try {
-			const stored = localStorage.getItem("exo-topology-only-mode");
-			if (stored !== null) {
-				this.topologyOnlyMode = stored === "true";
-			}
-		} catch (error) {
-			console.error("Failed to load topology only mode:", error);
-		}
-	}
-
-	private saveTopologyOnlyModeToStorage() {
-		try {
-			localStorage.setItem(
-				"exo-topology-only-mode",
-				this.topologyOnlyMode ? "true" : "false",
-			);
-		} catch (error) {
-			console.error("Failed to save topology only mode:", error);
-		}
-	}
-
-	private loadChatSidebarVisibleFromStorage() {
-		try {
-			const stored = localStorage.getItem("exo-chat-sidebar-visible");
-			if (stored !== null) {
-				this.chatSidebarVisible = stored === "true";
-			}
-		} catch (error) {
-			console.error("Failed to load chat sidebar visibility:", error);
-		}
-	}
-
-	private saveChatSidebarVisibleToStorage() {
-		try {
-			localStorage.setItem(
-				"exo-chat-sidebar-visible",
-				this.chatSidebarVisible ? "true" : "false",
-			);
-		} catch (error) {
-			console.error("Failed to save chat sidebar visibility:", error);
-		}
-	}
-
-	private loadImageGenerationParamsFromStorage() {
-		try {
-			const stored = localStorage.getItem(IMAGE_PARAMS_STORAGE_KEY);
-			if (stored) {
-				const parsed = JSON.parse(stored) as Partial<ImageGenerationParams>;
-				this.imageGenerationParams = {
-					...DEFAULT_IMAGE_PARAMS,
-					...parsed,
-				};
-			}
-		} catch (error) {
-			console.error("Failed to load image generation params:", error);
-		}
-	}
-
-	private saveImageGenerationParamsToStorage() {
-		try {
-			localStorage.setItem(
-				IMAGE_PARAMS_STORAGE_KEY,
-				JSON.stringify(this.imageGenerationParams),
-			);
-		} catch (error) {
-			console.error("Failed to save image generation params:", error);
-		}
-	}
-
-	getImageGenerationParams(): ImageGenerationParams {
-		return this.imageGenerationParams;
-	}
-
-	setImageGenerationParams(params: Partial<ImageGenerationParams>) {
-		this.imageGenerationParams = {
-			...this.imageGenerationParams,
-			...params,
-		};
-		this.saveImageGenerationParamsToStorage();
-	}
-
-	resetImageGenerationParams() {
-		this.imageGenerationParams = { ...DEFAULT_IMAGE_PARAMS };
-		this.saveImageGenerationParamsToStorage();
-	}
-
-	setEditingImage(imageDataUrl: string, sourceMessage: Message) {
-		this.editingImage = { imageDataUrl, sourceMessage };
-	}
-
-	clearEditingImage() {
-		this.editingImage = null;
-	}
-
-	/**
-	 * Create a new conversation
-	 */
-	createConversation(name?: string): string {
-		const id = generateUUID();
-		const now = Date.now();
-
-		// Try to derive model and strategy immediately from selected model or running instances
-		let derivedModelId = this.selectedChatModel || null;
-		let derivedInstanceType: string | null = null;
-		let derivedSharding: string | null = null;
-
-		// If no selected model, fall back to the first running instance
-		if (!derivedModelId) {
-			const firstInstance = Object.values(this.instances)[0];
-			if (firstInstance) {
-				const candidateModel = this.extractInstanceModelId(firstInstance);
-				derivedModelId = candidateModel ?? null;
-				const details = this.describeInstance(firstInstance);
-				derivedInstanceType = details.instanceType;
-				derivedSharding = details.sharding;
-			}
-		} else {
-			// If selected model is set, attempt to get its details from instances
-			for (const [, instanceWrapper] of Object.entries(this.instances)) {
-				const candidateModelId = this.extractInstanceModelId(instanceWrapper);
-				if (candidateModelId === derivedModelId) {
-					const details = this.describeInstance(instanceWrapper);
-					derivedInstanceType = details.instanceType;
-					derivedSharding = details.sharding;
-					break;
-				}
-			}
-		}
-
-		const conversation: Conversation = {
-			id,
-			name:
-				name ||
-				`Chat ${new Date(now).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`,
-			messages: [],
-			createdAt: now,
-			updatedAt: now,
-			modelId: derivedModelId,
-			sharding: derivedSharding,
-			instanceType: derivedInstanceType,
-		};
-
-		this.conversations.unshift(conversation);
-		this.activeConversationId = id;
-		this.messages = [];
-		this.hasStartedChat = true;
-		this.isTopologyMinimized = true;
-		this.isSidebarOpen = true; // Auto-open sidebar when chatting
-
-		this.saveConversationsToStorage();
-		return id;
-	}
-
-	/**
-	 * Load a conversation by ID
-	 */
-	loadConversation(id: string): boolean {
-		const conversation = this.conversations.find((c) => c.id === id);
-		if (!conversation) return false;
-
-		this.activeConversationId = id;
-		this.messages = [...conversation.messages];
-		this.hasStartedChat = true;
-		this.isTopologyMinimized = true;
-		this.isSidebarOpen = true; // Auto-open sidebar when chatting
-		this.refreshConversationModelFromInstances();
-
-		return true;
-	}
-
-	/**
-	 * Delete a conversation by ID
-	 */
-	deleteConversation(id: string) {
-		this.conversations = this.conversations.filter((c) => c.id !== id);
-
-		if (this.activeConversationId === id) {
-			this.activeConversationId = null;
-			this.messages = [];
-			this.hasStartedChat = false;
-			this.isTopologyMinimized = false;
-		}
-
-		this.saveConversationsToStorage();
-	}
-
-	/**
-	 * Delete all conversations
-	 */
-	deleteAllConversations() {
-		this.conversations = [];
-		this.activeConversationId = null;
-		this.messages = [];
-		this.hasStartedChat = false;
-		this.isTopologyMinimized = false;
-		this.saveConversationsToStorage();
-	}
-
-	/**
-	 * Rename a conversation
-	 */
-	renameConversation(id: string, newName: string) {
-		const conversation = this.conversations.find((c) => c.id === id);
-		if (conversation) {
-			conversation.name = newName;
-			conversation.updatedAt = Date.now();
-			this.saveConversationsToStorage();
-		}
-	}
-
-	private getTaggedValue(obj: unknown): [string | null, unknown] {
-		if (!obj || typeof obj !== "object") return [null, null];
-		const keys = Object.keys(obj as Record<string, unknown>);
-		if (keys.length === 1) {
-			return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
-		}
-		return [null, null];
-	}
-
-	private extractInstanceModelId(instanceWrapped: unknown): string | null {
-		const [, instance] = this.getTaggedValue(instanceWrapped);
-		if (!instance || typeof instance !== "object") return null;
-		const inst = instance as { shardAssignments?: { modelId?: string } };
-		return inst.shardAssignments?.modelId ?? null;
-	}
-
-	private describeInstance(instanceWrapped: unknown): {
-		sharding: string | null;
-		instanceType: string | null;
-	} {
-		const [instanceTag, instance] = this.getTaggedValue(instanceWrapped);
-		if (!instance || typeof instance !== "object") {
-			return { sharding: null, instanceType: null };
-		}
-
-		let instanceType: string | null = null;
-		if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
-		else if (
-			instanceTag === "MlxIbvInstance" ||
-			instanceTag === "MlxJacclInstance"
-		)
-			instanceType = "MLX RDMA";
-
-		let sharding: string | null = null;
-		const inst = instance as {
-			shardAssignments?: { runnerToShard?: Record<string, unknown> };
-		};
-		const runnerToShard = inst.shardAssignments?.runnerToShard || {};
-		const firstShardWrapped = Object.values(runnerToShard)[0];
-		if (firstShardWrapped) {
-			const [shardTag] = this.getTaggedValue(firstShardWrapped);
-			if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
-			else if (shardTag === "TensorShardMetadata") sharding = "Tensor";
-			else if (shardTag === "PrefillDecodeShardMetadata")
-				sharding = "Prefill/Decode";
-		}
-
-		return { sharding, instanceType };
-	}
-
-	private buildConversationModelInfo(modelId: string): {
-		modelId: string;
-		sharding: string | null;
-		instanceType: string | null;
-	} {
-		let sharding: string | null = null;
-		let instanceType: string | null = null;
-
-		for (const [, instanceWrapper] of Object.entries(this.instances)) {
-			const candidateModelId = this.extractInstanceModelId(instanceWrapper);
-			if (candidateModelId === modelId) {
-				const details = this.describeInstance(instanceWrapper);
-				sharding = details.sharding;
-				instanceType = details.instanceType;
-				break;
-			}
-		}
-
-		return { modelId, sharding, instanceType };
-	}
-
-	private applyConversationModelInfo(info: {
-		modelId: string;
-		sharding: string | null;
-		instanceType: string | null;
-	}) {
-		if (!this.activeConversationId) return;
-		const conversation = this.conversations.find(
-			(c) => c.id === this.activeConversationId,
-		);
-		if (!conversation) return;
-
-		// Keep the first known modelId stable; only backfill if missing
-		if (!conversation.modelId) {
-			conversation.modelId = info.modelId;
-		}
-		conversation.sharding = info.sharding;
-		conversation.instanceType = info.instanceType;
-		this.saveConversationsToStorage();
-	}
-
-	private getModelTail(modelId: string): string {
-		const parts = modelId.split("/");
-		return (parts[parts.length - 1] || modelId).toLowerCase();
-	}
-
-	private isBetterModelId(
-		currentId: string | null,
-		candidateId: string | null,
-	): boolean {
-		if (!candidateId) return false;
-		if (!currentId) return true;
-		const currentTail = this.getModelTail(currentId);
-		const candidateTail = this.getModelTail(candidateId);
-		return (
-			candidateTail.length > currentTail.length &&
-			candidateTail.startsWith(currentTail)
-		);
-	}
-
-	private refreshConversationModelFromInstances() {
-		if (!this.activeConversationId) return;
-		const conversation = this.conversations.find(
-			(c) => c.id === this.activeConversationId,
-		);
-		if (!conversation) return;
-
-		// Prefer stored model; do not replace it once set. Only backfill when missing.
-		let modelId = conversation.modelId;
-
-		// If missing, try the selected model
-		if (!modelId && this.selectedChatModel) {
-			modelId = this.selectedChatModel;
-		}
-
-		// If still missing, fall back to first instance model
-		if (!modelId) {
-			const firstInstance = Object.values(this.instances)[0];
-			if (firstInstance) {
-				modelId = this.extractInstanceModelId(firstInstance);
-			}
-		}
-
-		if (!modelId) return;
-
-		// If a more specific instance modelId is available (e.g., adds "-4bit"), prefer it
-		let preferredModelId = modelId;
-		for (const [, instanceWrapper] of Object.entries(this.instances)) {
-			const candidate = this.extractInstanceModelId(instanceWrapper);
-			if (!candidate) continue;
-			if (candidate === preferredModelId) {
-				break;
-			}
-			if (this.isBetterModelId(preferredModelId, candidate)) {
-				preferredModelId = candidate;
-			}
-		}
-
-		if (this.isBetterModelId(conversation.modelId, preferredModelId)) {
-			conversation.modelId = preferredModelId;
-		}
-
-		const info = this.buildConversationModelInfo(preferredModelId);
-		const hasNewInfo = Boolean(
-			info.sharding || info.instanceType || !conversation.modelId,
-		);
-		if (hasNewInfo) {
-			this.applyConversationModelInfo(info);
-		}
-	}
-
-	getDebugMode(): boolean {
-		return this.debugMode;
-	}
-
-	/**
-	 * Update the active conversation with current messages
-	 */
-	private updateActiveConversation() {
-		if (!this.activeConversationId) return;
-
-		const conversation = this.conversations.find(
-			(c) => c.id === this.activeConversationId,
-		);
-		if (conversation) {
-			conversation.messages = [...this.messages];
-			conversation.updatedAt = Date.now();
-
-			// Auto-generate name from first user message if still has default name
-			if (conversation.name.startsWith("Chat ")) {
-				const firstUserMsg = conversation.messages.find(
-					(m) => m.role === "user" && m.content.trim(),
-				);
-				if (firstUserMsg) {
-					// Clean up the content - remove file context markers and whitespace
-					let content = firstUserMsg.content
-						.replace(/\[File:.*?\][\s\S]*?```[\s\S]*?```/g, "") // Remove file attachments
-						.trim();
-
-					if (content) {
-						const preview = content.slice(0, 50);
-						conversation.name =
-							preview.length < content.length ? preview + "..." : preview;
-					}
-				}
-			}
-
-			this.saveConversationsToStorage();
-		}
-	}
-
-	private persistActiveConversation(throttleMs = 400) {
-		const now = Date.now();
-		if (now - this.lastConversationPersistTs < throttleMs) return;
-		this.lastConversationPersistTs = now;
-		this.updateActiveConversation();
-	}
-
-	/**
-	 * Toggle sidebar visibility
-	 */
-	toggleSidebar() {
-		this.isSidebarOpen = !this.isSidebarOpen;
-	}
-
-	setDebugMode(enabled: boolean) {
-		this.debugMode = enabled;
-		this.saveDebugModeToStorage();
-	}
-
-	toggleDebugMode() {
-		this.debugMode = !this.debugMode;
-		this.saveDebugModeToStorage();
-	}
-
-	getTopologyOnlyMode(): boolean {
-		return this.topologyOnlyMode;
-	}
-
-	setTopologyOnlyMode(enabled: boolean) {
-		this.topologyOnlyMode = enabled;
-		this.saveTopologyOnlyModeToStorage();
-	}
-
-	toggleTopologyOnlyMode() {
-		this.topologyOnlyMode = !this.topologyOnlyMode;
-		this.saveTopologyOnlyModeToStorage();
-	}
-
-	getChatSidebarVisible(): boolean {
-		return this.chatSidebarVisible;
-	}
-
-	setChatSidebarVisible(visible: boolean) {
-		this.chatSidebarVisible = visible;
-		this.saveChatSidebarVisibleToStorage();
-	}
-
-	toggleChatSidebarVisible() {
-		this.chatSidebarVisible = !this.chatSidebarVisible;
-		this.saveChatSidebarVisibleToStorage();
-	}
-
-	startPolling() {
-		this.fetchState();
-		this.fetchInterval = setInterval(() => this.fetchState(), 1000);
-	}
-
-	stopPolling() {
-		if (this.fetchInterval) {
-			clearInterval(this.fetchInterval);
-			this.fetchInterval = null;
-		}
-		this.stopPreviewsPolling();
-	}
-
-	async fetchState() {
-		try {
-			const response = await fetch("/state");
-			if (!response.ok) {
-				throw new Error(`Failed to fetch state: ${response.status}`);
-			}
-			const data: RawStateResponse = await response.json();
-
-			if (data.topology) {
-				this.topologyData = transformTopology(data.topology, {
-					nodeIdentities: data.nodeIdentities,
-					nodeMemory: data.nodeMemory,
-					nodeSystem: data.nodeSystem,
-					nodeNetwork: data.nodeNetwork,
-				});
-			}
-			if (data.instances) {
-				this.instances = data.instances;
-				this.refreshConversationModelFromInstances();
-			}
-			if (data.runners) {
-				this.runners = data.runners;
-			}
-			if (data.downloads) {
-				this.downloads = data.downloads;
-			}
-			this.lastUpdate = Date.now();
-		} catch (error) {
-			console.error("Error fetching state:", error);
-		}
-	}
-
-	async fetchPlacementPreviews(modelId: string, showLoading = true) {
-		if (!modelId) return;
-
-		if (showLoading) {
-			this.isLoadingPreviews = true;
-		}
-		this.selectedPreviewModelId = modelId;
-
-		try {
-			const response = await fetch(
-				`/instance/previews?model_id=${encodeURIComponent(modelId)}`,
-			);
-			if (!response.ok) {
-				throw new Error(
-					`Failed to fetch placement previews: ${response.status}`,
-				);
-			}
-			const data: PlacementPreviewResponse = await response.json();
-			this.placementPreviews = data.previews;
-		} catch (error) {
-			console.error("Error fetching placement previews:", error);
-			this.placementPreviews = [];
-		} finally {
-			if (showLoading) {
-				this.isLoadingPreviews = false;
-			}
-		}
-	}
-
-	startPreviewsPolling(modelId: string) {
-		// Stop any existing preview polling
-		this.stopPreviewsPolling();
-
-		// Fetch immediately
-		this.fetchPlacementPreviews(modelId);
-
-		// Then poll every 15 seconds (don't show loading spinner for subsequent fetches)
-		this.previewsInterval = setInterval(() => {
-			if (this.selectedPreviewModelId) {
-				this.fetchPlacementPreviews(this.selectedPreviewModelId, false);
-			}
-		}, 15000);
-	}
-
-	stopPreviewsPolling() {
-		if (this.previewsInterval) {
-			clearInterval(this.previewsInterval);
-			this.previewsInterval = null;
-		}
-	}
-
-	selectPreviewModel(modelId: string | null) {
-		if (modelId) {
-			this.startPreviewsPolling(modelId);
-		} else {
-			this.stopPreviewsPolling();
-			this.selectedPreviewModelId = null;
-			this.placementPreviews = [];
-		}
-	}
-
-	/**
-	 * Starts a chat conversation - triggers the topology minimization animation
-	 * Creates a new conversation if none is active
-	 */
-	startChat() {
-		if (!this.activeConversationId) {
-			this.createConversation();
-		} else {
-			this.hasStartedChat = true;
-			this.isSidebarOpen = true; // Auto-open sidebar when chatting
-			// Small delay before minimizing for a nice visual effect
-			setTimeout(() => {
-				this.isTopologyMinimized = true;
-			}, 100);
-		}
-	}
-
-	/**
-	 * Add a message to the conversation
-	 */
-	addMessage(role: "user" | "assistant", content: string) {
-		const message: Message = {
-			id: generateUUID(),
-			role,
-			content,
-			timestamp: Date.now(),
-		};
-		this.messages.push(message);
-		return message;
-	}
-
-	/**
-	 * Delete a message and all subsequent messages
-	 */
-	deleteMessage(messageId: string) {
-		const messageIndex = this.messages.findIndex((m) => m.id === messageId);
-		if (messageIndex === -1) return;
-
-		// Remove this message and all subsequent messages
-		this.messages = this.messages.slice(0, messageIndex);
-		this.updateActiveConversation();
-	}
-
-	/**
-	 * Edit a user message content (does not regenerate response)
-	 */
-	editMessage(messageId: string, newContent: string) {
-		const message = this.messages.find((m) => m.id === messageId);
-		if (!message) return;
-
-		message.content = newContent;
-		message.timestamp = Date.now();
-		this.updateActiveConversation();
-	}
-
-	/**
-	 * Edit a user message and regenerate the response
-	 */
-	async editAndRegenerate(
-		messageId: string,
-		newContent: string,
-	): Promise<void> {
-		const messageIndex = this.messages.findIndex((m) => m.id === messageId);
-		if (messageIndex === -1) return;
-
-		const message = this.messages[messageIndex];
-		if (message.role !== "user") return;
-
-		// Update the message content
-		message.content = newContent;
-		message.timestamp = Date.now();
-
-		// Remove all messages after this one (including the assistant response)
-		this.messages = this.messages.slice(0, messageIndex + 1);
-
-		// Regenerate the response
-		await this.regenerateLastResponse();
-	}
-
-	/**
-	 * Regenerate the last assistant response
-	 */
-	async regenerateLastResponse(): Promise<void> {
-		if (this.isLoading) return;
-
-		// Find the last user message
-		let lastUserIndex = -1;
-		for (let i = this.messages.length - 1; i >= 0; i--) {
-			if (this.messages[i].role === "user") {
-				lastUserIndex = i;
-				break;
-			}
-		}
-
-		if (lastUserIndex === -1) return;
-
-		// Remove any messages after the user message
-		this.messages = this.messages.slice(0, lastUserIndex + 1);
-
-		// Resend the message to get a new response
-		this.isLoading = true;
-		this.currentResponse = "";
-
-		// Create placeholder for assistant message
-		const assistantMessage = this.addMessage("assistant", "");
-
-		try {
-			const systemPrompt = {
-				role: "system" as const,
-				content:
-					"You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process.",
-			};
-
-			const apiMessages = [
-				systemPrompt,
-				...this.messages.slice(0, -1).map((m) => {
-					return { role: m.role, content: m.content };
-				}),
-			];
-
-			// Determine which model to use
-			let modelToUse = this.selectedChatModel;
-			if (!modelToUse) {
-				const firstInstanceKey = Object.keys(this.instances)[0];
-				if (firstInstanceKey) {
-					const instance = this.instances[firstInstanceKey] as
-						| Record<string, unknown>
-						| undefined;
-					if (instance) {
-						const keys = Object.keys(instance);
-						if (keys.length === 1) {
-							const inst = instance[keys[0]] as
-								| { shardAssignments?: { modelId?: string } }
-								| undefined;
-							modelToUse = inst?.shardAssignments?.modelId || "";
-						}
-					}
-				}
-			}
-
-			if (!modelToUse) {
-				const idx = this.messages.findIndex(
-					(m) => m.id === assistantMessage.id,
-				);
-				if (idx !== -1) {
-					this.messages[idx].content =
-						"Error: No model available. Please launch an instance first.";
-				}
-				this.isLoading = false;
-				this.updateActiveConversation();
-				return;
-			}
-
-			const response = await fetch("/v1/chat/completions", {
-				method: "POST",
-				headers: { "Content-Type": "application/json" },
-				body: JSON.stringify({
-					model: modelToUse,
-					messages: apiMessages,
-					stream: true,
-				}),
-			});
-
-			if (!response.ok) {
-				const errorText = await response.text();
-				const idx = this.messages.findIndex(
-					(m) => m.id === assistantMessage.id,
-				);
-				if (idx !== -1) {
-					this.messages[idx].content =
-						`Error: ${response.status} - ${errorText}`;
-				}
-				this.isLoading = false;
-				this.updateActiveConversation();
-				return;
-			}
-
-			const reader = response.body?.getReader();
-			if (!reader) {
-				const idx = this.messages.findIndex(
-					(m) => m.id === assistantMessage.id,
-				);
-				if (idx !== -1) {
-					this.messages[idx].content = "Error: No response stream available";
-				}
-				this.isLoading = false;
-				this.updateActiveConversation();
-				return;
-			}
-
-			const decoder = new TextDecoder();
-			let fullContent = "";
-			let partialLine = "";
-
-			while (true) {
-				const { done, value } = await reader.read();
-				if (done) break;
-
-				const chunk = decoder.decode(value, { stream: true });
-				const lines = (partialLine + chunk).split("\n");
-				partialLine = lines.pop() || "";
-
-				for (const line of lines) {
-					const trimmed = line.trim();
-					if (!trimmed || trimmed === "data: [DONE]") continue;
-
-					if (trimmed.startsWith("data: ")) {
-						try {
-							const json = JSON.parse(trimmed.slice(6));
-							const delta = json.choices?.[0]?.delta?.content;
-							if (delta) {
-								fullContent += delta;
-								const { displayContent, thinkingContent } =
-									this.stripThinkingTags(fullContent);
-								this.currentResponse = displayContent;
-
-								// Update the assistant message in place (triggers Svelte reactivity)
-								const idx = this.messages.findIndex(
-									(m) => m.id === assistantMessage.id,
-								);
-								if (idx !== -1) {
-									this.messages[idx].content = displayContent;
-									this.messages[idx].thinking = thinkingContent || undefined;
-								}
-								this.persistActiveConversation();
-							}
-						} catch {
-							// Skip malformed JSON
-						}
-					}
-				}
-			}
-
-			// Final cleanup of the message
-			const { displayContent, thinkingContent } =
-				this.stripThinkingTags(fullContent);
-			const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-			if (idx !== -1) {
-				this.messages[idx].content = displayContent;
-				this.messages[idx].thinking = thinkingContent || undefined;
-			}
-			this.persistActiveConversation();
-		} catch (error) {
-			const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-			if (idx !== -1) {
-				this.messages[idx].content =
-					`Error: ${error instanceof Error ? error.message : "Unknown error"}`;
-			}
-			this.persistActiveConversation();
-		} finally {
-			this.isLoading = false;
-			this.currentResponse = "";
-			this.updateActiveConversation();
-		}
-	}
-
-	/**
-	 * Selected model for chat (can be set by the UI)
-	 */
-	selectedChatModel = $state("");
-
-	/**
-	 * Set the model to use for chat
-	 */
-	setSelectedModel(modelId: string) {
-		this.selectedChatModel = modelId;
-		// Clear stats when model changes
-		this.ttftMs = null;
-		this.tps = null;
-	}
-
-	/**
-	 * Strip thinking tags from content for display.
-	 * Handles both complete <think>...</think> blocks and in-progress <think>... blocks during streaming.
-	 */
-	private stripThinkingTags(content: string): {
-		displayContent: string;
-		thinkingContent: string;
-	} {
-		const extracted: string[] = [];
-		let displayContent = content;
-
-		// Extract complete <think>...</think> blocks
-		const completeBlockRegex = /<think>([\s\S]*?)<\/think>/gi;
-		let match: RegExpExecArray | null;
-		while ((match = completeBlockRegex.exec(content)) !== null) {
-			const inner = match[1]?.trim();
-			if (inner) extracted.push(inner);
-		}
-		displayContent = displayContent.replace(completeBlockRegex, "");
-
-		// Handle in-progress thinking block (has <think> but no closing </think> yet)
-		const openTagIndex = displayContent.lastIndexOf("<think>");
-		if (openTagIndex !== -1) {
-			const inProgressThinking = displayContent.slice(openTagIndex + 7).trim();
-			if (inProgressThinking) {
-				extracted.push(inProgressThinking);
-			}
-			displayContent = displayContent.slice(0, openTagIndex);
-		}
-
-		return {
-			displayContent: displayContent.trim(),
-			thinkingContent: extracted.join("\n\n"),
-		};
-	}
-
-	/**
-	 * Send a message to the LLM and stream the response
-	 */
-	async sendMessage(
-		content: string,
-		files?: {
-			id: string;
-			name: string;
-			type: string;
-			textContent?: string;
-			preview?: string;
-		}[],
-	): Promise<void> {
-		if ((!content.trim() && (!files || files.length === 0)) || this.isLoading)
-			return;
-
-		if (!this.hasStartedChat) {
-			this.startChat();
-		}
-
-		this.isLoading = true;
-		this.currentResponse = "";
-		this.ttftMs = null;
-		this.tps = null;
-		this.totalTokens = 0;
-
-		// Build attachments from files
-		const attachments: MessageAttachment[] = [];
-		let fileContext = "";
-
-		if (files && files.length > 0) {
-			for (const file of files) {
-				const isImage = file.type.startsWith("image/");
-
-				if (isImage && file.preview) {
-					attachments.push({
-						type: "image",
-						name: file.name,
-						preview: file.preview,
-						mimeType: file.type,
-					});
-				} else if (file.textContent) {
-					attachments.push({
-						type: "text",
-						name: file.name,
-						content: file.textContent,
-						mimeType: file.type,
-					});
-					// Add text file content to the message context
-					fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
-				} else {
-					attachments.push({
-						type: "file",
-						name: file.name,
-						mimeType: file.type,
-					});
-				}
-			}
-		}
-
-		// Combine content with file context
-		const fullContent = content + fileContext;
-
-		// Add user message with attachments
-		const userMessage: Message = {
-			id: generateUUID(),
-			role: "user",
-			content: content, // Store original content for display
-			timestamp: Date.now(),
-			attachments: attachments.length > 0 ? attachments : undefined,
-		};
-		this.messages.push(userMessage);
-
-		// Create placeholder for assistant message
-		const assistantMessage = this.addMessage("assistant", "");
-		this.updateActiveConversation();
-
-		try {
-			// Build the messages array for the API with system prompt
-			const systemPrompt = {
-				role: "system" as const,
-				content:
-					"You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process. When files are shared with you, analyze them and respond helpfully.",
-			};
-
-			// Build API messages - include file content for text files
-			const apiMessages = [
-				systemPrompt,
-				...this.messages.slice(0, -1).map((m) => {
-					// Build content including any text file attachments
-					let msgContent = m.content;
-
-					// Add text attachments as context
-					if (m.attachments) {
-						for (const attachment of m.attachments) {
-							if (attachment.type === "text" && attachment.content) {
-								msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
-							}
-						}
-					}
-
-					return {
-						role: m.role,
-						content: msgContent,
-					};
-				}),
-			];
-
-			// Determine the model to use - prefer selectedChatModel, otherwise try to get from instances
-			let modelToUse = this.selectedChatModel;
-			if (!modelToUse) {
-				// Try to get model from first running instance
-				for (const [, instanceWrapper] of Object.entries(this.instances)) {
-					if (instanceWrapper && typeof instanceWrapper === "object") {
-						const keys = Object.keys(
-							instanceWrapper as Record<string, unknown>,
-						);
-						if (keys.length === 1) {
-							const instance = (instanceWrapper as Record<string, unknown>)[
-								keys[0]
-							] as { shardAssignments?: { modelId?: string } };
-							if (instance?.shardAssignments?.modelId) {
-								modelToUse = instance.shardAssignments.modelId;
-								break;
-							}
-						}
-					}
-				}
-			}
-
-			if (!modelToUse) {
-				throw new Error(
-					"No model selected and no running instances available. Please launch an instance first.",
-				);
-			}
-
-			const conversationModelInfo = this.buildConversationModelInfo(modelToUse);
-			this.applyConversationModelInfo(conversationModelInfo);
-
-			// Start timing for TTFT measurement
-			const requestStartTime = performance.now();
-			let firstTokenTime: number | null = null;
-			let tokenCount = 0;
-
-			const response = await fetch("/v1/chat/completions", {
-				method: "POST",
-				headers: {
-					"Content-Type": "application/json",
-				},
-				body: JSON.stringify({
-					model: modelToUse,
-					messages: apiMessages,
-					temperature: 0.7,
-					stream: true,
-				}),
-			});
-
-			if (!response.ok) {
-				const errorText = await response.text();
-				throw new Error(`API error: ${response.status} - ${errorText}`);
-			}
-
-			const reader = response.body?.getReader();
-			if (!reader) {
-				throw new Error("No response body");
-			}
-
-			const decoder = new TextDecoder();
-			let fullContent = "";
-			let buffer = "";
-
-			while (true) {
-				const { done, value } = await reader.read();
-				if (done) break;
-
-				buffer += decoder.decode(value, { stream: true });
-
-				// Process complete lines
-				const lines = buffer.split("\n");
-				buffer = lines.pop() || ""; // Keep incomplete line in buffer
-
-				for (const line of lines) {
-					const trimmed = line.trim();
-					if (!trimmed) continue;
-
-					if (trimmed.startsWith("data: ")) {
-						const data = trimmed.slice(6);
-						if (data === "[DONE]") continue;
-
-						try {
-							const parsed = JSON.parse(data);
-							const tokenContent = parsed.choices?.[0]?.delta?.content;
-							if (tokenContent) {
-								// Track first token for TTFT
-								if (firstTokenTime === null) {
-									firstTokenTime = performance.now();
-									this.ttftMs = firstTokenTime - requestStartTime;
-								}
-
-								// Count tokens (each SSE chunk is typically one token)
-								tokenCount += 1;
-								this.totalTokens = tokenCount;
-
-								// Update real-time TPS during streaming
-								if (firstTokenTime !== null && tokenCount > 1) {
-									const elapsed = performance.now() - firstTokenTime;
-									this.tps = (tokenCount / elapsed) * 1000;
-								}
-
-								fullContent += tokenContent;
-
-								// Strip thinking tags for display and extract thinking content
-								const { displayContent, thinkingContent } =
-									this.stripThinkingTags(fullContent);
-								this.currentResponse = displayContent;
-
-								// Update the assistant message in place
-								const idx = this.messages.findIndex(
-									(m) => m.id === assistantMessage.id,
-								);
-								if (idx !== -1) {
-									this.messages[idx].content = displayContent;
-									this.messages[idx].thinking = thinkingContent || undefined;
-								}
-								this.persistActiveConversation();
-							}
-						} catch {
-							// Skip invalid JSON lines
-						}
-					}
-				}
-			}
-
-			// Process any remaining buffer
-			if (buffer.trim()) {
-				const trimmed = buffer.trim();
-				if (trimmed.startsWith("data: ") && trimmed.slice(6) !== "[DONE]") {
-					try {
-						const parsed = JSON.parse(trimmed.slice(6));
-						const tokenContent = parsed.choices?.[0]?.delta?.content;
-						if (tokenContent) {
-							fullContent += tokenContent;
-							this.persistActiveConversation();
-						}
-					} catch {
-						// Skip
-					}
-				}
-			}
-
-			// Calculate final TPS
-			if (firstTokenTime !== null && tokenCount > 1) {
-				const totalGenerationTime = performance.now() - firstTokenTime;
-				this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
-			}
-
-			// Final cleanup of the message
-			const { displayContent, thinkingContent } =
-				this.stripThinkingTags(fullContent);
-			const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-			if (idx !== -1) {
-				this.messages[idx].content = displayContent;
-				this.messages[idx].thinking = thinkingContent || undefined;
-				// Store performance metrics on the message
-				if (this.ttftMs !== null) {
-					this.messages[idx].ttftMs = this.ttftMs;
-				}
-				if (this.tps !== null) {
-					this.messages[idx].tps = this.tps;
-				}
-			}
-			this.persistActiveConversation();
-		} catch (error) {
-			console.error("Error sending message:", error);
-			// Update the assistant message with error
-			const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-			if (idx !== -1) {
-				this.messages[idx].content =
-					`Error: ${error instanceof Error ? error.message : "Failed to get response"}`;
-			}
-			this.persistActiveConversation();
-		} finally {
-			this.isLoading = false;
-			this.currentResponse = "";
-			this.updateActiveConversation();
-		}
-	}
-
-	/**
-	 * Generate an image using the image generation API
-	 */
-	async generateImage(prompt: string, modelId?: string): Promise<void> {
-		if (!prompt.trim() || this.isLoading) return;
-
-		if (!this.hasStartedChat) {
-			this.startChat();
-		}
-
-		this.isLoading = true;
-		this.currentResponse = "";
-
-		// Add user message
-		const userMessage: Message = {
-			id: generateUUID(),
-			role: "user",
-			content: prompt,
-			timestamp: Date.now(),
-		};
-		this.messages.push(userMessage);
-
-		// Create placeholder for assistant message with generating state
-		const assistantMessage = this.addMessage("assistant", "");
-		this.messages[this.messages.length - 1].content = "Generating image...";
-		this.updateActiveConversation();
-
-		try {
-			// Determine the model to use
-			let model = modelId || this.selectedChatModel;
-			if (!model) {
-				throw new Error(
-					"No model selected. Please select an image generation model.",
-				);
-			}
-
-			// Build request body using image generation params
-			const params = this.imageGenerationParams;
-			const hasAdvancedParams =
-				params.seed !== null ||
-				params.numInferenceSteps !== null ||
-				params.guidance !== null ||
-				(params.negativePrompt !== null && params.negativePrompt.trim() !== "");
-
-			const requestBody: Record<string, unknown> = {
-				model,
-				prompt,
-				quality: params.quality,
-				size: params.size,
-				output_format: params.outputFormat,
-				response_format: "b64_json",
-				stream: true,
-				partial_images: 3,
-			};
-
-			if (hasAdvancedParams) {
-				requestBody.advanced_params = {
-					...(params.seed !== null && { seed: params.seed }),
-					...(params.numInferenceSteps !== null && {
-						num_inference_steps: params.numInferenceSteps,
-					}),
-					...(params.guidance !== null && { guidance: params.guidance }),
-					...(params.negativePrompt !== null &&
-						params.negativePrompt.trim() !== "" && {
-							negative_prompt: params.negativePrompt,
-						}),
-				};
-			}
-
-			const response = await fetch("/v1/images/generations", {
-				method: "POST",
-				headers: {
-					"Content-Type": "application/json",
-				},
-				body: JSON.stringify(requestBody),
-			});
-
-			if (!response.ok) {
-				const errorText = await response.text();
-				throw new Error(`API error: ${response.status} - ${errorText}`);
-			}
-
-			const reader = response.body?.getReader();
-			if (!reader) {
-				throw new Error("No response body");
-			}
-
-			const decoder = new TextDecoder();
-			let buffer = "";
-			const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-
-			while (true) {
-				const { done, value } = await reader.read();
-				if (done) break;
-
-				buffer += decoder.decode(value, { stream: true });
-
-				// Process complete lines
-				const lines = buffer.split("\n");
-				buffer = lines.pop() || ""; // Keep incomplete line in buffer
-
-				for (const line of lines) {
-					const trimmed = line.trim();
-					if (!trimmed) continue;
-
-					if (trimmed.startsWith("data: ")) {
-						const data = trimmed.slice(6);
-						if (data === "[DONE]") continue;
-
-						try {
-							const parsed = JSON.parse(data);
-							const imageData = parsed.data?.b64_json;
-
-							if (imageData && idx !== -1) {
-								const format = parsed.format || "png";
-								const mimeType = `image/${format}`;
-								if (parsed.type === "partial") {
-									// Update with partial image and progress
-									const partialNum = (parsed.partial_index ?? 0) + 1;
-									const totalPartials = parsed.total_partials ?? 3;
-									this.messages[idx].content =
-										`Generating... ${partialNum}/${totalPartials}`;
-									this.messages[idx].attachments = [
-										{
-											type: "generated-image",
-											name: `generated-image.${format}`,
-											preview: `data:${mimeType};base64,${imageData}`,
-											mimeType,
-										},
-									];
-								} else if (parsed.type === "final") {
-									// Final image
-									this.messages[idx].content = "";
-									this.messages[idx].attachments = [
-										{
-											type: "generated-image",
-											name: `generated-image.${format}`,
-											preview: `data:${mimeType};base64,${imageData}`,
-											mimeType,
-										},
-									];
-								}
-							}
-						} catch {
-							// Ignore parse errors for incomplete JSON
-						}
-					}
-				}
-			}
-		} catch (error) {
-			console.error("Error generating image:", error);
-			const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-			if (idx !== -1) {
-				this.messages[idx].content =
-					`Error: ${error instanceof Error ? error.message : "Failed to generate image"}`;
-			}
-		} finally {
-			this.isLoading = false;
-			this.updateActiveConversation();
-		}
-	}
-
-	/**
-	 * Edit an image using the image edit API
-	 */
-	async editImage(
-		prompt: string,
-		imageDataUrl: string,
-		modelId?: string,
-	): Promise<void> {
-		if (!prompt.trim() || !imageDataUrl || this.isLoading) return;
-
-		if (!this.hasStartedChat) {
-			this.startChat();
-		}
-
-		this.isLoading = true;
-		this.currentResponse = "";
-
-		// Add user message with the edit prompt
-		const userMessage: Message = {
-			id: generateUUID(),
-			role: "user",
-			content: prompt,
-			timestamp: Date.now(),
-		};
-		this.messages.push(userMessage);
-
-		// Create placeholder for assistant message with generating state
-		const assistantMessage = this.addMessage("assistant", "");
-		this.messages[this.messages.length - 1].content = "Editing image...";
-		this.updateActiveConversation();
-
-		// Clear editing state
-		this.editingImage = null;
-
-		try {
-			// Determine the model to use
-			let model = modelId || this.selectedChatModel;
-			if (!model) {
-				throw new Error(
-					"No model selected. Please select an image generation model.",
-				);
-			}
-
-			// Convert base64 data URL to blob
-			const response = await fetch(imageDataUrl);
-			const imageBlob = await response.blob();
-
-			// Build FormData request
-			const formData = new FormData();
-			formData.append("model", model);
-			formData.append("prompt", prompt);
-			formData.append("image", imageBlob, "image.png");
-
-			// Add params from image generation params
-			const params = this.imageGenerationParams;
-			formData.append("quality", params.quality);
-			formData.append("size", params.size);
-			formData.append("output_format", params.outputFormat);
-			formData.append("response_format", "b64_json");
-			formData.append("stream", "1"); // Use "1" instead of "true" for reliable FastAPI boolean parsing
-			formData.append("partial_images", "3");
-			formData.append("input_fidelity", params.inputFidelity);
-
-			// Advanced params
-			if (params.seed !== null) {
-				formData.append(
-					"advanced_params",
-					JSON.stringify({
-						seed: params.seed,
-						...(params.numInferenceSteps !== null && {
-							num_inference_steps: params.numInferenceSteps,
-						}),
-						...(params.guidance !== null && { guidance: params.guidance }),
-						...(params.negativePrompt !== null &&
-							params.negativePrompt.trim() !== "" && {
-								negative_prompt: params.negativePrompt,
-							}),
-					}),
-				);
-			} else if (
-				params.numInferenceSteps !== null ||
-				params.guidance !== null ||
-				(params.negativePrompt !== null && params.negativePrompt.trim() !== "")
-			) {
-				formData.append(
-					"advanced_params",
-					JSON.stringify({
-						...(params.numInferenceSteps !== null && {
-							num_inference_steps: params.numInferenceSteps,
-						}),
-						...(params.guidance !== null && { guidance: params.guidance }),
-						...(params.negativePrompt !== null &&
-							params.negativePrompt.trim() !== "" && {
-								negative_prompt: params.negativePrompt,
-							}),
-					}),
-				);
-			}
-
-			const apiResponse = await fetch("/v1/images/edits", {
-				method: "POST",
-				body: formData,
-			});
-
-			if (!apiResponse.ok) {
-				const errorText = await apiResponse.text();
-				throw new Error(`API error: ${apiResponse.status} - ${errorText}`);
-			}
-
-			const reader = apiResponse.body?.getReader();
-			if (!reader) {
-				throw new Error("No response body");
-			}
-
-			const decoder = new TextDecoder();
-			let buffer = "";
-			const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-
-			while (true) {
-				const { done, value } = await reader.read();
-				if (done) break;
-
-				buffer += decoder.decode(value, { stream: true });
-
-				// Process complete lines
-				const lines = buffer.split("\n");
-				buffer = lines.pop() || ""; // Keep incomplete line in buffer
-
-				for (const line of lines) {
-					const trimmed = line.trim();
-					if (!trimmed) continue;
-
-					if (trimmed.startsWith("data: ")) {
-						const data = trimmed.slice(6);
-						if (data === "[DONE]") continue;
-
-						try {
-							const parsed = JSON.parse(data);
-							const imageData = parsed.data?.b64_json;
-
-							if (imageData && idx !== -1) {
-								const format = parsed.format || "png";
-								const mimeType = `image/${format}`;
-								if (parsed.type === "partial") {
-									// Update with partial image and progress
-									const partialNum = (parsed.partial_index ?? 0) + 1;
-									const totalPartials = parsed.total_partials ?? 3;
-									this.messages[idx].content =
-										`Editing... ${partialNum}/${totalPartials}`;
-									this.messages[idx].attachments = [
-										{
-											type: "generated-image",
-											name: `edited-image.${format}`,
-											preview: `data:${mimeType};base64,${imageData}`,
-											mimeType,
-										},
-									];
-								} else if (parsed.type === "final") {
-									// Final image
-									this.messages[idx].content = "";
-									this.messages[idx].attachments = [
-										{
-											type: "generated-image",
-											name: `edited-image.${format}`,
-											preview: `data:${mimeType};base64,${imageData}`,
-											mimeType,
-										},
-									];
-								}
-							}
-						} catch {
-							// Ignore parse errors for incomplete JSON
-						}
-					}
-				}
-			}
-		} catch (error) {
-			console.error("Error editing image:", error);
-			const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-			if (idx !== -1) {
-				this.messages[idx].content =
-					`Error: ${error instanceof Error ? error.message : "Failed to edit image"}`;
-			}
-		} finally {
-			this.isLoading = false;
-			this.updateActiveConversation();
-		}
-	}
-
-	/**
-	 * Clear current chat and go back to welcome state
-	 */
-	clearChat() {
-		this.activeConversationId = null;
-		this.messages = [];
-		this.hasStartedChat = false;
-		this.isTopologyMinimized = false;
-		this.currentResponse = "";
-		// Clear performance stats
-		this.ttftMs = null;
-		this.tps = null;
-	}
-
-	/**
-	 * Get the active conversation
-	 */
-	getActiveConversation(): Conversation | null {
-		if (!this.activeConversationId) return null;
-		return (
-			this.conversations.find((c) => c.id === this.activeConversationId) || null
-		);
-	}
+  // Conversation state
+  conversations = $state<Conversation[]>([]);
+  activeConversationId = $state<string | null>(null);
+
+  // Chat state
+  hasStartedChat = $state(false);
+  messages = $state<Message[]>([]);
+  currentResponse = $state("");
+  isLoading = $state(false);
+
+  // Performance metrics
+  ttftMs = $state<number | null>(null); // Time to first token in ms
+  tps = $state<number | null>(null); // Tokens per second
+  totalTokens = $state<number>(0); // Total tokens in current response
+
+  // Topology state
+  topologyData = $state<TopologyData | null>(null);
+  instances = $state<Record<string, unknown>>({});
+  runners = $state<Record<string, unknown>>({});
+  downloads = $state<Record<string, unknown[]>>({});
+  placementPreviews = $state<PlacementPreview[]>([]);
+  selectedPreviewModelId = $state<string | null>(null);
+  isLoadingPreviews = $state(false);
+  lastUpdate = $state<number | null>(null);
+
+  // UI state
+  isTopologyMinimized = $state(false);
+  isSidebarOpen = $state(false); // Hidden by default, shown when in chat mode
+  debugMode = $state(false);
+  topologyOnlyMode = $state(false);
+  chatSidebarVisible = $state(true); // Shown by default
+
+  // Image generation params
+  imageGenerationParams = $state<ImageGenerationParams>({
+    ...DEFAULT_IMAGE_PARAMS,
+  });
+
+  // Image editing state
+  editingImage = $state<EditingImage | null>(null);
+
+  private fetchInterval: ReturnType<typeof setInterval> | null = null;
+  private previewsInterval: ReturnType<typeof setInterval> | null = null;
+  private lastConversationPersistTs = 0;
+
+  constructor() {
+    if (browser) {
+      this.startPolling();
+      this.loadConversationsFromStorage();
+      this.loadDebugModeFromStorage();
+      this.loadTopologyOnlyModeFromStorage();
+      this.loadChatSidebarVisibleFromStorage();
+      this.loadImageGenerationParamsFromStorage();
+    }
+  }
+
+  /**
+   * Load conversations from localStorage
+   */
+  private loadConversationsFromStorage() {
+    try {
+      const stored = localStorage.getItem(STORAGE_KEY);
+      if (stored) {
+        const parsed = JSON.parse(stored) as Array<Partial<Conversation>>;
+        this.conversations = parsed.map((conversation) => ({
+          id: conversation.id ?? generateUUID(),
+          name: conversation.name ?? "Chat",
+          messages: conversation.messages ?? [],
+          createdAt: conversation.createdAt ?? Date.now(),
+          updatedAt: conversation.updatedAt ?? Date.now(),
+          modelId: conversation.modelId ?? null,
+          sharding: conversation.sharding ?? null,
+          instanceType: conversation.instanceType ?? null,
+        }));
+      }
+    } catch (error) {
+      console.error("Failed to load conversations:", error);
+    }
+  }
+
+  /**
+   * Save conversations to localStorage
+   */
+  private saveConversationsToStorage() {
+    try {
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(this.conversations));
+    } catch (error) {
+      console.error("Failed to save conversations:", error);
+    }
+  }
+
+  private loadDebugModeFromStorage() {
+    try {
+      const stored = localStorage.getItem("exo-debug-mode");
+      if (stored !== null) {
+        this.debugMode = stored === "true";
+      }
+    } catch (error) {
+      console.error("Failed to load debug mode:", error);
+    }
+  }
+
+  private saveDebugModeToStorage() {
+    try {
+      localStorage.setItem("exo-debug-mode", this.debugMode ? "true" : "false");
+    } catch (error) {
+      console.error("Failed to save debug mode:", error);
+    }
+  }
+
+  private loadTopologyOnlyModeFromStorage() {
+    try {
+      const stored = localStorage.getItem("exo-topology-only-mode");
+      if (stored !== null) {
+        this.topologyOnlyMode = stored === "true";
+      }
+    } catch (error) {
+      console.error("Failed to load topology only mode:", error);
+    }
+  }
+
+  private saveTopologyOnlyModeToStorage() {
+    try {
+      localStorage.setItem(
+        "exo-topology-only-mode",
+        this.topologyOnlyMode ? "true" : "false",
+      );
+    } catch (error) {
+      console.error("Failed to save topology only mode:", error);
+    }
+  }
+
+  private loadChatSidebarVisibleFromStorage() {
+    try {
+      const stored = localStorage.getItem("exo-chat-sidebar-visible");
+      if (stored !== null) {
+        this.chatSidebarVisible = stored === "true";
+      }
+    } catch (error) {
+      console.error("Failed to load chat sidebar visibility:", error);
+    }
+  }
+
+  private saveChatSidebarVisibleToStorage() {
+    try {
+      localStorage.setItem(
+        "exo-chat-sidebar-visible",
+        this.chatSidebarVisible ? "true" : "false",
+      );
+    } catch (error) {
+      console.error("Failed to save chat sidebar visibility:", error);
+    }
+  }
+
+  private loadImageGenerationParamsFromStorage() {
+    try {
+      const stored = localStorage.getItem(IMAGE_PARAMS_STORAGE_KEY);
+      if (stored) {
+        const parsed = JSON.parse(stored) as Partial<ImageGenerationParams>;
+        this.imageGenerationParams = {
+          ...DEFAULT_IMAGE_PARAMS,
+          ...parsed,
+        };
+      }
+    } catch (error) {
+      console.error("Failed to load image generation params:", error);
+    }
+  }
+
+  private saveImageGenerationParamsToStorage() {
+    try {
+      localStorage.setItem(
+        IMAGE_PARAMS_STORAGE_KEY,
+        JSON.stringify(this.imageGenerationParams),
+      );
+    } catch (error) {
+      console.error("Failed to save image generation params:", error);
+    }
+  }
+
+  getImageGenerationParams(): ImageGenerationParams {
+    return this.imageGenerationParams;
+  }
+
+  setImageGenerationParams(params: Partial<ImageGenerationParams>) {
+    this.imageGenerationParams = {
+      ...this.imageGenerationParams,
+      ...params,
+    };
+    this.saveImageGenerationParamsToStorage();
+  }
+
+  resetImageGenerationParams() {
+    this.imageGenerationParams = { ...DEFAULT_IMAGE_PARAMS };
+    this.saveImageGenerationParamsToStorage();
+  }
+
+  setEditingImage(imageDataUrl: string, sourceMessage: Message) {
+    this.editingImage = { imageDataUrl, sourceMessage };
+  }
+
+  clearEditingImage() {
+    this.editingImage = null;
+  }
+
+  /**
+   * Create a new conversation
+   */
+  createConversation(name?: string): string {
+    const id = generateUUID();
+    const now = Date.now();
+
+    // Try to derive model and strategy immediately from selected model or running instances
+    let derivedModelId = this.selectedChatModel || null;
+    let derivedInstanceType: string | null = null;
+    let derivedSharding: string | null = null;
+
+    // If no selected model, fall back to the first running instance
+    if (!derivedModelId) {
+      const firstInstance = Object.values(this.instances)[0];
+      if (firstInstance) {
+        const candidateModel = this.extractInstanceModelId(firstInstance);
+        derivedModelId = candidateModel ?? null;
+        const details = this.describeInstance(firstInstance);
+        derivedInstanceType = details.instanceType;
+        derivedSharding = details.sharding;
+      }
+    } else {
+      // If selected model is set, attempt to get its details from instances
+      for (const [, instanceWrapper] of Object.entries(this.instances)) {
+        const candidateModelId = this.extractInstanceModelId(instanceWrapper);
+        if (candidateModelId === derivedModelId) {
+          const details = this.describeInstance(instanceWrapper);
+          derivedInstanceType = details.instanceType;
+          derivedSharding = details.sharding;
+          break;
+        }
+      }
+    }
+
+    const conversation: Conversation = {
+      id,
+      name:
+        name ||
+        `Chat ${new Date(now).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`,
+      messages: [],
+      createdAt: now,
+      updatedAt: now,
+      modelId: derivedModelId,
+      sharding: derivedSharding,
+      instanceType: derivedInstanceType,
+    };
+
+    this.conversations.unshift(conversation);
+    this.activeConversationId = id;
+    this.messages = [];
+    this.hasStartedChat = true;
+    this.isTopologyMinimized = true;
+    this.isSidebarOpen = true; // Auto-open sidebar when chatting
+
+    this.saveConversationsToStorage();
+    return id;
+  }
+
+  /**
+   * Load a conversation by ID
+   */
+  loadConversation(id: string): boolean {
+    const conversation = this.conversations.find((c) => c.id === id);
+    if (!conversation) return false;
+
+    this.activeConversationId = id;
+    this.messages = [...conversation.messages];
+    this.hasStartedChat = true;
+    this.isTopologyMinimized = true;
+    this.isSidebarOpen = true; // Auto-open sidebar when chatting
+    this.refreshConversationModelFromInstances();
+
+    return true;
+  }
+
+  /**
+   * Delete a conversation by ID
+   */
+  deleteConversation(id: string) {
+    this.conversations = this.conversations.filter((c) => c.id !== id);
+
+    if (this.activeConversationId === id) {
+      this.activeConversationId = null;
+      this.messages = [];
+      this.hasStartedChat = false;
+      this.isTopologyMinimized = false;
+    }
+
+    this.saveConversationsToStorage();
+  }
+
+  /**
+   * Delete all conversations
+   */
+  deleteAllConversations() {
+    this.conversations = [];
+    this.activeConversationId = null;
+    this.messages = [];
+    this.hasStartedChat = false;
+    this.isTopologyMinimized = false;
+    this.saveConversationsToStorage();
+  }
+
+  /**
+   * Rename a conversation
+   */
+  renameConversation(id: string, newName: string) {
+    const conversation = this.conversations.find((c) => c.id === id);
+    if (conversation) {
+      conversation.name = newName;
+      conversation.updatedAt = Date.now();
+      this.saveConversationsToStorage();
+    }
+  }
+
+  private getTaggedValue(obj: unknown): [string | null, unknown] {
+    if (!obj || typeof obj !== "object") return [null, null];
+    const keys = Object.keys(obj as Record<string, unknown>);
+    if (keys.length === 1) {
+      return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
+    }
+    return [null, null];
+  }
+
+  private extractInstanceModelId(instanceWrapped: unknown): string | null {
+    const [, instance] = this.getTaggedValue(instanceWrapped);
+    if (!instance || typeof instance !== "object") return null;
+    const inst = instance as { shardAssignments?: { modelId?: string } };
+    return inst.shardAssignments?.modelId ?? null;
+  }
+
+  private describeInstance(instanceWrapped: unknown): {
+    sharding: string | null;
+    instanceType: string | null;
+  } {
+    const [instanceTag, instance] = this.getTaggedValue(instanceWrapped);
+    if (!instance || typeof instance !== "object") {
+      return { sharding: null, instanceType: null };
+    }
+
+    let instanceType: string | null = null;
+    if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
+    else if (
+      instanceTag === "MlxIbvInstance" ||
+      instanceTag === "MlxJacclInstance"
+    )
+      instanceType = "MLX RDMA";
+
+    let sharding: string | null = null;
+    const inst = instance as {
+      shardAssignments?: { runnerToShard?: Record<string, unknown> };
+    };
+    const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+    const firstShardWrapped = Object.values(runnerToShard)[0];
+    if (firstShardWrapped) {
+      const [shardTag] = this.getTaggedValue(firstShardWrapped);
+      if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
+      else if (shardTag === "TensorShardMetadata") sharding = "Tensor";
+      else if (shardTag === "PrefillDecodeShardMetadata")
+        sharding = "Prefill/Decode";
+    }
+
+    return { sharding, instanceType };
+  }
+
+  private buildConversationModelInfo(modelId: string): {
+    modelId: string;
+    sharding: string | null;
+    instanceType: string | null;
+  } {
+    let sharding: string | null = null;
+    let instanceType: string | null = null;
+
+    for (const [, instanceWrapper] of Object.entries(this.instances)) {
+      const candidateModelId = this.extractInstanceModelId(instanceWrapper);
+      if (candidateModelId === modelId) {
+        const details = this.describeInstance(instanceWrapper);
+        sharding = details.sharding;
+        instanceType = details.instanceType;
+        break;
+      }
+    }
+
+    return { modelId, sharding, instanceType };
+  }
+
+  private applyConversationModelInfo(info: {
+    modelId: string;
+    sharding: string | null;
+    instanceType: string | null;
+  }) {
+    if (!this.activeConversationId) return;
+    const conversation = this.conversations.find(
+      (c) => c.id === this.activeConversationId,
+    );
+    if (!conversation) return;
+
+    // Keep the first known modelId stable; only backfill if missing
+    if (!conversation.modelId) {
+      conversation.modelId = info.modelId;
+    }
+    conversation.sharding = info.sharding;
+    conversation.instanceType = info.instanceType;
+    this.saveConversationsToStorage();
+  }
+
+  private getModelTail(modelId: string): string {
+    const parts = modelId.split("/");
+    return (parts[parts.length - 1] || modelId).toLowerCase();
+  }
+
+  private isBetterModelId(
+    currentId: string | null,
+    candidateId: string | null,
+  ): boolean {
+    if (!candidateId) return false;
+    if (!currentId) return true;
+    const currentTail = this.getModelTail(currentId);
+    const candidateTail = this.getModelTail(candidateId);
+    return (
+      candidateTail.length > currentTail.length &&
+      candidateTail.startsWith(currentTail)
+    );
+  }
+
+  private refreshConversationModelFromInstances() {
+    if (!this.activeConversationId) return;
+    const conversation = this.conversations.find(
+      (c) => c.id === this.activeConversationId,
+    );
+    if (!conversation) return;
+
+    // Prefer stored model; do not replace it once set. Only backfill when missing.
+    let modelId = conversation.modelId;
+
+    // If missing, try the selected model
+    if (!modelId && this.selectedChatModel) {
+      modelId = this.selectedChatModel;
+    }
+
+    // If still missing, fall back to first instance model
+    if (!modelId) {
+      const firstInstance = Object.values(this.instances)[0];
+      if (firstInstance) {
+        modelId = this.extractInstanceModelId(firstInstance);
+      }
+    }
+
+    if (!modelId) return;
+
+    // If a more specific instance modelId is available (e.g., adds "-4bit"), prefer it
+    let preferredModelId = modelId;
+    for (const [, instanceWrapper] of Object.entries(this.instances)) {
+      const candidate = this.extractInstanceModelId(instanceWrapper);
+      if (!candidate) continue;
+      if (candidate === preferredModelId) {
+        break;
+      }
+      if (this.isBetterModelId(preferredModelId, candidate)) {
+        preferredModelId = candidate;
+      }
+    }
+
+    if (this.isBetterModelId(conversation.modelId, preferredModelId)) {
+      conversation.modelId = preferredModelId;
+    }
+
+    const info = this.buildConversationModelInfo(preferredModelId);
+    const hasNewInfo = Boolean(
+      info.sharding || info.instanceType || !conversation.modelId,
+    );
+    if (hasNewInfo) {
+      this.applyConversationModelInfo(info);
+    }
+  }
+
+  getDebugMode(): boolean {
+    return this.debugMode;
+  }
+
+  /**
+   * Update the active conversation with current messages
+   */
+  private updateActiveConversation() {
+    if (!this.activeConversationId) return;
+
+    const conversation = this.conversations.find(
+      (c) => c.id === this.activeConversationId,
+    );
+    if (conversation) {
+      conversation.messages = [...this.messages];
+      conversation.updatedAt = Date.now();
+
+      // Auto-generate name from first user message if still has default name
+      if (conversation.name.startsWith("Chat ")) {
+        const firstUserMsg = conversation.messages.find(
+          (m) => m.role === "user" && m.content.trim(),
+        );
+        if (firstUserMsg) {
+          // Clean up the content - remove file context markers and whitespace
+          let content = firstUserMsg.content
+            .replace(/\[File:.*?\][\s\S]*?```[\s\S]*?```/g, "") // Remove file attachments
+            .trim();
+
+          if (content) {
+            const preview = content.slice(0, 50);
+            conversation.name =
+              preview.length < content.length ? preview + "..." : preview;
+          }
+        }
+      }
+
+      this.saveConversationsToStorage();
+    }
+  }
+
+  private persistActiveConversation(throttleMs = 400) {
+    const now = Date.now();
+    if (now - this.lastConversationPersistTs < throttleMs) return;
+    this.lastConversationPersistTs = now;
+    this.updateActiveConversation();
+  }
+
+  /**
+   * Toggle sidebar visibility
+   */
+  toggleSidebar() {
+    this.isSidebarOpen = !this.isSidebarOpen;
+  }
+
+  setDebugMode(enabled: boolean) {
+    this.debugMode = enabled;
+    this.saveDebugModeToStorage();
+  }
+
+  toggleDebugMode() {
+    this.debugMode = !this.debugMode;
+    this.saveDebugModeToStorage();
+  }
+
+  getTopologyOnlyMode(): boolean {
+    return this.topologyOnlyMode;
+  }
+
+  setTopologyOnlyMode(enabled: boolean) {
+    this.topologyOnlyMode = enabled;
+    this.saveTopologyOnlyModeToStorage();
+  }
+
+  toggleTopologyOnlyMode() {
+    this.topologyOnlyMode = !this.topologyOnlyMode;
+    this.saveTopologyOnlyModeToStorage();
+  }
+
+  getChatSidebarVisible(): boolean {
+    return this.chatSidebarVisible;
+  }
+
+  setChatSidebarVisible(visible: boolean) {
+    this.chatSidebarVisible = visible;
+    this.saveChatSidebarVisibleToStorage();
+  }
+
+  toggleChatSidebarVisible() {
+    this.chatSidebarVisible = !this.chatSidebarVisible;
+    this.saveChatSidebarVisibleToStorage();
+  }
+
+  startPolling() {
+    this.fetchState();
+    this.fetchInterval = setInterval(() => this.fetchState(), 1000);
+  }
+
+  stopPolling() {
+    if (this.fetchInterval) {
+      clearInterval(this.fetchInterval);
+      this.fetchInterval = null;
+    }
+    this.stopPreviewsPolling();
+  }
+
+  async fetchState() {
+    try {
+      const response = await fetch("/state");
+      if (!response.ok) {
+        throw new Error(`Failed to fetch state: ${response.status}`);
+      }
+      const data: RawStateResponse = await response.json();
+
+      if (data.topology) {
+        this.topologyData = transformTopology(data.topology, {
+          nodeIdentities: data.nodeIdentities,
+          nodeMemory: data.nodeMemory,
+          nodeSystem: data.nodeSystem,
+          nodeNetwork: data.nodeNetwork,
+        });
+      }
+      if (data.instances) {
+        this.instances = data.instances;
+        this.refreshConversationModelFromInstances();
+      }
+      if (data.runners) {
+        this.runners = data.runners;
+      }
+      if (data.downloads) {
+        this.downloads = data.downloads;
+      }
+      this.lastUpdate = Date.now();
+    } catch (error) {
+      console.error("Error fetching state:", error);
+    }
+  }
+
+  async fetchPlacementPreviews(modelId: string, showLoading = true) {
+    if (!modelId) return;
+
+    if (showLoading) {
+      this.isLoadingPreviews = true;
+    }
+    this.selectedPreviewModelId = modelId;
+
+    try {
+      const response = await fetch(
+        `/instance/previews?model_id=${encodeURIComponent(modelId)}`,
+      );
+      if (!response.ok) {
+        throw new Error(
+          `Failed to fetch placement previews: ${response.status}`,
+        );
+      }
+      const data: PlacementPreviewResponse = await response.json();
+      this.placementPreviews = data.previews;
+    } catch (error) {
+      console.error("Error fetching placement previews:", error);
+      this.placementPreviews = [];
+    } finally {
+      if (showLoading) {
+        this.isLoadingPreviews = false;
+      }
+    }
+  }
+
+  startPreviewsPolling(modelId: string) {
+    // Stop any existing preview polling
+    this.stopPreviewsPolling();
+
+    // Fetch immediately
+    this.fetchPlacementPreviews(modelId);
+
+    // Then poll every 15 seconds (don't show loading spinner for subsequent fetches)
+    this.previewsInterval = setInterval(() => {
+      if (this.selectedPreviewModelId) {
+        this.fetchPlacementPreviews(this.selectedPreviewModelId, false);
+      }
+    }, 15000);
+  }
+
+  stopPreviewsPolling() {
+    if (this.previewsInterval) {
+      clearInterval(this.previewsInterval);
+      this.previewsInterval = null;
+    }
+  }
+
+  selectPreviewModel(modelId: string | null) {
+    if (modelId) {
+      this.startPreviewsPolling(modelId);
+    } else {
+      this.stopPreviewsPolling();
+      this.selectedPreviewModelId = null;
+      this.placementPreviews = [];
+    }
+  }
+
+  /**
+   * Starts a chat conversation - triggers the topology minimization animation
+   * Creates a new conversation if none is active
+   */
+  startChat() {
+    if (!this.activeConversationId) {
+      this.createConversation();
+    } else {
+      this.hasStartedChat = true;
+      this.isSidebarOpen = true; // Auto-open sidebar when chatting
+      // Small delay before minimizing for a nice visual effect
+      setTimeout(() => {
+        this.isTopologyMinimized = true;
+      }, 100);
+    }
+  }
+
+  /**
+   * Add a message to the conversation
+   */
+  addMessage(role: "user" | "assistant", content: string) {
+    const message: Message = {
+      id: generateUUID(),
+      role,
+      content,
+      timestamp: Date.now(),
+    };
+    this.messages.push(message);
+    return message;
+  }
+
+  /**
+   * Delete a message and all subsequent messages
+   */
+  deleteMessage(messageId: string) {
+    const messageIndex = this.messages.findIndex((m) => m.id === messageId);
+    if (messageIndex === -1) return;
+
+    // Remove this message and all subsequent messages
+    this.messages = this.messages.slice(0, messageIndex);
+    this.updateActiveConversation();
+  }
+
+  /**
+   * Edit a user message content (does not regenerate response)
+   */
+  editMessage(messageId: string, newContent: string) {
+    const message = this.messages.find((m) => m.id === messageId);
+    if (!message) return;
+
+    message.content = newContent;
+    message.timestamp = Date.now();
+    this.updateActiveConversation();
+  }
+
+  /**
+   * Edit a user message and regenerate the response
+   */
+  async editAndRegenerate(
+    messageId: string,
+    newContent: string,
+  ): Promise<void> {
+    const messageIndex = this.messages.findIndex((m) => m.id === messageId);
+    if (messageIndex === -1) return;
+
+    const message = this.messages[messageIndex];
+    if (message.role !== "user") return;
+
+    // Update the message content
+    message.content = newContent;
+    message.timestamp = Date.now();
+
+    // Remove all messages after this one (including the assistant response)
+    this.messages = this.messages.slice(0, messageIndex + 1);
+
+    // Regenerate the response
+    await this.regenerateLastResponse();
+  }
+
+  /**
+   * Regenerate the last assistant response
+   */
+  async regenerateLastResponse(): Promise<void> {
+    if (this.isLoading) return;
+
+    // Find the last user message
+    let lastUserIndex = -1;
+    for (let i = this.messages.length - 1; i >= 0; i--) {
+      if (this.messages[i].role === "user") {
+        lastUserIndex = i;
+        break;
+      }
+    }
+
+    if (lastUserIndex === -1) return;
+
+    // Remove any messages after the user message
+    this.messages = this.messages.slice(0, lastUserIndex + 1);
+
+    // Resend the message to get a new response
+    this.isLoading = true;
+    this.currentResponse = "";
+
+    // Create placeholder for assistant message
+    const assistantMessage = this.addMessage("assistant", "");
+
+    try {
+      const systemPrompt = {
+        role: "system" as const,
+        content:
+          "You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process.",
+      };
+
+      const apiMessages = [
+        systemPrompt,
+        ...this.messages.slice(0, -1).map((m) => {
+          return { role: m.role, content: m.content };
+        }),
+      ];
+
+      // Determine which model to use
+      let modelToUse = this.selectedChatModel;
+      if (!modelToUse) {
+        const firstInstanceKey = Object.keys(this.instances)[0];
+        if (firstInstanceKey) {
+          const instance = this.instances[firstInstanceKey] as
+            | Record<string, unknown>
+            | undefined;
+          if (instance) {
+            const keys = Object.keys(instance);
+            if (keys.length === 1) {
+              const inst = instance[keys[0]] as
+                | { shardAssignments?: { modelId?: string } }
+                | undefined;
+              modelToUse = inst?.shardAssignments?.modelId || "";
+            }
+          }
+        }
+      }
+
+      if (!modelToUse) {
+        const idx = this.messages.findIndex(
+          (m) => m.id === assistantMessage.id,
+        );
+        if (idx !== -1) {
+          this.messages[idx].content =
+            "Error: No model available. Please launch an instance first.";
+        }
+        this.isLoading = false;
+        this.updateActiveConversation();
+        return;
+      }
+
+      const response = await fetch("/v1/chat/completions", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          model: modelToUse,
+          messages: apiMessages,
+          stream: true,
+        }),
+      });
+
+      if (!response.ok) {
+        const errorText = await response.text();
+        const idx = this.messages.findIndex(
+          (m) => m.id === assistantMessage.id,
+        );
+        if (idx !== -1) {
+          this.messages[idx].content =
+            `Error: ${response.status} - ${errorText}`;
+        }
+        this.isLoading = false;
+        this.updateActiveConversation();
+        return;
+      }
+
+      const reader = response.body?.getReader();
+      if (!reader) {
+        const idx = this.messages.findIndex(
+          (m) => m.id === assistantMessage.id,
+        );
+        if (idx !== -1) {
+          this.messages[idx].content = "Error: No response stream available";
+        }
+        this.isLoading = false;
+        this.updateActiveConversation();
+        return;
+      }
+
+      const decoder = new TextDecoder();
+      let fullContent = "";
+      let partialLine = "";
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        const chunk = decoder.decode(value, { stream: true });
+        const lines = (partialLine + chunk).split("\n");
+        partialLine = lines.pop() || "";
+
+        for (const line of lines) {
+          const trimmed = line.trim();
+          if (!trimmed || trimmed === "data: [DONE]") continue;
+
+          if (trimmed.startsWith("data: ")) {
+            try {
+              const json = JSON.parse(trimmed.slice(6));
+              const delta = json.choices?.[0]?.delta?.content;
+              if (delta) {
+                fullContent += delta;
+                const { displayContent, thinkingContent } =
+                  this.stripThinkingTags(fullContent);
+                this.currentResponse = displayContent;
+
+                // Update the assistant message in place (triggers Svelte reactivity)
+                const idx = this.messages.findIndex(
+                  (m) => m.id === assistantMessage.id,
+                );
+                if (idx !== -1) {
+                  this.messages[idx].content = displayContent;
+                  this.messages[idx].thinking = thinkingContent || undefined;
+                }
+                this.persistActiveConversation();
+              }
+            } catch {
+              // Skip malformed JSON
+            }
+          }
+        }
+      }
+
+      // Final cleanup of the message
+      const { displayContent, thinkingContent } =
+        this.stripThinkingTags(fullContent);
+      const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+      if (idx !== -1) {
+        this.messages[idx].content = displayContent;
+        this.messages[idx].thinking = thinkingContent || undefined;
+      }
+      this.persistActiveConversation();
+    } catch (error) {
+      const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+      if (idx !== -1) {
+        this.messages[idx].content =
+          `Error: ${error instanceof Error ? error.message : "Unknown error"}`;
+      }
+      this.persistActiveConversation();
+    } finally {
+      this.isLoading = false;
+      this.currentResponse = "";
+      this.updateActiveConversation();
+    }
+  }
+
+  /**
+   * Selected model for chat (can be set by the UI)
+   */
+  selectedChatModel = $state("");
+
+  /**
+   * Set the model to use for chat
+   */
+  setSelectedModel(modelId: string) {
+    this.selectedChatModel = modelId;
+    // Clear stats when model changes
+    this.ttftMs = null;
+    this.tps = null;
+  }
+
+  /**
+   * Strip thinking tags from content for display.
+   * Handles both complete <think>...</think> blocks and in-progress <think>... blocks during streaming.
+   */
+  private stripThinkingTags(content: string): {
+    displayContent: string;
+    thinkingContent: string;
+  } {
+    const extracted: string[] = [];
+    let displayContent = content;
+
+    // Extract complete <think>...</think> blocks
+    const completeBlockRegex = /<think>([\s\S]*?)<\/think>/gi;
+    let match: RegExpExecArray | null;
+    while ((match = completeBlockRegex.exec(content)) !== null) {
+      const inner = match[1]?.trim();
+      if (inner) extracted.push(inner);
+    }
+    displayContent = displayContent.replace(completeBlockRegex, "");
+
+    // Handle in-progress thinking block (has <think> but no closing </think> yet)
+    const openTagIndex = displayContent.lastIndexOf("<think>");
+    if (openTagIndex !== -1) {
+      const inProgressThinking = displayContent.slice(openTagIndex + 7).trim();
+      if (inProgressThinking) {
+        extracted.push(inProgressThinking);
+      }
+      displayContent = displayContent.slice(0, openTagIndex);
+    }
+
+    return {
+      displayContent: displayContent.trim(),
+      thinkingContent: extracted.join("\n\n"),
+    };
+  }
+
+  /**
+   * Send a message to the LLM and stream the response
+   */
+  async sendMessage(
+    content: string,
+    files?: {
+      id: string;
+      name: string;
+      type: string;
+      textContent?: string;
+      preview?: string;
+    }[],
+  ): Promise<void> {
+    if ((!content.trim() && (!files || files.length === 0)) || this.isLoading)
+      return;
+
+    if (!this.hasStartedChat) {
+      this.startChat();
+    }
+
+    this.isLoading = true;
+    this.currentResponse = "";
+    this.ttftMs = null;
+    this.tps = null;
+    this.totalTokens = 0;
+
+    // Build attachments from files
+    const attachments: MessageAttachment[] = [];
+    let fileContext = "";
+
+    if (files && files.length > 0) {
+      for (const file of files) {
+        const isImage = file.type.startsWith("image/");
+
+        if (isImage && file.preview) {
+          attachments.push({
+            type: "image",
+            name: file.name,
+            preview: file.preview,
+            mimeType: file.type,
+          });
+        } else if (file.textContent) {
+          attachments.push({
+            type: "text",
+            name: file.name,
+            content: file.textContent,
+            mimeType: file.type,
+          });
+          // Add text file content to the message context
+          fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
+        } else {
+          attachments.push({
+            type: "file",
+            name: file.name,
+            mimeType: file.type,
+          });
+        }
+      }
+    }
+
+    // Combine content with file context
+    const fullContent = content + fileContext;
+
+    // Add user message with attachments
+    const userMessage: Message = {
+      id: generateUUID(),
+      role: "user",
+      content: content, // Store original content for display
+      timestamp: Date.now(),
+      attachments: attachments.length > 0 ? attachments : undefined,
+    };
+    this.messages.push(userMessage);
+
+    // Create placeholder for assistant message
+    const assistantMessage = this.addMessage("assistant", "");
+    this.updateActiveConversation();
+
+    try {
+      // Build the messages array for the API with system prompt
+      const systemPrompt = {
+        role: "system" as const,
+        content:
+          "You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process. When files are shared with you, analyze them and respond helpfully.",
+      };
+
+      // Build API messages - include file content for text files
+      const apiMessages = [
+        systemPrompt,
+        ...this.messages.slice(0, -1).map((m) => {
+          // Build content including any text file attachments
+          let msgContent = m.content;
+
+          // Add text attachments as context
+          if (m.attachments) {
+            for (const attachment of m.attachments) {
+              if (attachment.type === "text" && attachment.content) {
+                msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
+              }
+            }
+          }
+
+          return {
+            role: m.role,
+            content: msgContent,
+          };
+        }),
+      ];
+
+      // Determine the model to use - prefer selectedChatModel, otherwise try to get from instances
+      let modelToUse = this.selectedChatModel;
+      if (!modelToUse) {
+        // Try to get model from first running instance
+        for (const [, instanceWrapper] of Object.entries(this.instances)) {
+          if (instanceWrapper && typeof instanceWrapper === "object") {
+            const keys = Object.keys(
+              instanceWrapper as Record<string, unknown>,
+            );
+            if (keys.length === 1) {
+              const instance = (instanceWrapper as Record<string, unknown>)[
+                keys[0]
+              ] as { shardAssignments?: { modelId?: string } };
+              if (instance?.shardAssignments?.modelId) {
+                modelToUse = instance.shardAssignments.modelId;
+                break;
+              }
+            }
+          }
+        }
+      }
+
+      if (!modelToUse) {
+        throw new Error(
+          "No model selected and no running instances available. Please launch an instance first.",
+        );
+      }
+
+      const conversationModelInfo = this.buildConversationModelInfo(modelToUse);
+      this.applyConversationModelInfo(conversationModelInfo);
+
+      // Start timing for TTFT measurement
+      const requestStartTime = performance.now();
+      let firstTokenTime: number | null = null;
+      let tokenCount = 0;
+
+      const response = await fetch("/v1/chat/completions", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({
+          model: modelToUse,
+          messages: apiMessages,
+          temperature: 0.7,
+          stream: true,
+        }),
+      });
+
+      if (!response.ok) {
+        const errorText = await response.text();
+        throw new Error(`API error: ${response.status} - ${errorText}`);
+      }
+
+      const reader = response.body?.getReader();
+      if (!reader) {
+        throw new Error("No response body");
+      }
+
+      const decoder = new TextDecoder();
+      let fullContent = "";
+      let buffer = "";
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        buffer += decoder.decode(value, { stream: true });
+
+        // Process complete lines
+        const lines = buffer.split("\n");
+        buffer = lines.pop() || ""; // Keep incomplete line in buffer
+
+        for (const line of lines) {
+          const trimmed = line.trim();
+          if (!trimmed) continue;
+
+          if (trimmed.startsWith("data: ")) {
+            const data = trimmed.slice(6);
+            if (data === "[DONE]") continue;
+
+            try {
+              const parsed = JSON.parse(data);
+              const tokenContent = parsed.choices?.[0]?.delta?.content;
+              if (tokenContent) {
+                // Track first token for TTFT
+                if (firstTokenTime === null) {
+                  firstTokenTime = performance.now();
+                  this.ttftMs = firstTokenTime - requestStartTime;
+                }
+
+                // Count tokens (each SSE chunk is typically one token)
+                tokenCount += 1;
+                this.totalTokens = tokenCount;
+
+                // Update real-time TPS during streaming
+                if (firstTokenTime !== null && tokenCount > 1) {
+                  const elapsed = performance.now() - firstTokenTime;
+                  this.tps = (tokenCount / elapsed) * 1000;
+                }
+
+                fullContent += tokenContent;
+
+                // Strip thinking tags for display and extract thinking content
+                const { displayContent, thinkingContent } =
+                  this.stripThinkingTags(fullContent);
+                this.currentResponse = displayContent;
+
+                // Update the assistant message in place
+                const idx = this.messages.findIndex(
+                  (m) => m.id === assistantMessage.id,
+                );
+                if (idx !== -1) {
+                  this.messages[idx].content = displayContent;
+                  this.messages[idx].thinking = thinkingContent || undefined;
+                }
+                this.persistActiveConversation();
+              }
+            } catch {
+              // Skip invalid JSON lines
+            }
+          }
+        }
+      }
+
+      // Process any remaining buffer
+      if (buffer.trim()) {
+        const trimmed = buffer.trim();
+        if (trimmed.startsWith("data: ") && trimmed.slice(6) !== "[DONE]") {
+          try {
+            const parsed = JSON.parse(trimmed.slice(6));
+            const tokenContent = parsed.choices?.[0]?.delta?.content;
+            if (tokenContent) {
+              fullContent += tokenContent;
+              this.persistActiveConversation();
+            }
+          } catch {
+            // Skip
+          }
+        }
+      }
+
+      // Calculate final TPS
+      if (firstTokenTime !== null && tokenCount > 1) {
+        const totalGenerationTime = performance.now() - firstTokenTime;
+        this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
+      }
+
+      // Final cleanup of the message
+      const { displayContent, thinkingContent } =
+        this.stripThinkingTags(fullContent);
+      const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+      if (idx !== -1) {
+        this.messages[idx].content = displayContent;
+        this.messages[idx].thinking = thinkingContent || undefined;
+        // Store performance metrics on the message
+        if (this.ttftMs !== null) {
+          this.messages[idx].ttftMs = this.ttftMs;
+        }
+        if (this.tps !== null) {
+          this.messages[idx].tps = this.tps;
+        }
+      }
+      this.persistActiveConversation();
+    } catch (error) {
+      console.error("Error sending message:", error);
+      // Update the assistant message with error
+      const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+      if (idx !== -1) {
+        this.messages[idx].content =
+          `Error: ${error instanceof Error ? error.message : "Failed to get response"}`;
+      }
+      this.persistActiveConversation();
+    } finally {
+      this.isLoading = false;
+      this.currentResponse = "";
+      this.updateActiveConversation();
+    }
+  }
+
+  /**
+   * Generate an image using the image generation API
+   */
+  async generateImage(prompt: string, modelId?: string): Promise<void> {
+    if (!prompt.trim() || this.isLoading) return;
+
+    if (!this.hasStartedChat) {
+      this.startChat();
+    }
+
+    this.isLoading = true;
+    this.currentResponse = "";
+
+    // Add user message
+    const userMessage: Message = {
+      id: generateUUID(),
+      role: "user",
+      content: prompt,
+      timestamp: Date.now(),
+    };
+    this.messages.push(userMessage);
+
+    // Create placeholder for assistant message with generating state
+    const assistantMessage = this.addMessage("assistant", "");
+    this.messages[this.messages.length - 1].content = "Generating image...";
+    this.updateActiveConversation();
+
+    try {
+      // Determine the model to use
+      let model = modelId || this.selectedChatModel;
+      if (!model) {
+        throw new Error(
+          "No model selected. Please select an image generation model.",
+        );
+      }
+
+      // Build request body using image generation params
+      const params = this.imageGenerationParams;
+      const hasAdvancedParams =
+        params.seed !== null ||
+        params.numInferenceSteps !== null ||
+        params.guidance !== null ||
+        (params.negativePrompt !== null && params.negativePrompt.trim() !== "");
+
+      const requestBody: Record<string, unknown> = {
+        model,
+        prompt,
+        quality: params.quality,
+        size: params.size,
+        output_format: params.outputFormat,
+        response_format: "b64_json",
+        stream: true,
+        partial_images: 3,
+      };
+
+      if (hasAdvancedParams) {
+        requestBody.advanced_params = {
+          ...(params.seed !== null && { seed: params.seed }),
+          ...(params.numInferenceSteps !== null && {
+            num_inference_steps: params.numInferenceSteps,
+          }),
+          ...(params.guidance !== null && { guidance: params.guidance }),
+          ...(params.negativePrompt !== null &&
+            params.negativePrompt.trim() !== "" && {
+              negative_prompt: params.negativePrompt,
+            }),
+        };
+      }
+
+      const response = await fetch("/v1/images/generations", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify(requestBody),
+      });
+
+      if (!response.ok) {
+        const errorText = await response.text();
+        throw new Error(`API error: ${response.status} - ${errorText}`);
+      }
+
+      const reader = response.body?.getReader();
+      if (!reader) {
+        throw new Error("No response body");
+      }
+
+      const decoder = new TextDecoder();
+      let buffer = "";
+      const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        buffer += decoder.decode(value, { stream: true });
+
+        // Process complete lines
+        const lines = buffer.split("\n");
+        buffer = lines.pop() || ""; // Keep incomplete line in buffer
+
+        for (const line of lines) {
+          const trimmed = line.trim();
+          if (!trimmed) continue;
+
+          if (trimmed.startsWith("data: ")) {
+            const data = trimmed.slice(6);
+            if (data === "[DONE]") continue;
+
+            try {
+              const parsed = JSON.parse(data);
+              const imageData = parsed.data?.b64_json;
+
+              if (imageData && idx !== -1) {
+                const format = parsed.format || "png";
+                const mimeType = `image/${format}`;
+                if (parsed.type === "partial") {
+                  // Update with partial image and progress
+                  const partialNum = (parsed.partial_index ?? 0) + 1;
+                  const totalPartials = parsed.total_partials ?? 3;
+                  this.messages[idx].content =
+                    `Generating... ${partialNum}/${totalPartials}`;
+                  this.messages[idx].attachments = [
+                    {
+                      type: "generated-image",
+                      name: `generated-image.${format}`,
+                      preview: `data:${mimeType};base64,${imageData}`,
+                      mimeType,
+                    },
+                  ];
+                } else if (parsed.type === "final") {
+                  // Final image
+                  this.messages[idx].content = "";
+                  this.messages[idx].attachments = [
+                    {
+                      type: "generated-image",
+                      name: `generated-image.${format}`,
+                      preview: `data:${mimeType};base64,${imageData}`,
+                      mimeType,
+                    },
+                  ];
+                }
+              }
+            } catch {
+              // Ignore parse errors for incomplete JSON
+            }
+          }
+        }
+      }
+    } catch (error) {
+      console.error("Error generating image:", error);
+      const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+      if (idx !== -1) {
+        this.messages[idx].content =
+          `Error: ${error instanceof Error ? error.message : "Failed to generate image"}`;
+      }
+    } finally {
+      this.isLoading = false;
+      this.updateActiveConversation();
+    }
+  }
+
+  /**
+   * Edit an image using the image edit API
+   */
+  async editImage(
+    prompt: string,
+    imageDataUrl: string,
+    modelId?: string,
+  ): Promise<void> {
+    if (!prompt.trim() || !imageDataUrl || this.isLoading) return;
+
+    if (!this.hasStartedChat) {
+      this.startChat();
+    }
+
+    this.isLoading = true;
+    this.currentResponse = "";
+
+    // Add user message with the edit prompt
+    const userMessage: Message = {
+      id: generateUUID(),
+      role: "user",
+      content: prompt,
+      timestamp: Date.now(),
+    };
+    this.messages.push(userMessage);
+
+    // Create placeholder for assistant message with generating state
+    const assistantMessage = this.addMessage("assistant", "");
+    this.messages[this.messages.length - 1].content = "Editing image...";
+    this.updateActiveConversation();
+
+    // Clear editing state
+    this.editingImage = null;
+
+    try {
+      // Determine the model to use
+      let model = modelId || this.selectedChatModel;
+      if (!model) {
+        throw new Error(
+          "No model selected. Please select an image generation model.",
+        );
+      }
+
+      // Convert base64 data URL to blob
+      const response = await fetch(imageDataUrl);
+      const imageBlob = await response.blob();
+
+      // Build FormData request
+      const formData = new FormData();
+      formData.append("model", model);
+      formData.append("prompt", prompt);
+      formData.append("image", imageBlob, "image.png");
+
+      // Add params from image generation params
+      const params = this.imageGenerationParams;
+      formData.append("quality", params.quality);
+      formData.append("size", params.size);
+      formData.append("output_format", params.outputFormat);
+      formData.append("response_format", "b64_json");
+      formData.append("stream", "1"); // Use "1" instead of "true" for reliable FastAPI boolean parsing
+      formData.append("partial_images", "3");
+      formData.append("input_fidelity", params.inputFidelity);
+
+      // Advanced params
+      if (params.seed !== null) {
+        formData.append(
+          "advanced_params",
+          JSON.stringify({
+            seed: params.seed,
+            ...(params.numInferenceSteps !== null && {
+              num_inference_steps: params.numInferenceSteps,
+            }),
+            ...(params.guidance !== null && { guidance: params.guidance }),
+            ...(params.negativePrompt !== null &&
+              params.negativePrompt.trim() !== "" && {
+                negative_prompt: params.negativePrompt,
+              }),
+          }),
+        );
+      } else if (
+        params.numInferenceSteps !== null ||
+        params.guidance !== null ||
+        (params.negativePrompt !== null && params.negativePrompt.trim() !== "")
+      ) {
+        formData.append(
+          "advanced_params",
+          JSON.stringify({
+            ...(params.numInferenceSteps !== null && {
+              num_inference_steps: params.numInferenceSteps,
+            }),
+            ...(params.guidance !== null && { guidance: params.guidance }),
+            ...(params.negativePrompt !== null &&
+              params.negativePrompt.trim() !== "" && {
+                negative_prompt: params.negativePrompt,
+              }),
+          }),
+        );
+      }
+
+      const apiResponse = await fetch("/v1/images/edits", {
+        method: "POST",
+        body: formData,
+      });
+
+      if (!apiResponse.ok) {
+        const errorText = await apiResponse.text();
+        throw new Error(`API error: ${apiResponse.status} - ${errorText}`);
+      }
+
+      const reader = apiResponse.body?.getReader();
+      if (!reader) {
+        throw new Error("No response body");
+      }
+
+      const decoder = new TextDecoder();
+      let buffer = "";
+      const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        buffer += decoder.decode(value, { stream: true });
+
+        // Process complete lines
+        const lines = buffer.split("\n");
+        buffer = lines.pop() || ""; // Keep incomplete line in buffer
+
+        for (const line of lines) {
+          const trimmed = line.trim();
+          if (!trimmed) continue;
+
+          if (trimmed.startsWith("data: ")) {
+            const data = trimmed.slice(6);
+            if (data === "[DONE]") continue;
+
+            try {
+              const parsed = JSON.parse(data);
+              const imageData = parsed.data?.b64_json;
+
+              if (imageData && idx !== -1) {
+                const format = parsed.format || "png";
+                const mimeType = `image/${format}`;
+                if (parsed.type === "partial") {
+                  // Update with partial image and progress
+                  const partialNum = (parsed.partial_index ?? 0) + 1;
+                  const totalPartials = parsed.total_partials ?? 3;
+                  this.messages[idx].content =
+                    `Editing... ${partialNum}/${totalPartials}`;
+                  this.messages[idx].attachments = [
+                    {
+                      type: "generated-image",
+                      name: `edited-image.${format}`,
+                      preview: `data:${mimeType};base64,${imageData}`,
+                      mimeType,
+                    },
+                  ];
+                } else if (parsed.type === "final") {
+                  // Final image
+                  this.messages[idx].content = "";
+                  this.messages[idx].attachments = [
+                    {
+                      type: "generated-image",
+                      name: `edited-image.${format}`,
+                      preview: `data:${mimeType};base64,${imageData}`,
+                      mimeType,
+                    },
+                  ];
+                }
+              }
+            } catch {
+              // Ignore parse errors for incomplete JSON
+            }
+          }
+        }
+      }
+    } catch (error) {
+      console.error("Error editing image:", error);
+      const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+      if (idx !== -1) {
+        this.messages[idx].content =
+          `Error: ${error instanceof Error ? error.message : "Failed to edit image"}`;
+      }
+    } finally {
+      this.isLoading = false;
+      this.updateActiveConversation();
+    }
+  }
+
+  /**
+   * Clear current chat and go back to welcome state
+   */
+  clearChat() {
+    this.activeConversationId = null;
+    this.messages = [];
+    this.hasStartedChat = false;
+    this.isTopologyMinimized = false;
+    this.currentResponse = "";
+    // Clear performance stats
+    this.ttftMs = null;
+    this.tps = null;
+  }
+
+  /**
+   * Get the active conversation
+   */
+  getActiveConversation(): Conversation | null {
+    if (!this.activeConversationId) return null;
+    return (
+      this.conversations.find((c) => c.id === this.activeConversationId) || null
+    );
+  }
 }
 
 export const appStore = new AppStore();
@@ -2073,50 +2073,50 @@ export const chatSidebarVisible = () => appStore.getChatSidebarVisible();
 // Actions
 export const startChat = () => appStore.startChat();
 export const sendMessage = (
-	content: string,
-	files?: {
-		id: string;
-		name: string;
-		type: string;
-		textContent?: string;
-		preview?: string;
-	}[],
+  content: string,
+  files?: {
+    id: string;
+    name: string;
+    type: string;
+    textContent?: string;
+    preview?: string;
+  }[],
 ) => appStore.sendMessage(content, files);
 export const generateImage = (prompt: string, modelId?: string) =>
-	appStore.generateImage(prompt, modelId);
+  appStore.generateImage(prompt, modelId);
 export const editImage = (
-	prompt: string,
-	imageDataUrl: string,
-	modelId?: string,
+  prompt: string,
+  imageDataUrl: string,
+  modelId?: string,
 ) => appStore.editImage(prompt, imageDataUrl, modelId);
 export const editingImage = () => appStore.editingImage;
 export const setEditingImage = (imageDataUrl: string, sourceMessage: Message) =>
-	appStore.setEditingImage(imageDataUrl, sourceMessage);
+  appStore.setEditingImage(imageDataUrl, sourceMessage);
 export const clearEditingImage = () => appStore.clearEditingImage();
 export const clearChat = () => appStore.clearChat();
 export const setSelectedChatModel = (modelId: string) =>
-	appStore.setSelectedModel(modelId);
+  appStore.setSelectedModel(modelId);
 export const selectPreviewModel = (modelId: string | null) =>
-	appStore.selectPreviewModel(modelId);
+  appStore.selectPreviewModel(modelId);
 export const deleteMessage = (messageId: string) =>
-	appStore.deleteMessage(messageId);
+  appStore.deleteMessage(messageId);
 export const editMessage = (messageId: string, newContent: string) =>
-	appStore.editMessage(messageId, newContent);
+  appStore.editMessage(messageId, newContent);
 export const editAndRegenerate = (messageId: string, newContent: string) =>
-	appStore.editAndRegenerate(messageId, newContent);
+  appStore.editAndRegenerate(messageId, newContent);
 export const regenerateLastResponse = () => appStore.regenerateLastResponse();
 
 // Conversation actions
 export const conversations = () => appStore.conversations;
 export const activeConversationId = () => appStore.activeConversationId;
 export const createConversation = (name?: string) =>
-	appStore.createConversation(name);
+  appStore.createConversation(name);
 export const loadConversation = (id: string) => appStore.loadConversation(id);
 export const deleteConversation = (id: string) =>
-	appStore.deleteConversation(id);
+  appStore.deleteConversation(id);
 export const deleteAllConversations = () => appStore.deleteAllConversations();
 export const renameConversation = (id: string, name: string) =>
-	appStore.renameConversation(id, name);
+  appStore.renameConversation(id, name);
 export const getActiveConversation = () => appStore.getActiveConversation();
 
 // Sidebar actions
@@ -2124,20 +2124,20 @@ export const isSidebarOpen = () => appStore.isSidebarOpen;
 export const toggleSidebar = () => appStore.toggleSidebar();
 export const toggleDebugMode = () => appStore.toggleDebugMode();
 export const setDebugMode = (enabled: boolean) =>
-	appStore.setDebugMode(enabled);
+  appStore.setDebugMode(enabled);
 export const toggleTopologyOnlyMode = () => appStore.toggleTopologyOnlyMode();
 export const setTopologyOnlyMode = (enabled: boolean) =>
-	appStore.setTopologyOnlyMode(enabled);
+  appStore.setTopologyOnlyMode(enabled);
 export const toggleChatSidebarVisible = () =>
-	appStore.toggleChatSidebarVisible();
+  appStore.toggleChatSidebarVisible();
 export const setChatSidebarVisible = (visible: boolean) =>
-	appStore.setChatSidebarVisible(visible);
+  appStore.setChatSidebarVisible(visible);
 export const refreshState = () => appStore.fetchState();
 
 // Image generation params
 export const imageGenerationParams = () => appStore.getImageGenerationParams();
 export const setImageGenerationParams = (
-	params: Partial<ImageGenerationParams>,
+  params: Partial<ImageGenerationParams>,
 ) => appStore.setImageGenerationParams(params);
 export const resetImageGenerationParams = () =>
-	appStore.resetImageGenerationParams();
+  appStore.resetImageGenerationParams();
diff --git a/dashboard/src/lib/types/files.ts b/dashboard/src/lib/types/files.ts
index ecd9a41b..4b11f128 100644
--- a/dashboard/src/lib/types/files.ts
+++ b/dashboard/src/lib/types/files.ts
@@ -3,89 +3,89 @@
  */
 
 export interface ChatUploadedFile {
-	id: string;
-	name: string;
-	size: number;
-	type: string;
-	file: File;
-	preview?: string;
-	textContent?: string;
+  id: string;
+  name: string;
+  size: number;
+  type: string;
+  file: File;
+  preview?: string;
+  textContent?: string;
 }
 
 export interface ChatAttachment {
-	type: "image" | "text" | "pdf" | "audio";
-	name: string;
-	content?: string;
-	base64Url?: string;
-	mimeType?: string;
+  type: "image" | "text" | "pdf" | "audio";
+  name: string;
+  content?: string;
+  base64Url?: string;
+  mimeType?: string;
 }
 
 export type FileCategory = "image" | "text" | "pdf" | "audio" | "unknown";
 
 export const IMAGE_EXTENSIONS = [
-	".jpg",
-	".jpeg",
-	".png",
-	".gif",
-	".webp",
-	".svg",
+  ".jpg",
+  ".jpeg",
+  ".png",
+  ".gif",
+  ".webp",
+  ".svg",
 ];
 export const IMAGE_MIME_TYPES = [
-	"image/jpeg",
-	"image/png",
-	"image/gif",
-	"image/webp",
-	"image/svg+xml",
+  "image/jpeg",
+  "image/png",
+  "image/gif",
+  "image/webp",
+  "image/svg+xml",
 ];
 
 export const TEXT_EXTENSIONS = [
-	".txt",
-	".md",
-	".json",
-	".xml",
-	".yaml",
-	".yml",
-	".csv",
-	".log",
-	".js",
-	".ts",
-	".jsx",
-	".tsx",
-	".py",
-	".java",
-	".cpp",
-	".c",
-	".h",
-	".css",
-	".html",
-	".htm",
-	".sql",
-	".sh",
-	".bat",
-	".rs",
-	".go",
-	".rb",
-	".php",
-	".swift",
-	".kt",
-	".scala",
-	".r",
-	".dart",
-	".vue",
-	".svelte",
+  ".txt",
+  ".md",
+  ".json",
+  ".xml",
+  ".yaml",
+  ".yml",
+  ".csv",
+  ".log",
+  ".js",
+  ".ts",
+  ".jsx",
+  ".tsx",
+  ".py",
+  ".java",
+  ".cpp",
+  ".c",
+  ".h",
+  ".css",
+  ".html",
+  ".htm",
+  ".sql",
+  ".sh",
+  ".bat",
+  ".rs",
+  ".go",
+  ".rb",
+  ".php",
+  ".swift",
+  ".kt",
+  ".scala",
+  ".r",
+  ".dart",
+  ".vue",
+  ".svelte",
 ];
 export const TEXT_MIME_TYPES = [
-	"text/plain",
-	"text/markdown",
-	"text/csv",
-	"text/html",
-	"text/css",
-	"application/json",
-	"application/xml",
-	"text/xml",
-	"application/javascript",
-	"text/javascript",
-	"application/typescript",
+  "text/plain",
+  "text/markdown",
+  "text/csv",
+  "text/html",
+  "text/css",
+  "application/json",
+  "application/xml",
+  "text/xml",
+  "application/javascript",
+  "text/javascript",
+  "application/typescript",
 ];
 
 export const PDF_EXTENSIONS = [".pdf"];
@@ -93,148 +93,148 @@ export const PDF_MIME_TYPES = ["application/pdf"];
 
 export const AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a"];
 export const AUDIO_MIME_TYPES = [
-	"audio/mpeg",
-	"audio/wav",
-	"audio/ogg",
-	"audio/mp4",
+  "audio/mpeg",
+  "audio/wav",
+  "audio/ogg",
+  "audio/mp4",
 ];
 
 /**
  * Get file category based on MIME type and extension
  */
 export function getFileCategory(
-	mimeType: string,
-	fileName: string,
+  mimeType: string,
+  fileName: string,
 ): FileCategory {
-	const extension = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
-
-	if (
-		IMAGE_MIME_TYPES.includes(mimeType) ||
-		IMAGE_EXTENSIONS.includes(extension)
-	) {
-		return "image";
-	}
-	if (PDF_MIME_TYPES.includes(mimeType) || PDF_EXTENSIONS.includes(extension)) {
-		return "pdf";
-	}
-	if (
-		AUDIO_MIME_TYPES.includes(mimeType) ||
-		AUDIO_EXTENSIONS.includes(extension)
-	) {
-		return "audio";
-	}
-	if (
-		TEXT_MIME_TYPES.includes(mimeType) ||
-		TEXT_EXTENSIONS.includes(extension) ||
-		mimeType.startsWith("text/")
-	) {
-		return "text";
-	}
-	return "unknown";
+  const extension = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
+
+  if (
+    IMAGE_MIME_TYPES.includes(mimeType) ||
+    IMAGE_EXTENSIONS.includes(extension)
+  ) {
+    return "image";
+  }
+  if (PDF_MIME_TYPES.includes(mimeType) || PDF_EXTENSIONS.includes(extension)) {
+    return "pdf";
+  }
+  if (
+    AUDIO_MIME_TYPES.includes(mimeType) ||
+    AUDIO_EXTENSIONS.includes(extension)
+  ) {
+    return "audio";
+  }
+  if (
+    TEXT_MIME_TYPES.includes(mimeType) ||
+    TEXT_EXTENSIONS.includes(extension) ||
+    mimeType.startsWith("text/")
+  ) {
+    return "text";
+  }
+  return "unknown";
 }
 
 /**
  * Get accept string for file input based on categories
  */
 export function getAcceptString(categories: FileCategory[]): string {
-	const accepts: string[] = [];
-
-	for (const category of categories) {
-		switch (category) {
-			case "image":
-				accepts.push(...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES);
-				break;
-			case "text":
-				accepts.push(...TEXT_EXTENSIONS, ...TEXT_MIME_TYPES);
-				break;
-			case "pdf":
-				accepts.push(...PDF_EXTENSIONS, ...PDF_MIME_TYPES);
-				break;
-			case "audio":
-				accepts.push(...AUDIO_EXTENSIONS, ...AUDIO_MIME_TYPES);
-				break;
-		}
-	}
-
-	return accepts.join(",");
+  const accepts: string[] = [];
+
+  for (const category of categories) {
+    switch (category) {
+      case "image":
+        accepts.push(...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES);
+        break;
+      case "text":
+        accepts.push(...TEXT_EXTENSIONS, ...TEXT_MIME_TYPES);
+        break;
+      case "pdf":
+        accepts.push(...PDF_EXTENSIONS, ...PDF_MIME_TYPES);
+        break;
+      case "audio":
+        accepts.push(...AUDIO_EXTENSIONS, ...AUDIO_MIME_TYPES);
+        break;
+    }
+  }
+
+  return accepts.join(",");
 }
 
 /**
  * Format file size for display
  */
 export function formatFileSize(bytes: number): string {
-	if (bytes === 0) return "0 B";
-	const k = 1024;
-	const sizes = ["B", "KB", "MB", "GB"];
-	const i = Math.floor(Math.log(bytes) / Math.log(k));
-	return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
+  if (bytes === 0) return "0 B";
+  const k = 1024;
+  const sizes = ["B", "KB", "MB", "GB"];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
 }
 
 /**
  * Read file as data URL (base64)
  */
 export function readFileAsDataURL(file: File): Promise<string> {
-	return new Promise((resolve, reject) => {
-		const reader = new FileReader();
-		reader.onload = () => resolve(reader.result as string);
-		reader.onerror = () => reject(reader.error);
-		reader.readAsDataURL(file);
-	});
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onload = () => resolve(reader.result as string);
+    reader.onerror = () => reject(reader.error);
+    reader.readAsDataURL(file);
+  });
 }
 
 /**
  * Read file as text
  */
 export function readFileAsText(file: File): Promise<string> {
-	return new Promise((resolve, reject) => {
-		const reader = new FileReader();
-		reader.onload = () => resolve(reader.result as string);
-		reader.onerror = () => reject(reader.error);
-		reader.readAsText(file);
-	});
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onload = () => resolve(reader.result as string);
+    reader.onerror = () => reject(reader.error);
+    reader.readAsText(file);
+  });
 }
 
 /**
  * Process uploaded files into ChatUploadedFile format
  */
 export async function processUploadedFiles(
-	files: File[],
+  files: File[],
 ): Promise<ChatUploadedFile[]> {
-	const results: ChatUploadedFile[] = [];
-
-	for (const file of files) {
-		const id =
-			Date.now().toString() + Math.random().toString(36).substring(2, 9);
-		const category = getFileCategory(file.type, file.name);
-
-		const base: ChatUploadedFile = {
-			id,
-			name: file.name,
-			size: file.size,
-			type: file.type,
-			file,
-		};
-
-		try {
-			if (category === "image") {
-				const preview = await readFileAsDataURL(file);
-				results.push({ ...base, preview });
-			} else if (category === "text" || category === "unknown") {
-				const textContent = await readFileAsText(file);
-				results.push({ ...base, textContent });
-			} else if (category === "pdf") {
-				results.push(base);
-			} else if (category === "audio") {
-				const preview = await readFileAsDataURL(file);
-				results.push({ ...base, preview });
-			} else {
-				results.push(base);
-			}
-		} catch (error) {
-			console.error("Error processing file:", file.name, error);
-			results.push(base);
-		}
-	}
-
-	return results;
+  const results: ChatUploadedFile[] = [];
+
+  for (const file of files) {
+    const id =
+      Date.now().toString() + Math.random().toString(36).substring(2, 9);
+    const category = getFileCategory(file.type, file.name);
+
+    const base: ChatUploadedFile = {
+      id,
+      name: file.name,
+      size: file.size,
+      type: file.type,
+      file,
+    };
+
+    try {
+      if (category === "image") {
+        const preview = await readFileAsDataURL(file);
+        results.push({ ...base, preview });
+      } else if (category === "text" || category === "unknown") {
+        const textContent = await readFileAsText(file);
+        results.push({ ...base, textContent });
+      } else if (category === "pdf") {
+        results.push(base);
+      } else if (category === "audio") {
+        const preview = await readFileAsDataURL(file);
+        results.push({ ...base, preview });
+      } else {
+        results.push(base);
+      }
+    } catch (error) {
+      console.error("Error processing file:", file.name, error);
+      results.push(base);
+    }
+  }
+
+  return results;
 }
diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte
index 7e75b676..d249e9cf 100644
--- a/dashboard/src/routes/+layout.svelte
+++ b/dashboard/src/routes/+layout.svelte
@@ -1,15 +1,14 @@
 <script lang="ts">
-	import '../app.css';
-	
-	let { children } = $props();
+  import "../app.css";
+
+  let { children } = $props();
 </script>
 
 <svelte:head>
-	<title>EXO</title>
-	<meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
+  <title>EXO</title>
+  <meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
 </svelte:head>
 
 <div class="min-h-screen bg-background text-foreground">
-	{@render children?.()}
+  {@render children?.()}
 </div>
-
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index 4be37a3d..f32948fc 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -1,2025 +1,3009 @@
 <script lang="ts">
-	import { TopologyGraph, ChatForm, ChatMessages, ChatSidebar, ModelCard } from '$lib/components';
-	import { 
-		hasStartedChat, 
-		isTopologyMinimized, 
-		topologyData, 
-		lastUpdate,
-		clearChat,
-		instances,
-		runners,
-		downloads,
-		placementPreviews,
-		selectedPreviewModelId,
-		isLoadingPreviews,
-		selectPreviewModel,
-		createConversation,
-		setSelectedChatModel,
-		selectedChatModel,
-	debugMode,
-	toggleDebugMode,
-	topologyOnlyMode,
-	toggleTopologyOnlyMode,
-	chatSidebarVisible,
-	toggleChatSidebarVisible,
-		type DownloadProgress,
-		type PlacementPreview
-	} from '$lib/stores/app.svelte';
-	import HeaderNav from '$lib/components/HeaderNav.svelte';
-	import { fade, fly } from 'svelte/transition';
-	import { cubicInOut } from 'svelte/easing';
-	import { onMount } from 'svelte';
-
-	const chatStarted = $derived(hasStartedChat());
-	const minimized = $derived(isTopologyMinimized());
-	const data = $derived(topologyData());
-	const update = $derived(lastUpdate());
-	const instanceData = $derived(instances());
-	const runnersData = $derived(runners());
-	const downloadsData = $derived(downloads());
-	const previewsData = $derived(placementPreviews());
-	const selectedModelId = $derived(selectedPreviewModelId());
-	const loadingPreviews = $derived(isLoadingPreviews());
-const debugEnabled = $derived(debugMode());
-const topologyOnlyEnabled = $derived(topologyOnlyMode());
-const sidebarVisible = $derived(chatSidebarVisible());
-
-	let mounted = $state(false);
-
-	// Instance launch state
-	let models = $state<Array<{id: string, name?: string, storage_size_megabytes?: number, tasks?: string[], hugging_face_id?: string}>>([]);
-	
-	// Model tasks lookup for ChatForm - maps both short IDs and full HuggingFace IDs
-	const modelTasks = $derived(() => {
-		const tasks: Record<string, string[]> = {};
-		for (const model of models) {
-			if (model.tasks && model.tasks.length > 0) {
-				// Map by short ID
-				tasks[model.id] = model.tasks;
-				// Also map by hugging_face_id from the API response
-				if (model.hugging_face_id) {
-					tasks[model.hugging_face_id] = model.tasks;
-				}
-			}
-		}
-		return tasks;
-	});
-	
-	// Helper to check if a model supports image generation
-	function modelSupportsImageGeneration(modelId: string): boolean {
-		const model = models.find(m => m.id === modelId || m.hugging_face_id === modelId);
-		if (!model?.tasks) return false;
-		return model.tasks.includes('TextToImage') || model.tasks.includes('ImageToImage');
-	}
-	let selectedSharding = $state<'Pipeline' | 'Tensor'>('Pipeline');
-	type InstanceMeta = 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
-	
-	// Launch defaults persistence
-	const LAUNCH_DEFAULTS_KEY = 'exo-launch-defaults';
-	interface LaunchDefaults {
-		modelId: string | null;
-		sharding: 'Pipeline' | 'Tensor';
-		instanceType: InstanceMeta;
-		minNodes: number;
-	}
-	
-	function saveLaunchDefaults(): void {
-		const defaults: LaunchDefaults = {
-			modelId: selectedPreviewModelId(),
-			sharding: selectedSharding,
-			instanceType: selectedInstanceType,
-			minNodes: selectedMinNodes,
-		};
-		try {
-			localStorage.setItem(LAUNCH_DEFAULTS_KEY, JSON.stringify(defaults));
-		} catch (e) {
-			console.warn('Failed to save launch defaults:', e);
-		}
-	}
-	
-	function loadLaunchDefaults(): LaunchDefaults | null {
-		try {
-			const stored = localStorage.getItem(LAUNCH_DEFAULTS_KEY);
-			if (!stored) return null;
-			return JSON.parse(stored) as LaunchDefaults;
-		} catch (e) {
-			console.warn('Failed to load launch defaults:', e);
-			return null;
-		}
-	}
-	
-	function applyLaunchDefaults(availableModels: Array<{id: string}>, maxNodes: number): void {
-		const defaults = loadLaunchDefaults();
-		if (!defaults) return;
-		
-		// Apply sharding and instance type unconditionally
-		selectedSharding = defaults.sharding;
-		selectedInstanceType = defaults.instanceType;
-		
-		// Apply minNodes if valid (between 1 and maxNodes)
-		if (defaults.minNodes && defaults.minNodes >= 1 && defaults.minNodes <= maxNodes) {
-			selectedMinNodes = defaults.minNodes;
-		}
-		
-		// Only apply model if it exists in the available models
-		if (defaults.modelId && availableModels.some(m => m.id === defaults.modelId)) {
-			selectPreviewModel(defaults.modelId);
-		}
-	}
-	
-	let selectedInstanceType = $state<InstanceMeta>('MlxRing');
-	let selectedMinNodes = $state<number>(1);
-	let minNodesInitialized = $state(false);
-	let launchingModelId = $state<string | null>(null);
-let instanceDownloadExpandedNodes = $state<Set<string>>(new Set());
-	
-	// Custom dropdown state
-	let isModelDropdownOpen = $state(false);
-	let modelDropdownSearch = $state('');
-	
-	// Slider dragging state
-	let isDraggingSlider = $state(false);
-	let sliderTrackElement: HTMLDivElement | null = $state(null);
-	
-	// Instances container ref for scrolling
-	let instancesContainerRef: HTMLDivElement | null = $state(null);
-// Chat scroll container ref for precise scroll behavior
-let chatScrollRef: HTMLDivElement | null = $state(null);
-	
-	// Instance hover state for highlighting nodes in topology
-	let hoveredInstanceId = $state<string | null>(null);
-	
-	// Preview card hover state for highlighting nodes in topology
-	let hoveredPreviewNodes = $state<Set<string>>(new Set());
-	
-	// Helper to unwrap tagged instance for hover highlighting
-	function unwrapInstanceNodes(instanceWrapped: unknown): Set<string> {
-		if (!instanceWrapped || typeof instanceWrapped !== 'object') return new Set();
-		const keys = Object.keys(instanceWrapped as Record<string, unknown>);
-		if (keys.length !== 1) return new Set();
-		const instance = (instanceWrapped as Record<string, unknown>)[keys[0]];
-		if (!instance || typeof instance !== 'object') return new Set();
-		const inst = instance as { shardAssignments?: { nodeToRunner?: Record<string, string> } };
-		if (!inst.shardAssignments?.nodeToRunner) return new Set();
-		return new Set(Object.keys(inst.shardAssignments.nodeToRunner));
-	}
-
-function toggleInstanceDownloadDetails(nodeId: string): void {
-	const next = new Set(instanceDownloadExpandedNodes);
-	if (next.has(nodeId)) {
-		next.delete(nodeId);
-	} else {
-		next.add(nodeId);
-	}
-	instanceDownloadExpandedNodes = next;
-}
-	
-	// Compute highlighted nodes from hovered instance or hovered preview
-	const highlightedNodes = $derived(() => {
-		// First check instance hover
-		if (hoveredInstanceId) {
-			const instanceWrapped = instanceData[hoveredInstanceId];
-			return unwrapInstanceNodes(instanceWrapped);
-		}
-		// Then check preview hover
-		if (hoveredPreviewNodes.size > 0) {
-			return hoveredPreviewNodes;
-		}
-		return new Set<string>();
-	});
-	
-	// Helper to estimate memory from model ID (mirrors ModelCard logic)
-	// Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
-	function estimateMemoryGB(modelId: string, modelName?: string): number {
-		// Check both ID and name for quantization info
-		const combined = `${modelId} ${modelName || ''}`.toLowerCase();
-		
-		// Detect quantization level - affects memory by roughly 2x between levels
-		const is4bit = combined.includes('4bit') || combined.includes('4-bit') || combined.includes(':4bit');
-		const is8bit = combined.includes('8bit') || combined.includes('8-bit') || combined.includes(':8bit');
-		// 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
-		const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
-		const id = modelId.toLowerCase();
-		
-		// Known large models that don't follow the standard naming pattern
-		// DeepSeek V3 has 685B parameters
-		if (id.includes('deepseek-v3')) {
-			return Math.round(685 * quantMultiplier);
-		}
-		// DeepSeek V2 has 236B parameters  
-		if (id.includes('deepseek-v2')) {
-			return Math.round(236 * quantMultiplier);
-		}
-		// Llama 4 Scout/Maverick are large models
-		if (id.includes('llama-4')) {
-			return Math.round(400 * quantMultiplier);
-		}
-		
-		// Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
-		const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
-		if (paramMatch) {
-			const params = parseFloat(paramMatch[1]);
-			return Math.max(4, Math.round(params * quantMultiplier));
-		}
-		
-		// Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
-		if (id.includes('405b') || id.includes('400b')) return Math.round(405 * quantMultiplier);
-		if (id.includes('180b')) return Math.round(180 * quantMultiplier);
-		if (id.includes('141b') || id.includes('140b')) return Math.round(140 * quantMultiplier);
-		if (id.includes('123b') || id.includes('120b')) return Math.round(123 * quantMultiplier);
-		if (id.includes('72b') || id.includes('70b')) return Math.round(70 * quantMultiplier);
-		if (id.includes('67b') || id.includes('65b')) return Math.round(65 * quantMultiplier);
-		if (id.includes('35b') || id.includes('34b') || id.includes('32b') || id.includes('30b')) return Math.round(32 * quantMultiplier);
-		if (id.includes('27b') || id.includes('26b') || id.includes('22b')) return Math.round(24 * quantMultiplier);
-		if (id.includes('14b') || id.includes('13b') || id.includes('15b')) return Math.round(14 * quantMultiplier);
-		if (id.includes('8b') || id.includes('9b') || id.includes('7b')) return Math.round(8 * quantMultiplier);
-		if (id.includes('3b') || id.includes('3.8b')) return Math.round(4 * quantMultiplier);
-		if (id.includes('2b') || id.includes('1b') || id.includes('1.5b') || id.includes('0.5b')) return Math.round(2 * quantMultiplier);
-		return 16; // Default fallback
-	}
-	
-	// Helper to estimate performance from model ID
-	function estimatePerformance(modelId: string): { ttft: number; tps: number } {
-		const id = modelId.toLowerCase();
-		if (id.includes('405b') || id.includes('400b')) return { ttft: 8000, tps: 3 };
-		if (id.includes('180b')) return { ttft: 4000, tps: 5 };
-		if (id.includes('141b') || id.includes('140b')) return { ttft: 3500, tps: 6 };
-		if (id.includes('123b') || id.includes('120b')) return { ttft: 3000, tps: 7 };
-		if (id.includes('72b') || id.includes('70b')) return { ttft: 1800, tps: 12 };
-		if (id.includes('67b') || id.includes('65b')) return { ttft: 1600, tps: 14 };
-		if (id.includes('35b') || id.includes('34b') || id.includes('32b') || id.includes('30b')) return { ttft: 900, tps: 22 };
-		if (id.includes('27b') || id.includes('26b') || id.includes('22b')) return { ttft: 700, tps: 28 };
-		if (id.includes('14b') || id.includes('13b') || id.includes('15b')) return { ttft: 400, tps: 45 };
-		if (id.includes('8b') || id.includes('9b') || id.includes('7b')) return { ttft: 200, tps: 65 };
-		if (id.includes('4b') || id.includes('3b') || id.includes('3.8b')) return { ttft: 100, tps: 95 };
-		if (id.includes('2b') || id.includes('1b') || id.includes('1.5b') || id.includes('0.5b')) return { ttft: 50, tps: 150 };
-		return { ttft: 300, tps: 50 };
-	}
-	
-	const matchesSelectedRuntime = (runtime: InstanceMeta): boolean =>
-		selectedInstanceType === 'MlxRing'
-			? runtime === 'MlxRing'
-			: runtime === 'MlxIbv' || runtime === 'MlxJaccl';
-
-	// Helper to check if a model can be launched (has valid placement with >= minNodes)
-	function canModelFit(modelId: string): boolean {
-
-		// Find previews matching the model, sharding, and instance type
-		const matchingPreviews = previewsData.filter(
-			(p: PlacementPreview) => 
-				p.model_id === modelId && 
-				p.sharding === selectedSharding && 
-				matchesSelectedRuntime(p.instance_meta) &&
-				p.error === null &&
-				p.memory_delta_by_node !== null
-		);
-		
-		// Check if any preview has node count >= selectedMinNodes
-		return matchingPreviews.some((p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes);
-	}
-	
-	// Helper to get model size in GB (from megabytes)
-	function getModelSizeGB(model: {id: string, name?: string, storage_size_megabytes?: number}): number {
-		if (model.storage_size_megabytes) {
-			return model.storage_size_megabytes / 1024;
-		}
-		return estimateMemoryGB(model.id, model.name);
-	}
-	
-	// Calculate available memory in the cluster (in GB)
-	const availableMemoryGB = $derived(() => {
-		if (!data) return 0;
-		return Object.values(data.nodes).reduce((acc, n) => {
-			const total = n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
-			const used = n.macmon_info?.memory?.ram_usage ?? 0;
-			return acc + (total - used);
-		}, 0) / (1024 * 1024 * 1024);
-	});
-	
-	// Check if a model has enough memory to run
-	function hasEnoughMemory(model: {id: string, name?: string, storage_size_megabytes?: number}): boolean {
-		const modelSizeGB = getModelSizeGB(model);
-		return modelSizeGB <= availableMemoryGB();
-	}
-	
-	// Sorted models for dropdown - biggest first, unrunnable at the end
-	const sortedModels = $derived(() => {
-		return [...models].sort((a, b) => {
-			// First: models that have enough memory come before those that don't
-			const aCanFit = hasEnoughMemory(a);
-			const bCanFit = hasEnoughMemory(b);
-			if (aCanFit && !bCanFit) return -1;
-			if (!aCanFit && bCanFit) return 1;
-			
-			// Then: sort by size (biggest first)
-			const aSize = getModelSizeGB(a);
-			const bSize = getModelSizeGB(b);
-			return bSize - aSize;
-		});
-	});
-	
-	// Compute model tags (FASTEST, BIGGEST)
-	const modelTags = $derived(() => {
-		const tags: Record<string, string[]> = {};
-		if (models.length === 0) return tags;
-		
-		// Find the fastest model (highest TPS)
-		let fastestId = '';
-		let highestTps = 0;
-		
-		// Find the biggest model (most memory)
-		let biggestId = '';
-		let highestMemory = 0;
-		
-		for (const model of models) {
-			const perf = estimatePerformance(model.id);
-			const mem = getModelSizeGB(model);
-			
-			if (perf.tps > highestTps) {
-				highestTps = perf.tps;
-				fastestId = model.id;
-			}
-			
-			if (mem > highestMemory) {
-				highestMemory = mem;
-				biggestId = model.id;
-			}
-		}
-		
-		if (fastestId) {
-			tags[fastestId] = tags[fastestId] || [];
-			tags[fastestId].push('FASTEST');
-		}
-		
-		if (biggestId && biggestId !== fastestId) {
-			tags[biggestId] = tags[biggestId] || [];
-			tags[biggestId].push('BIGGEST');
-		} else if (biggestId === fastestId && biggestId) {
-			// Same model is both - unlikely but handle it
-			tags[biggestId].push('BIGGEST');
-		}
-		
-		return tags;
-	});
-
-	onMount(() => {
-		mounted = true;
-		fetchModels();
-	});
-
-	async function fetchModels() {
-		try {
-			const response = await fetch('/models');
-			if (response.ok) {
-				const data = await response.json();
-				// API returns { data: [{ id, name }] } format
-				models = data.data || [];
-				// Restore last launch defaults if available
-				const currentNodeCount = topologyData() ? Object.keys(topologyData()!.nodes).length : 1;
-				applyLaunchDefaults(models, currentNodeCount);
-			}
-		} catch (error) {
-			console.error('Failed to fetch models:', error);
-		}
-	}
-
-	async function launchInstance(modelId: string, specificPreview?: PlacementPreview | null) {
-		if (!modelId || launchingModelId) return;
-		
-		launchingModelId = modelId;
-		
-		try {
-			// Use the specific preview if provided, otherwise fall back to filtered preview
-			const preview = specificPreview ?? filteredPreview();
-			
-			let instanceData: unknown;
-			
-			if (preview?.instance) {
-				// Use the instance from the preview
-				instanceData = preview.instance;
-			} else {
-				// Fallback: GET placement from API
-				const placementResponse = await fetch(
-					`/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${selectedSharding}&instance_meta=${selectedInstanceType}&min_nodes=${selectedMinNodes}`
-				);
-				
-				if (!placementResponse.ok) {
-					const errorText = await placementResponse.text();
-					console.error('Failed to get placement:', errorText);
-					return;
-				}
-				
-				instanceData = await placementResponse.json();
-			}
-			
-			// POST the instance to create it
-			const response = await fetch('/instance', {
-				method: 'POST',
-				headers: { 'Content-Type': 'application/json' },
-				body: JSON.stringify({ instance: instanceData })
-			});
-			
-			if (!response.ok) {
-				const errorText = await response.text();
-				console.error('Failed to launch instance:', errorText);
-			} else {
-				// Always auto-select the newly launched model so the user chats to what they just launched
-				setSelectedChatModel(modelId);
-				
-				// Scroll to the bottom of instances container to show the new instance
-				// Use multiple attempts to ensure DOM has updated with the new instance
-				const scrollToBottom = () => {
-					if (instancesContainerRef) {
-						instancesContainerRef.scrollTo({ top: instancesContainerRef.scrollHeight, behavior: 'smooth' });
-					}
-				};
-				setTimeout(scrollToBottom, 200);
-				setTimeout(scrollToBottom, 500);
-				setTimeout(scrollToBottom, 1000);
-			}
-		} catch (error) {
-			console.error('Error launching instance:', error);
-		} finally {
-			launchingModelId = null;
-		}
-	}
-
-	// Helper to extract model ID from download shard metadata
-	function extractModelIdFromDownload(downloadPayload: Record<string, unknown>): string | null {
-		const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
-		if (!shardMetadata || typeof shardMetadata !== 'object') return null;
-		
-		// Shard metadata is a tagged union: { PipelineShardMetadata: {...} } or { TensorShardMetadata: {...} }
-		const shardObj = shardMetadata as Record<string, unknown>;
-		const shardKeys = Object.keys(shardObj);
-		if (shardKeys.length !== 1) return null;
-		
-		const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
-		if (!shardData) return null;
-		
-		// Model meta is nested: shard.model_card.model_id
-		const modelMeta = shardData.model_card ?? shardData.modelCard;
-		if (!modelMeta || typeof modelMeta !== 'object') return null;
-		
-		const meta = modelMeta as Record<string, unknown>;
-		return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
-	}
-
-	// Helper to parse download progress from payload
-	function parseDownloadProgress(payload: Record<string, unknown>): DownloadProgress | null {
-		const progress = payload.download_progress ?? payload.downloadProgress;
-		if (!progress || typeof progress !== 'object') return null;
-		
-		const prog = progress as Record<string, unknown>;
-		const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
-		const downloadedBytes = getBytes(prog.downloaded_bytes ?? prog.downloadedBytes);
-		const speed = (prog.speed as number) ?? 0;
-		const completedFiles = (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
-		const totalFiles = (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
-		const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
-		
-		const files: DownloadProgress['files'] = [];
-		const filesObj = (prog.files ?? {}) as Record<string, unknown>;
-		for (const [fileName, fileData] of Object.entries(filesObj)) {
-			if (!fileData || typeof fileData !== 'object') continue;
-			const fd = fileData as Record<string, unknown>;
-			const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
-			const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
-			files.push({
-				name: fileName,
-				totalBytes: fTotal,
-				downloadedBytes: fDownloaded,
-				speed: (fd.speed as number) ?? 0,
-				etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
-				percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0
-			});
-		}
-		
-		return {
-			totalBytes,
-			downloadedBytes,
-			speed,
-			etaMs: etaMs || (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
-			percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
-			completedFiles,
-			totalFiles,
-			files
-		};
-	}
-
-	// Helper to get download status for a model (checks all downloads for matching model ID)
-	function getModelDownloadStatus(modelId: string): { 
-		isDownloading: boolean; 
-		progress: DownloadProgress | null;
-		perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }>;
-	} {
-		if (!downloadsData || Object.keys(downloadsData).length === 0) {
-			return { isDownloading: false, progress: null, perNode: [] };
-		}
-
-		let totalBytes = 0;
-		let downloadedBytes = 0;
-		let totalSpeed = 0;
-		let completedFiles = 0;
-		let totalFiles = 0;
-		let isDownloading = false;
-		const allFiles: DownloadProgress['files'] = [];
-		const perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }> = [];
-
-		// Check all nodes for downloads matching this model
-		for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
-			if (!Array.isArray(nodeDownloads)) continue;
-
-			for (const downloadWrapped of nodeDownloads) {
-				if (!downloadWrapped || typeof downloadWrapped !== 'object') continue;
-				
-				const keys = Object.keys(downloadWrapped as Record<string, unknown>);
-				if (keys.length !== 1) continue;
-				
-				const downloadKind = keys[0];
-				const downloadPayload = (downloadWrapped as Record<string, unknown>)[downloadKind] as Record<string, unknown>;
-				
-				if (downloadKind !== 'DownloadOngoing') continue;
-				if (!downloadPayload) continue;
-				
-				const downloadModelId = extractModelIdFromDownload(downloadPayload);
-				
-				// Match if the model ID contains or equals the requested model
-				// (handles cases like "mlx-community/Meta-Llama..." matching)
-				if (!downloadModelId || !downloadModelId.includes(modelId.split('/').pop() || modelId)) {
-					// Try exact match or partial match
-					if (downloadModelId !== modelId) continue;
-				}
-				
-				isDownloading = true;
-				
-				const progress = parseDownloadProgress(downloadPayload);
-				if (progress) {
-					// Sum all values across nodes - each node downloads independently
-					totalBytes += progress.totalBytes;
-					downloadedBytes += progress.downloadedBytes;
-					totalSpeed += progress.speed;
-					completedFiles += progress.completedFiles;
-					totalFiles += progress.totalFiles;
-					allFiles.push(...progress.files);
-
-					const nodeName = data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
-					perNode.push({ nodeId, nodeName, progress });
-				}
-			}
-		}
-
-		if (!isDownloading) {
-			return { isDownloading: false, progress: null, perNode: [] };
-		}
-
-		// ETA = total remaining bytes / total speed across all nodes
-		const remainingBytes = totalBytes - downloadedBytes;
-		const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
-
-		return {
-			isDownloading: true,
-			progress: {
-				totalBytes,
-				downloadedBytes,
-				speed: totalSpeed,
-				etaMs,
-				percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
-				completedFiles,
-				totalFiles,
-				files: allFiles
-			},
-			perNode
-		};
-	}
-
-	// Debug: Log downloads data when it changes
-	$effect(() => {
-		if (downloadsData && Object.keys(downloadsData).length > 0) {
-			console.log('[Download Debug] Current downloads:', downloadsData);
-		}
-	});
-
-	// Helper to get download status for an instance
-	function getInstanceDownloadStatus(instanceId: string, instanceWrapped: unknown): { 
-		isDownloading: boolean; 
-		progress: DownloadProgress | null; 
-		statusText: string;
-		perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }>;
-	} {
-		if (!downloadsData || Object.keys(downloadsData).length === 0) {
-			return { isDownloading: false, progress: null, statusText: 'RUNNING', perNode: [] };
-		}
-
-		// Unwrap the instance
-		const [instanceTag, instance] = getTagged(instanceWrapped);
-		if (!instance || typeof instance !== 'object') {
-			return { isDownloading: false, progress: null, statusText: 'PREPARING', perNode: [] };
-		}
-
-		const inst = instance as { shardAssignments?: { nodeToRunner?: Record<string, string>; runnerToShard?: Record<string, unknown>; modelId?: string } };
-		const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
-		const runnerToShard = inst.shardAssignments?.runnerToShard || {};
-		const instanceModelId = inst.shardAssignments?.modelId;
-
-		// Build reverse mapping: runnerId -> nodeId
-		const runnerToNode: Record<string, string> = {};
-		for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
-			runnerToNode[runnerId] = nodeId;
-		}
-
-		let totalBytes = 0;
-		let downloadedBytes = 0;
-		let totalSpeed = 0;
-		let completedFiles = 0;
-		let totalFiles = 0;
-		let isDownloading = false;
-		const allFiles: DownloadProgress['files'] = [];
-		const perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }> = [];
-
-		// Check downloads for nodes that are part of this instance
-		for (const runnerId of Object.keys(runnerToShard)) {
-			const nodeId = runnerToNode[runnerId];
-			if (!nodeId) continue;
-
-			const nodeDownloads = downloadsData[nodeId];
-			if (!Array.isArray(nodeDownloads)) continue;
-
-			for (const downloadWrapped of nodeDownloads) {
-				if (!downloadWrapped || typeof downloadWrapped !== 'object') continue;
-				
-				const keys = Object.keys(downloadWrapped as Record<string, unknown>);
-				if (keys.length !== 1) continue;
-				
-				const downloadKind = keys[0];
-				const downloadPayload = (downloadWrapped as Record<string, unknown>)[downloadKind] as Record<string, unknown>;
-				
-				if (downloadKind !== 'DownloadOngoing') continue;
-				if (!downloadPayload) continue;
-
-				// Check if this download is for this instance's model
-				const downloadModelId = extractModelIdFromDownload(downloadPayload);
-				if (instanceModelId && downloadModelId && downloadModelId === instanceModelId) {
-					isDownloading = true;
-					
-					const progress = parseDownloadProgress(downloadPayload);
-					if (progress) {
-						// Sum all values across nodes - each node downloads independently
-						totalBytes += progress.totalBytes;
-						downloadedBytes += progress.downloadedBytes;
-						totalSpeed += progress.speed;
-						completedFiles += progress.completedFiles;
-						totalFiles += progress.totalFiles;
-						allFiles.push(...progress.files);
-
-						const nodeName = data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
-						perNode.push({ nodeId, nodeName, progress });
-					}
-				}
-			}
-		}
-
-		if (!isDownloading) {
-			// Check runner status for other states
-			const statusInfo = deriveInstanceStatus(instanceWrapped);
-			return { isDownloading: false, progress: null, statusText: statusInfo.statusText, perNode: [] };
-		}
-
-		// ETA = total remaining bytes / total speed across all nodes
-		const remainingBytes = totalBytes - downloadedBytes;
-		const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
-
-		return {
-			isDownloading: true,
-			progress: {
-				totalBytes,
-				downloadedBytes,
-				speed: totalSpeed,
-				etaMs,
-				percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
-				completedFiles,
-				totalFiles,
-				files: allFiles
-			},
-			statusText: 'DOWNLOADING',
-			perNode
-		};
-	}
-
-	// Derive instance status from runners
-	// Get color class for a status
-	function getStatusColor(statusText: string): string {
-		switch (statusText) {
-			case 'FAILED': return 'text-red-400';
-			case 'SHUTDOWN': return 'text-gray-400';
-			case 'DOWNLOADING': return 'text-blue-400';
-			case 'LOADING': 
-			case 'WARMING UP': 
-			case 'WAITING':
-			case 'INITIALIZING': return 'text-yellow-400';
-			case 'RUNNING': return 'text-teal-400';
-			case 'READY': 
-			case 'LOADED': return 'text-green-400';
-			default: return 'text-exo-light-gray';
-		}
-	}
-
-	function deriveInstanceStatus(instanceWrapped: unknown): { statusText: string; statusClass: string } {
-		const [, instance] = getTagged(instanceWrapped);
-		if (!instance || typeof instance !== 'object') {
-			return { statusText: 'PREPARING', statusClass: 'inactive' };
-		}
-		
-		const inst = instance as { shardAssignments?: { runnerToShard?: Record<string, unknown> } };
-		const runnerIds = Object.keys(inst.shardAssignments?.runnerToShard || {});
-
-		const statuses = runnerIds
-			.map(rid => {
-				const r = runnersData[rid];
-				if (!r) return null;
-				const [kind] = getTagged(r);
-				const statusMap: Record<string, string> = {
-					RunnerWaitingForInitialization: 'WaitingForInitialization',
-					RunnerInitializingBackend: 'InitializingBackend',
-					RunnerWaitingForModel: 'WaitingForModel',
-					RunnerLoading: 'Loading',
-					RunnerLoaded: 'Loaded',
-					RunnerWarmingUp: 'WarmingUp',
-					RunnerReady: 'Ready',
-					RunnerRunning: 'Running',
-					RunnerShutdown: 'Shutdown',
-					RunnerFailed: 'Failed',
-				};
-				return kind ? statusMap[kind] || null : null;
-			})
-			.filter((s): s is string => s !== null);
-
-		const has = (s: string) => statuses.includes(s);
-
-		if (statuses.length === 0) return { statusText: 'PREPARING', statusClass: 'inactive' };
-		if (has('Failed')) return { statusText: 'FAILED', statusClass: 'failed' };
-		if (has('Shutdown')) return { statusText: 'SHUTDOWN', statusClass: 'inactive' };
-		if (has('Loading')) return { statusText: 'LOADING', statusClass: 'starting' };
-		if (has('WarmingUp')) return { statusText: 'WARMING UP', statusClass: 'starting' };
-		if (has('Running')) return { statusText: 'RUNNING', statusClass: 'running' };
-		if (has('Ready')) return { statusText: 'READY', statusClass: 'loaded' };
-		if (has('Loaded')) return { statusText: 'LOADED', statusClass: 'loaded' };
-		if (has('WaitingForModel')) return { statusText: 'WAITING', statusClass: 'starting' };
-		if (has('InitializingBackend')) return { statusText: 'INITIALIZING', statusClass: 'starting' };
-		if (has('WaitingForInitialization')) return { statusText: 'INITIALIZING', statusClass: 'starting' };
-
-		return { statusText: 'RUNNING', statusClass: 'active' };
-	}
-
-	function getBytes(value: unknown): number {
-		if (typeof value === 'number') return value;
-		if (value && typeof value === 'object') {
-			const v = value as Record<string, unknown>;
-			if (typeof v.in_bytes === 'number') return v.in_bytes;
-			if (typeof v.inBytes === 'number') return v.inBytes;
-		}
-		return 0;
-	}
-
-	async function deleteInstance(instanceId: string) {
-		if (!confirm(`Delete instance ${instanceId.slice(0, 8)}...?`)) return;
-		
-		// Get the model ID of the instance being deleted before we delete it
-		const deletedInstanceModelId = getInstanceModelId(instanceData[instanceId]);
-		const wasSelected = selectedChatModel() === deletedInstanceModelId;
-		
-		try {
-			const response = await fetch(`/instance/${instanceId}`, {
-				method: 'DELETE',
-				headers: { 'Content-Type': 'application/json' }
-			});
-			
-			if (!response.ok) {
-				console.error('Failed to delete instance:', response.status);
-			} else if (wasSelected) {
-				// If we deleted the currently selected model, switch to another available model
-				// Find another instance that isn't the one we just deleted
-				const remainingInstances = Object.entries(instanceData).filter(([id]) => id !== instanceId);
-				if (remainingInstances.length > 0) {
-					// Select the last instance (most recently added, since objects preserve insertion order)
-					const [, lastInstance] = remainingInstances[remainingInstances.length - 1];
-					const newModelId = getInstanceModelId(lastInstance);
-					if (newModelId && newModelId !== 'Unknown' && newModelId !== 'Unknown Model') {
-						setSelectedChatModel(newModelId);
-					} else {
-						// Clear selection if no valid model found
-						setSelectedChatModel('');
-					}
-				} else {
-					// No more instances, clear the selection
-					setSelectedChatModel('');
-				}
-			}
-		} catch (error) {
-			console.error('Error deleting instance:', error);
-		}
-	}
-
-	// Helper to unwrap tagged unions like { MlxRingInstance: {...} }
-	function getTagged(obj: unknown): [string | null, unknown] {
-		if (!obj || typeof obj !== 'object') return [null, null];
-		const keys = Object.keys(obj as Record<string, unknown>);
-		if (keys.length === 1) {
-			return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
-		}
-		return [null, null];
-	}
-
-	// Get model ID from an instance
-	function getInstanceModelId(instanceWrapped: unknown): string {
-		const [, instance] = getTagged(instanceWrapped);
-		if (!instance || typeof instance !== 'object') return 'Unknown';
-		const inst = instance as { shardAssignments?: { modelId?: string } };
-		return inst.shardAssignments?.modelId || 'Unknown Model';
-	}
-
-	// Get instance details: type (MLX Ring/IBV), sharding (Pipeline/Tensor), and node names
-	function getInstanceInfo(instanceWrapped: unknown): { 
-		instanceType: string; 
-		sharding: string; 
-		nodeNames: string[];
-		nodeIds: string[];
-		nodeCount: number;
-	} {
-		const [instanceTag, instance] = getTagged(instanceWrapped);
-		if (!instance || typeof instance !== 'object') {
-			return { instanceType: 'Unknown', sharding: 'Unknown', nodeNames: [], nodeIds: [], nodeCount: 0 };
-		}
-		
-		// Instance type from tag
-		let instanceType = 'Unknown';
-		if (instanceTag === 'MlxRingInstance') instanceType = 'MLX Ring';
-		else if (instanceTag === 'MlxIbvInstance' || instanceTag === 'MlxJacclInstance') instanceType = 'MLX RDMA';
-		
-		const inst = instance as { 
-			shardAssignments?: { 
-				nodeToRunner?: Record<string, string>; 
-				runnerToShard?: Record<string, unknown>;
-			} 
-		};
-		
-		// Sharding strategy from first shard
-		let sharding = 'Unknown';
-		const runnerToShard = inst.shardAssignments?.runnerToShard || {};
-		const firstShardWrapped = Object.values(runnerToShard)[0];
-		if (firstShardWrapped) {
-			const [shardTag] = getTagged(firstShardWrapped);
-			if (shardTag === 'PipelineShardMetadata') sharding = 'Pipeline';
-			else if (shardTag === 'TensorShardMetadata') sharding = 'Tensor';
-			else if (shardTag === 'PrefillDecodeShardMetadata') sharding = 'Prefill/Decode';
-		}
-		
-		// Node names from topology
-		const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
-		const nodeIds = Object.keys(nodeToRunner);
-		const nodeNames = nodeIds.map(nodeId => {
-			const node = data?.nodes?.[nodeId];
-			return node?.friendly_name || nodeId.slice(0, 8);
-		});
-		
-		return { instanceType, sharding, nodeNames, nodeIds, nodeCount: nodeIds.length };
-	}
-
-	function formatLastUpdate(): string {
-		if (!update) return 'ACQUIRING...';
-		const seconds = Math.floor((Date.now() - update) / 1000);
-		if (seconds < 5) return 'LIVE';
-		return `${seconds}s AGO`;
-	}
-
-	function formatBytes(bytes: number, decimals = 2): string {
-		if (!bytes || bytes === 0) return '0 B';
-		const k = 1024;
-		const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
-		const i = Math.floor(Math.log(bytes) / Math.log(k));
-		return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
-	}
-
-	function formatSpeed(bps: number): string {
-		if (!bps || bps <= 0) return '0 B/s';
-		return formatBytes(bps, 1) + '/s';
-	}
-
-	function getNodeLabel(nodeId: string): string {
-		const node = data?.nodes?.[nodeId];
-		return node?.friendly_name || nodeId.slice(0, 8);
-	}
-
-	function getInterfaceLabel(nodeId: string, ip?: string): { label: string; missing: boolean } {
-		if (!ip) return { label: '?', missing: true };
-		const node = data?.nodes?.[nodeId];
-		if (!node) return { label: '?', missing: true };
-
-		// Prefer explicit network_interfaces from NodePerformanceProfile
-		const matchFromInterfaces = node.network_interfaces?.find((iface) =>
-			(iface.addresses || []).some((addr) => addr === ip)
-		);
-		if (matchFromInterfaces?.name) {
-			return { label: `${matchFromInterfaces.name} on ${getNodeLabel(nodeId)}`, missing: false };
-		}
-
-		// Fallback to derived ip_to_interface map
-		const mapped = node.ip_to_interface?.[ip];
-		if (mapped && mapped.trim().length > 0) {
-			return { label: `${mapped} on ${getNodeLabel(nodeId)}`, missing: false };
-		}
-
-		return { label: '?', missing: true };
-	}
-
-	function getOrderedRunnerNodes(instance: Record<string, unknown>, shardType: 'Pipeline' | 'Tensor') {
-		const runnerToShard = (instance.shardAssignments as { runnerToShard?: Record<string, unknown> } | undefined)?.runnerToShard || {};
-		const nodeToRunner = (instance.shardAssignments as { nodeToRunner?: Record<string, string> } | undefined)?.nodeToRunner || {};
-		const runnerEntries = Object.entries(runnerToShard).map(([runnerId, shardWrapped]) => {
-			const [tag, shard] = getTagged(shardWrapped);
-			const meta = (shard as { modelMeta?: { worldSize?: number; nLayers?: number; deviceRank?: number } } | undefined);
-			const deviceRank = meta?.modelMeta?.deviceRank ?? 0;
-			return { runnerId, tag, deviceRank };
-		});
-
-		const ordered = runnerEntries
-			.filter(r => (shardType === 'Pipeline' ? r.tag === 'PipelineShardMetadata' : r.tag === 'TensorShardMetadata'))
-			.sort((a, b) => a.deviceRank - b.deviceRank)
-			.map((r, idx) => {
-				const nodeId = Object.entries(nodeToRunner).find(([, rid]) => rid === r.runnerId)?.[0];
-				return { nodeId, runnerId: r.runnerId, order: idx };
-			})
-			.filter(item => item.nodeId);
-
-		return ordered as Array<{ nodeId: string; runnerId: string; order: number }>;
-	}
-
-	function pickHost(hosts?: Array<{ ip: string; port: number }>): { ip: string; port: number } | null {
-		if (!hosts || hosts.length === 0) return null;
-		const scored = hosts
-			.filter(h => h.ip && h.ip !== '0.0.0.0' && h.port && h.port > 0)
-			.map(h => {
-				const ip = h.ip;
-				const score = ip.startsWith('10.') || ip.startsWith('172.') || ip.startsWith('192.168') ? 3
-					: ip.startsWith('169.254') ? 2
-					: 1;
-				return { host: h, score };
-			});
-		if (scored.length === 0) return null;
-		scored.sort((a, b) => b.score - a.score);
-		return scored[0].host;
-	}
-
-	function getInstanceConnections(instanceWrapped: unknown): Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> {
-		const [instanceTag, instance] = getTagged(instanceWrapped);
-		if (!instance || typeof instance !== 'object') return [];
-
-		// Jaccl (RDMA) – show RDMA interfaces from ibvDevices
-		if (instanceTag === 'MlxJacclInstance') {
-			const ordered = getOrderedRunnerNodes(instance as Record<string, unknown>, 'Tensor');
-			const ibvDevices = (instance as { ibvDevices?: Array<Array<string | null>> }).ibvDevices || [];
-			const rows: Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> = [];
-
-			for (let i = 0; i < ordered.length; i++) {
-				for (let j = i + 1; j < ordered.length; j++) {
-					const iface = ibvDevices[i]?.[j] ?? ibvDevices[j]?.[i] ?? null;
-					if (!iface) continue;
-					const fromId = ordered[i].nodeId;
-					const toId = ordered[j].nodeId;
-					rows.push({
-						from: getNodeLabel(fromId),
-						to: getNodeLabel(toId),
-						ip: iface,
-						ifaceLabel: `RDMA ${iface}`,
-						missingIface: false
-					});
-				}
-			}
-			return rows;
-		}
-
-		// Ring – derive ring order from pipeline shard ranks and pick host IPs from hostsByNode
-		if (instanceTag === 'MlxRingInstance') {
-			const ordered = getOrderedRunnerNodes(instance as Record<string, unknown>, 'Pipeline');
-			const hostsByNode = (instance as { hostsByNode?: Record<string, Array<{ ip: string; port: number }>> }).hostsByNode || {};
-			const rows: Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> = [];
-			if (ordered.length === 0) return rows;
-
-			for (let idx = 0; idx < ordered.length; idx++) {
-				const current = ordered[idx];
-				const next = ordered[(idx + 1) % ordered.length];
-				const host = pickHost(hostsByNode[next.nodeId]);
-				const ip = host ? `${host.ip}:${host.port}` : '?';
-				const ifaceInfo = host ? getInterfaceLabel(next.nodeId, host.ip) : { label: '?', missing: true };
-				rows.push({
-					from: getNodeLabel(current.nodeId),
-					to: getNodeLabel(next.nodeId),
-					ip,
-					ifaceLabel: ifaceInfo.label,
-					missingIface: ifaceInfo.missing
-				});
-			}
-			return rows;
-		}
-
-		return [];
-	}
-
-	function formatEta(ms: number): string {
-		if (!ms || ms <= 0) return '--';
-		const totalSeconds = Math.round(ms / 1000);
-		const s = totalSeconds % 60;
-		const m = Math.floor(totalSeconds / 60) % 60;
-		const h = Math.floor(totalSeconds / 3600);
-		if (h > 0) return `${h}h ${m}m`;
-		if (m > 0) return `${m}m ${s}s`;
-		return `${s}s`;
-	}
-
-	function handleNewChat() {
-		createConversation();
-	}
-
-	function handleGoHome() {
-		clearChat();
-	}
-
-	// Slider drag handlers
-	function handleSliderDrag(clientX: number) {
-		if (!sliderTrackElement || availableMinNodes <= 1) return;
-		
-		const rect = sliderTrackElement.getBoundingClientRect();
-		const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
-		const rawValue = Math.round(percentage * (availableMinNodes - 1)) + 1;
-		const clampedValue = Math.max(1, Math.min(availableMinNodes, rawValue));
-		
-		// Find nearest valid value
-		const validCounts = validMinNodeCounts();
-		if (validCounts.has(clampedValue)) {
-			selectedMinNodes = clampedValue;
-		} else {
-			// Find nearest valid value
-			let nearest = clampedValue;
-			let minDist = Infinity;
-			for (const v of validCounts) {
-				const dist = Math.abs(v - clampedValue);
-				if (dist < minDist) {
-					minDist = dist;
-					nearest = v;
-				}
-			}
-			if (validCounts.size > 0) {
-				selectedMinNodes = nearest;
-			}
-		}
-	}
-
-	function handleSliderMouseDown(event: MouseEvent) {
-		isDraggingSlider = true;
-		handleSliderDrag(event.clientX);
-	}
-
-	function handleSliderMouseMove(event: MouseEvent) {
-		if (isDraggingSlider) {
-			handleSliderDrag(event.clientX);
-		}
-	}
-
-	function handleSliderMouseUp() {
-		isDraggingSlider = false;
-		saveLaunchDefaults();
-	}
-
-	// Handle touch events for mobile
-	function handleSliderTouchStart(event: TouchEvent) {
-		isDraggingSlider = true;
-		if (event.touches.length > 0) {
-			handleSliderDrag(event.touches[0].clientX);
-		}
-	}
-
-	function handleSliderTouchMove(event: TouchEvent) {
-		if (isDraggingSlider && event.touches.length > 0) {
-			event.preventDefault();
-			handleSliderDrag(event.touches[0].clientX);
-		}
-	}
-
-	function handleSliderTouchEnd() {
-		isDraggingSlider = false;
-		saveLaunchDefaults();
-	}
-
-	const nodeCount = $derived(data ? Object.keys(data.nodes).length : 0);
-	const instanceCount = $derived(Object.keys(instanceData).length);
-	
-	// Helper to get the number of nodes in a placement preview
-	function getPreviewNodeCount(preview: PlacementPreview): number {
-		if (!preview.memory_delta_by_node) return 0;
-		// Count nodes that have non-zero memory delta (i.e. nodes actually used)
-		return Object.entries(preview.memory_delta_by_node).filter(([_, delta]) => delta > 0).length;
-	}
-	
-	// Available min nodes options based on topology (like old dashboard)
-	const availableMinNodes = $derived(Math.max(1, nodeCount));
-	
-	// Compute which min node values have valid previews for the current model/sharding/instance type
-	// A minNodes value N is valid if there exists a placement with nodeCount >= N
-	// Note: previewsData already contains previews for the selected model (fetched via API)
-	const validMinNodeCounts = $derived(() => {
-		if (!selectedModelId || previewsData.length === 0) {
-			// If no model selected or no previews, allow all node counts (UI shows all as clickable)
-			return new Set(Array.from({ length: availableMinNodes }, (_, i) => i + 1));
-		}
-		
-		// Find the max node count among valid placements for this model/sharding/instance
-		// (model_id filter not needed since previewsData is already for selected model)
-		let maxValidNodes = 0;
-		for (const preview of previewsData) {
-			if (preview.sharding !== selectedSharding) continue;
-			if (!matchesSelectedRuntime(preview.instance_meta)) continue;
-			if (preview.error !== null) continue;
-			if (!preview.memory_delta_by_node) continue;
-			
-			const previewNodes = getPreviewNodeCount(preview);
-			if (previewNodes > maxValidNodes) {
-				maxValidNodes = previewNodes;
-			}
-		}
-		
-		// All values from 1 to maxValidNodes are valid (since there's a placement with >= that many nodes)
-		if (maxValidNodes === 0) return new Set<number>();
-		return new Set(Array.from({ length: maxValidNodes }, (_, i) => i + 1));
-	});
-	
-	// Get ALL filtered previews based on current settings (matching minimum nodes)
-	// Note: previewsData already contains previews for the selected model (fetched via API)
-	// We filter by sharding/instance type and min nodes, returning ALL eligible previews
-	const filteredPreviews = $derived(() => {
-		if (!selectedModelId || previewsData.length === 0) return [];
-		
-		// Find previews matching sharding/instance type (model_id filter not needed since previewsData is already for selected model)
-		const matchingPreviews = previewsData.filter((p: PlacementPreview) => 
-			p.sharding === selectedSharding && 
-			matchesSelectedRuntime(p.instance_meta) && 
-			p.error === null &&
-			p.memory_delta_by_node !== null
-		);
-		
-		// Filter to previews with node count >= selectedMinNodes, sorted by node count (ascending)
-		return matchingPreviews
-			.filter((p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes)
-			.sort((a: PlacementPreview, b: PlacementPreview) => getPreviewNodeCount(a) - getPreviewNodeCount(b));
-	});
-	
-	// Get the first filtered preview (for launch function compatibility)
-	const filteredPreview = $derived(() => filteredPreviews()[0] ?? null);
-	
-	// Auto-update selectedMinNodes when node count changes (default to 1 = show all placements)
-	$effect(() => {
-		const maxNodes = availableMinNodes;
-		if (!minNodesInitialized && maxNodes > 0) {
-			// On initial load, default to 1 (minimum) to show all valid placements
-			selectedMinNodes = 1;
-			minNodesInitialized = true;
-		} else if (selectedMinNodes > maxNodes) {
-			// If current selection exceeds available nodes, cap it
-			selectedMinNodes = maxNodes;
-		}
-	});
-	
-	// Auto-adjust selectedMinNodes to a valid value when it becomes invalid
-	$effect(() => {
-		const valid = validMinNodeCounts();
-		if (valid.size > 0 && !valid.has(selectedMinNodes)) {
-			// Find the smallest valid count >= current selection, or the largest valid count
-			const validArray = Array.from(valid).sort((a, b) => a - b);
-			const nextValid = validArray.find(n => n >= selectedMinNodes) ?? validArray[validArray.length - 1];
-			if (nextValid !== undefined) {
-				selectedMinNodes = nextValid;
-			}
-		}
-	});
-	
-	// Calculate total memory usage across all nodes
-	const clusterMemory = $derived(() => {
-		if (!data) return { used: 0, total: 0 };
-		return Object.values(data.nodes).reduce((acc, n) => {
-			const total = n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
-			const used = n.macmon_info?.memory?.ram_usage ?? 0;
-			return { used: acc.used + used, total: acc.total + total };
-		}, { used: 0, total: 0 });
-	});
-	
+  import {
+    TopologyGraph,
+    ChatForm,
+    ChatMessages,
+    ChatSidebar,
+    ModelCard,
+  } from "$lib/components";
+  import {
+    hasStartedChat,
+    isTopologyMinimized,
+    topologyData,
+    lastUpdate,
+    clearChat,
+    instances,
+    runners,
+    downloads,
+    placementPreviews,
+    selectedPreviewModelId,
+    isLoadingPreviews,
+    selectPreviewModel,
+    createConversation,
+    setSelectedChatModel,
+    selectedChatModel,
+    debugMode,
+    toggleDebugMode,
+    topologyOnlyMode,
+    toggleTopologyOnlyMode,
+    chatSidebarVisible,
+    toggleChatSidebarVisible,
+    type DownloadProgress,
+    type PlacementPreview,
+  } from "$lib/stores/app.svelte";
+  import HeaderNav from "$lib/components/HeaderNav.svelte";
+  import { fade, fly } from "svelte/transition";
+  import { cubicInOut } from "svelte/easing";
+  import { onMount } from "svelte";
+
+  const chatStarted = $derived(hasStartedChat());
+  const minimized = $derived(isTopologyMinimized());
+  const data = $derived(topologyData());
+  const update = $derived(lastUpdate());
+  const instanceData = $derived(instances());
+  const runnersData = $derived(runners());
+  const downloadsData = $derived(downloads());
+  const previewsData = $derived(placementPreviews());
+  const selectedModelId = $derived(selectedPreviewModelId());
+  const loadingPreviews = $derived(isLoadingPreviews());
+  const debugEnabled = $derived(debugMode());
+  const topologyOnlyEnabled = $derived(topologyOnlyMode());
+  const sidebarVisible = $derived(chatSidebarVisible());
+
+  let mounted = $state(false);
+
+  // Instance launch state
+  let models = $state<
+    Array<{
+      id: string;
+      name?: string;
+      storage_size_megabytes?: number;
+      tasks?: string[];
+      hugging_face_id?: string;
+    }>
+  >([]);
+
+  // Model tasks lookup for ChatForm - maps both short IDs and full HuggingFace IDs
+  const modelTasks = $derived(() => {
+    const tasks: Record<string, string[]> = {};
+    for (const model of models) {
+      if (model.tasks && model.tasks.length > 0) {
+        // Map by short ID
+        tasks[model.id] = model.tasks;
+        // Also map by hugging_face_id from the API response
+        if (model.hugging_face_id) {
+          tasks[model.hugging_face_id] = model.tasks;
+        }
+      }
+    }
+    return tasks;
+  });
+
+  // Helper to check if a model supports image generation
+  function modelSupportsImageGeneration(modelId: string): boolean {
+    const model = models.find(
+      (m) => m.id === modelId || m.hugging_face_id === modelId,
+    );
+    if (!model?.tasks) return false;
+    return (
+      model.tasks.includes("TextToImage") ||
+      model.tasks.includes("ImageToImage")
+    );
+  }
+  let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
+  type InstanceMeta = "MlxRing" | "MlxIbv" | "MlxJaccl";
+
+  // Launch defaults persistence
+  const LAUNCH_DEFAULTS_KEY = "exo-launch-defaults";
+  interface LaunchDefaults {
+    modelId: string | null;
+    sharding: "Pipeline" | "Tensor";
+    instanceType: InstanceMeta;
+    minNodes: number;
+  }
+
+  function saveLaunchDefaults(): void {
+    const defaults: LaunchDefaults = {
+      modelId: selectedPreviewModelId(),
+      sharding: selectedSharding,
+      instanceType: selectedInstanceType,
+      minNodes: selectedMinNodes,
+    };
+    try {
+      localStorage.setItem(LAUNCH_DEFAULTS_KEY, JSON.stringify(defaults));
+    } catch (e) {
+      console.warn("Failed to save launch defaults:", e);
+    }
+  }
+
+  function loadLaunchDefaults(): LaunchDefaults | null {
+    try {
+      const stored = localStorage.getItem(LAUNCH_DEFAULTS_KEY);
+      if (!stored) return null;
+      return JSON.parse(stored) as LaunchDefaults;
+    } catch (e) {
+      console.warn("Failed to load launch defaults:", e);
+      return null;
+    }
+  }
+
+  function applyLaunchDefaults(
+    availableModels: Array<{ id: string }>,
+    maxNodes: number,
+  ): void {
+    const defaults = loadLaunchDefaults();
+    if (!defaults) return;
+
+    // Apply sharding and instance type unconditionally
+    selectedSharding = defaults.sharding;
+    selectedInstanceType = defaults.instanceType;
+
+    // Apply minNodes if valid (between 1 and maxNodes)
+    if (
+      defaults.minNodes &&
+      defaults.minNodes >= 1 &&
+      defaults.minNodes <= maxNodes
+    ) {
+      selectedMinNodes = defaults.minNodes;
+    }
+
+    // Only apply model if it exists in the available models
+    if (
+      defaults.modelId &&
+      availableModels.some((m) => m.id === defaults.modelId)
+    ) {
+      selectPreviewModel(defaults.modelId);
+    }
+  }
+
+  let selectedInstanceType = $state<InstanceMeta>("MlxRing");
+  let selectedMinNodes = $state<number>(1);
+  let minNodesInitialized = $state(false);
+  let launchingModelId = $state<string | null>(null);
+  let instanceDownloadExpandedNodes = $state<Set<string>>(new Set());
+
+  // Custom dropdown state
+  let isModelDropdownOpen = $state(false);
+  let modelDropdownSearch = $state("");
+
+  // Slider dragging state
+  let isDraggingSlider = $state(false);
+  let sliderTrackElement: HTMLDivElement | null = $state(null);
+
+  // Instances container ref for scrolling
+  let instancesContainerRef: HTMLDivElement | null = $state(null);
+  // Chat scroll container ref for precise scroll behavior
+  let chatScrollRef: HTMLDivElement | null = $state(null);
+
+  // Instance hover state for highlighting nodes in topology
+  let hoveredInstanceId = $state<string | null>(null);
+
+  // Preview card hover state for highlighting nodes in topology
+  let hoveredPreviewNodes = $state<Set<string>>(new Set());
+
+  // Helper to unwrap tagged instance for hover highlighting
+  function unwrapInstanceNodes(instanceWrapped: unknown): Set<string> {
+    if (!instanceWrapped || typeof instanceWrapped !== "object")
+      return new Set();
+    const keys = Object.keys(instanceWrapped as Record<string, unknown>);
+    if (keys.length !== 1) return new Set();
+    const instance = (instanceWrapped as Record<string, unknown>)[keys[0]];
+    if (!instance || typeof instance !== "object") return new Set();
+    const inst = instance as {
+      shardAssignments?: { nodeToRunner?: Record<string, string> };
+    };
+    if (!inst.shardAssignments?.nodeToRunner) return new Set();
+    return new Set(Object.keys(inst.shardAssignments.nodeToRunner));
+  }
+
+  function toggleInstanceDownloadDetails(nodeId: string): void {
+    const next = new Set(instanceDownloadExpandedNodes);
+    if (next.has(nodeId)) {
+      next.delete(nodeId);
+    } else {
+      next.add(nodeId);
+    }
+    instanceDownloadExpandedNodes = next;
+  }
+
+  // Compute highlighted nodes from hovered instance or hovered preview
+  const highlightedNodes = $derived(() => {
+    // First check instance hover
+    if (hoveredInstanceId) {
+      const instanceWrapped = instanceData[hoveredInstanceId];
+      return unwrapInstanceNodes(instanceWrapped);
+    }
+    // Then check preview hover
+    if (hoveredPreviewNodes.size > 0) {
+      return hoveredPreviewNodes;
+    }
+    return new Set<string>();
+  });
+
+  // Helper to estimate memory from model ID (mirrors ModelCard logic)
+  // Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
+  function estimateMemoryGB(modelId: string, modelName?: string): number {
+    // Check both ID and name for quantization info
+    const combined = `${modelId} ${modelName || ""}`.toLowerCase();
+
+    // Detect quantization level - affects memory by roughly 2x between levels
+    const is4bit =
+      combined.includes("4bit") ||
+      combined.includes("4-bit") ||
+      combined.includes(":4bit");
+    const is8bit =
+      combined.includes("8bit") ||
+      combined.includes("8-bit") ||
+      combined.includes(":8bit");
+    // 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
+    const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
+    const id = modelId.toLowerCase();
+
+    // Known large models that don't follow the standard naming pattern
+    // DeepSeek V3 has 685B parameters
+    if (id.includes("deepseek-v3")) {
+      return Math.round(685 * quantMultiplier);
+    }
+    // DeepSeek V2 has 236B parameters
+    if (id.includes("deepseek-v2")) {
+      return Math.round(236 * quantMultiplier);
+    }
+    // Llama 4 Scout/Maverick are large models
+    if (id.includes("llama-4")) {
+      return Math.round(400 * quantMultiplier);
+    }
+
+    // Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
+    const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
+    if (paramMatch) {
+      const params = parseFloat(paramMatch[1]);
+      return Math.max(4, Math.round(params * quantMultiplier));
+    }
+
+    // Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
+    if (id.includes("405b") || id.includes("400b"))
+      return Math.round(405 * quantMultiplier);
+    if (id.includes("180b")) return Math.round(180 * quantMultiplier);
+    if (id.includes("141b") || id.includes("140b"))
+      return Math.round(140 * quantMultiplier);
+    if (id.includes("123b") || id.includes("120b"))
+      return Math.round(123 * quantMultiplier);
+    if (id.includes("72b") || id.includes("70b"))
+      return Math.round(70 * quantMultiplier);
+    if (id.includes("67b") || id.includes("65b"))
+      return Math.round(65 * quantMultiplier);
+    if (
+      id.includes("35b") ||
+      id.includes("34b") ||
+      id.includes("32b") ||
+      id.includes("30b")
+    )
+      return Math.round(32 * quantMultiplier);
+    if (id.includes("27b") || id.includes("26b") || id.includes("22b"))
+      return Math.round(24 * quantMultiplier);
+    if (id.includes("14b") || id.includes("13b") || id.includes("15b"))
+      return Math.round(14 * quantMultiplier);
+    if (id.includes("8b") || id.includes("9b") || id.includes("7b"))
+      return Math.round(8 * quantMultiplier);
+    if (id.includes("3b") || id.includes("3.8b"))
+      return Math.round(4 * quantMultiplier);
+    if (
+      id.includes("2b") ||
+      id.includes("1b") ||
+      id.includes("1.5b") ||
+      id.includes("0.5b")
+    )
+      return Math.round(2 * quantMultiplier);
+    return 16; // Default fallback
+  }
+
+  // Helper to estimate performance from model ID
+  function estimatePerformance(modelId: string): { ttft: number; tps: number } {
+    const id = modelId.toLowerCase();
+    if (id.includes("405b") || id.includes("400b"))
+      return { ttft: 8000, tps: 3 };
+    if (id.includes("180b")) return { ttft: 4000, tps: 5 };
+    if (id.includes("141b") || id.includes("140b"))
+      return { ttft: 3500, tps: 6 };
+    if (id.includes("123b") || id.includes("120b"))
+      return { ttft: 3000, tps: 7 };
+    if (id.includes("72b") || id.includes("70b"))
+      return { ttft: 1800, tps: 12 };
+    if (id.includes("67b") || id.includes("65b"))
+      return { ttft: 1600, tps: 14 };
+    if (
+      id.includes("35b") ||
+      id.includes("34b") ||
+      id.includes("32b") ||
+      id.includes("30b")
+    )
+      return { ttft: 900, tps: 22 };
+    if (id.includes("27b") || id.includes("26b") || id.includes("22b"))
+      return { ttft: 700, tps: 28 };
+    if (id.includes("14b") || id.includes("13b") || id.includes("15b"))
+      return { ttft: 400, tps: 45 };
+    if (id.includes("8b") || id.includes("9b") || id.includes("7b"))
+      return { ttft: 200, tps: 65 };
+    if (id.includes("4b") || id.includes("3b") || id.includes("3.8b"))
+      return { ttft: 100, tps: 95 };
+    if (
+      id.includes("2b") ||
+      id.includes("1b") ||
+      id.includes("1.5b") ||
+      id.includes("0.5b")
+    )
+      return { ttft: 50, tps: 150 };
+    return { ttft: 300, tps: 50 };
+  }
+
+  const matchesSelectedRuntime = (runtime: InstanceMeta): boolean =>
+    selectedInstanceType === "MlxRing"
+      ? runtime === "MlxRing"
+      : runtime === "MlxIbv" || runtime === "MlxJaccl";
+
+  // Helper to check if a model can be launched (has valid placement with >= minNodes)
+  function canModelFit(modelId: string): boolean {
+    // Find previews matching the model, sharding, and instance type
+    const matchingPreviews = previewsData.filter(
+      (p: PlacementPreview) =>
+        p.model_id === modelId &&
+        p.sharding === selectedSharding &&
+        matchesSelectedRuntime(p.instance_meta) &&
+        p.error === null &&
+        p.memory_delta_by_node !== null,
+    );
+
+    // Check if any preview has node count >= selectedMinNodes
+    return matchingPreviews.some(
+      (p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes,
+    );
+  }
+
+  // Helper to get model size in GB (from megabytes)
+  function getModelSizeGB(model: {
+    id: string;
+    name?: string;
+    storage_size_megabytes?: number;
+  }): number {
+    if (model.storage_size_megabytes) {
+      return model.storage_size_megabytes / 1024;
+    }
+    return estimateMemoryGB(model.id, model.name);
+  }
+
+  // Calculate available memory in the cluster (in GB)
+  const availableMemoryGB = $derived(() => {
+    if (!data) return 0;
+    return (
+      Object.values(data.nodes).reduce((acc, n) => {
+        const total =
+          n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
+        const used = n.macmon_info?.memory?.ram_usage ?? 0;
+        return acc + (total - used);
+      }, 0) /
+      (1024 * 1024 * 1024)
+    );
+  });
+
+  // Check if a model has enough memory to run
+  function hasEnoughMemory(model: {
+    id: string;
+    name?: string;
+    storage_size_megabytes?: number;
+  }): boolean {
+    const modelSizeGB = getModelSizeGB(model);
+    return modelSizeGB <= availableMemoryGB();
+  }
+
+  // Sorted models for dropdown - biggest first, unrunnable at the end
+  const sortedModels = $derived(() => {
+    return [...models].sort((a, b) => {
+      // First: models that have enough memory come before those that don't
+      const aCanFit = hasEnoughMemory(a);
+      const bCanFit = hasEnoughMemory(b);
+      if (aCanFit && !bCanFit) return -1;
+      if (!aCanFit && bCanFit) return 1;
+
+      // Then: sort by size (biggest first)
+      const aSize = getModelSizeGB(a);
+      const bSize = getModelSizeGB(b);
+      return bSize - aSize;
+    });
+  });
+
+  // Compute model tags (FASTEST, BIGGEST)
+  const modelTags = $derived(() => {
+    const tags: Record<string, string[]> = {};
+    if (models.length === 0) return tags;
+
+    // Find the fastest model (highest TPS)
+    let fastestId = "";
+    let highestTps = 0;
+
+    // Find the biggest model (most memory)
+    let biggestId = "";
+    let highestMemory = 0;
+
+    for (const model of models) {
+      const perf = estimatePerformance(model.id);
+      const mem = getModelSizeGB(model);
+
+      if (perf.tps > highestTps) {
+        highestTps = perf.tps;
+        fastestId = model.id;
+      }
+
+      if (mem > highestMemory) {
+        highestMemory = mem;
+        biggestId = model.id;
+      }
+    }
+
+    if (fastestId) {
+      tags[fastestId] = tags[fastestId] || [];
+      tags[fastestId].push("FASTEST");
+    }
+
+    if (biggestId && biggestId !== fastestId) {
+      tags[biggestId] = tags[biggestId] || [];
+      tags[biggestId].push("BIGGEST");
+    } else if (biggestId === fastestId && biggestId) {
+      // Same model is both - unlikely but handle it
+      tags[biggestId].push("BIGGEST");
+    }
+
+    return tags;
+  });
+
+  onMount(() => {
+    mounted = true;
+    fetchModels();
+  });
+
+  async function fetchModels() {
+    try {
+      const response = await fetch("/models");
+      if (response.ok) {
+        const data = await response.json();
+        // API returns { data: [{ id, name }] } format
+        models = data.data || [];
+        // Restore last launch defaults if available
+        const currentNodeCount = topologyData()
+          ? Object.keys(topologyData()!.nodes).length
+          : 1;
+        applyLaunchDefaults(models, currentNodeCount);
+      }
+    } catch (error) {
+      console.error("Failed to fetch models:", error);
+    }
+  }
+
+  async function launchInstance(
+    modelId: string,
+    specificPreview?: PlacementPreview | null,
+  ) {
+    if (!modelId || launchingModelId) return;
+
+    launchingModelId = modelId;
+
+    try {
+      // Use the specific preview if provided, otherwise fall back to filtered preview
+      const preview = specificPreview ?? filteredPreview();
+
+      let instanceData: unknown;
+
+      if (preview?.instance) {
+        // Use the instance from the preview
+        instanceData = preview.instance;
+      } else {
+        // Fallback: GET placement from API
+        const placementResponse = await fetch(
+          `/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${selectedSharding}&instance_meta=${selectedInstanceType}&min_nodes=${selectedMinNodes}`,
+        );
+
+        if (!placementResponse.ok) {
+          const errorText = await placementResponse.text();
+          console.error("Failed to get placement:", errorText);
+          return;
+        }
+
+        instanceData = await placementResponse.json();
+      }
+
+      // POST the instance to create it
+      const response = await fetch("/instance", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ instance: instanceData }),
+      });
+
+      if (!response.ok) {
+        const errorText = await response.text();
+        console.error("Failed to launch instance:", errorText);
+      } else {
+        // Always auto-select the newly launched model so the user chats to what they just launched
+        setSelectedChatModel(modelId);
+
+        // Scroll to the bottom of instances container to show the new instance
+        // Use multiple attempts to ensure DOM has updated with the new instance
+        const scrollToBottom = () => {
+          if (instancesContainerRef) {
+            instancesContainerRef.scrollTo({
+              top: instancesContainerRef.scrollHeight,
+              behavior: "smooth",
+            });
+          }
+        };
+        setTimeout(scrollToBottom, 200);
+        setTimeout(scrollToBottom, 500);
+        setTimeout(scrollToBottom, 1000);
+      }
+    } catch (error) {
+      console.error("Error launching instance:", error);
+    } finally {
+      launchingModelId = null;
+    }
+  }
+
+  // Helper to extract model ID from download shard metadata
+  function extractModelIdFromDownload(
+    downloadPayload: Record<string, unknown>,
+  ): string | null {
+    const shardMetadata =
+      downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+    if (!shardMetadata || typeof shardMetadata !== "object") return null;
+
+    // Shard metadata is a tagged union: { PipelineShardMetadata: {...} } or { TensorShardMetadata: {...} }
+    const shardObj = shardMetadata as Record<string, unknown>;
+    const shardKeys = Object.keys(shardObj);
+    if (shardKeys.length !== 1) return null;
+
+    const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
+    if (!shardData) return null;
+
+    // Model meta is nested: shard.model_card.model_id
+    const modelMeta = shardData.model_card ?? shardData.modelCard;
+    if (!modelMeta || typeof modelMeta !== "object") return null;
+
+    const meta = modelMeta as Record<string, unknown>;
+    return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
+  }
+
+  // Helper to parse download progress from payload
+  function parseDownloadProgress(
+    payload: Record<string, unknown>,
+  ): DownloadProgress | null {
+    const progress = payload.download_progress ?? payload.downloadProgress;
+    if (!progress || typeof progress !== "object") return null;
+
+    const prog = progress as Record<string, unknown>;
+    const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
+    const downloadedBytes = getBytes(
+      prog.downloaded_bytes ?? prog.downloadedBytes,
+    );
+    const speed = (prog.speed as number) ?? 0;
+    const completedFiles =
+      (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
+    const totalFiles =
+      (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
+    const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
+
+    const files: DownloadProgress["files"] = [];
+    const filesObj = (prog.files ?? {}) as Record<string, unknown>;
+    for (const [fileName, fileData] of Object.entries(filesObj)) {
+      if (!fileData || typeof fileData !== "object") continue;
+      const fd = fileData as Record<string, unknown>;
+      const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
+      const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+      files.push({
+        name: fileName,
+        totalBytes: fTotal,
+        downloadedBytes: fDownloaded,
+        speed: (fd.speed as number) ?? 0,
+        etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
+        percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0,
+      });
+    }
+
+    return {
+      totalBytes,
+      downloadedBytes,
+      speed,
+      etaMs:
+        etaMs ||
+        (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
+      percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+      completedFiles,
+      totalFiles,
+      files,
+    };
+  }
+
+  // Helper to get download status for a model (checks all downloads for matching model ID)
+  function getModelDownloadStatus(modelId: string): {
+    isDownloading: boolean;
+    progress: DownloadProgress | null;
+    perNode: Array<{
+      nodeId: string;
+      nodeName: string;
+      progress: DownloadProgress;
+    }>;
+  } {
+    if (!downloadsData || Object.keys(downloadsData).length === 0) {
+      return { isDownloading: false, progress: null, perNode: [] };
+    }
+
+    let totalBytes = 0;
+    let downloadedBytes = 0;
+    let totalSpeed = 0;
+    let completedFiles = 0;
+    let totalFiles = 0;
+    let isDownloading = false;
+    const allFiles: DownloadProgress["files"] = [];
+    const perNode: Array<{
+      nodeId: string;
+      nodeName: string;
+      progress: DownloadProgress;
+    }> = [];
+
+    // Check all nodes for downloads matching this model
+    for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
+      if (!Array.isArray(nodeDownloads)) continue;
+
+      for (const downloadWrapped of nodeDownloads) {
+        if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
+
+        const keys = Object.keys(downloadWrapped as Record<string, unknown>);
+        if (keys.length !== 1) continue;
+
+        const downloadKind = keys[0];
+        const downloadPayload = (downloadWrapped as Record<string, unknown>)[
+          downloadKind
+        ] as Record<string, unknown>;
+
+        if (downloadKind !== "DownloadOngoing") continue;
+        if (!downloadPayload) continue;
+
+        const downloadModelId = extractModelIdFromDownload(downloadPayload);
+
+        // Match if the model ID contains or equals the requested model
+        // (handles cases like "mlx-community/Meta-Llama..." matching)
+        if (
+          !downloadModelId ||
+          !downloadModelId.includes(modelId.split("/").pop() || modelId)
+        ) {
+          // Try exact match or partial match
+          if (downloadModelId !== modelId) continue;
+        }
+
+        isDownloading = true;
+
+        const progress = parseDownloadProgress(downloadPayload);
+        if (progress) {
+          // Sum all values across nodes - each node downloads independently
+          totalBytes += progress.totalBytes;
+          downloadedBytes += progress.downloadedBytes;
+          totalSpeed += progress.speed;
+          completedFiles += progress.completedFiles;
+          totalFiles += progress.totalFiles;
+          allFiles.push(...progress.files);
+
+          const nodeName =
+            data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
+          perNode.push({ nodeId, nodeName, progress });
+        }
+      }
+    }
+
+    if (!isDownloading) {
+      return { isDownloading: false, progress: null, perNode: [] };
+    }
+
+    // ETA = total remaining bytes / total speed across all nodes
+    const remainingBytes = totalBytes - downloadedBytes;
+    const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
+
+    return {
+      isDownloading: true,
+      progress: {
+        totalBytes,
+        downloadedBytes,
+        speed: totalSpeed,
+        etaMs,
+        percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+        completedFiles,
+        totalFiles,
+        files: allFiles,
+      },
+      perNode,
+    };
+  }
+
+  // Debug: Log downloads data when it changes
+  $effect(() => {
+    if (downloadsData && Object.keys(downloadsData).length > 0) {
+      console.log("[Download Debug] Current downloads:", downloadsData);
+    }
+  });
+
+  // Helper to get download status for an instance
+  function getInstanceDownloadStatus(
+    instanceId: string,
+    instanceWrapped: unknown,
+  ): {
+    isDownloading: boolean;
+    progress: DownloadProgress | null;
+    statusText: string;
+    perNode: Array<{
+      nodeId: string;
+      nodeName: string;
+      progress: DownloadProgress;
+    }>;
+  } {
+    if (!downloadsData || Object.keys(downloadsData).length === 0) {
+      return {
+        isDownloading: false,
+        progress: null,
+        statusText: "RUNNING",
+        perNode: [],
+      };
+    }
+
+    // Unwrap the instance
+    const [instanceTag, instance] = getTagged(instanceWrapped);
+    if (!instance || typeof instance !== "object") {
+      return {
+        isDownloading: false,
+        progress: null,
+        statusText: "PREPARING",
+        perNode: [],
+      };
+    }
+
+    const inst = instance as {
+      shardAssignments?: {
+        nodeToRunner?: Record<string, string>;
+        runnerToShard?: Record<string, unknown>;
+        modelId?: string;
+      };
+    };
+    const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
+    const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+    const instanceModelId = inst.shardAssignments?.modelId;
+
+    // Build reverse mapping: runnerId -> nodeId
+    const runnerToNode: Record<string, string> = {};
+    for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
+      runnerToNode[runnerId] = nodeId;
+    }
+
+    let totalBytes = 0;
+    let downloadedBytes = 0;
+    let totalSpeed = 0;
+    let completedFiles = 0;
+    let totalFiles = 0;
+    let isDownloading = false;
+    const allFiles: DownloadProgress["files"] = [];
+    const perNode: Array<{
+      nodeId: string;
+      nodeName: string;
+      progress: DownloadProgress;
+    }> = [];
+
+    // Check downloads for nodes that are part of this instance
+    for (const runnerId of Object.keys(runnerToShard)) {
+      const nodeId = runnerToNode[runnerId];
+      if (!nodeId) continue;
+
+      const nodeDownloads = downloadsData[nodeId];
+      if (!Array.isArray(nodeDownloads)) continue;
+
+      for (const downloadWrapped of nodeDownloads) {
+        if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
+
+        const keys = Object.keys(downloadWrapped as Record<string, unknown>);
+        if (keys.length !== 1) continue;
+
+        const downloadKind = keys[0];
+        const downloadPayload = (downloadWrapped as Record<string, unknown>)[
+          downloadKind
+        ] as Record<string, unknown>;
+
+        if (downloadKind !== "DownloadOngoing") continue;
+        if (!downloadPayload) continue;
+
+        // Check if this download is for this instance's model
+        const downloadModelId = extractModelIdFromDownload(downloadPayload);
+        if (
+          instanceModelId &&
+          downloadModelId &&
+          downloadModelId === instanceModelId
+        ) {
+          isDownloading = true;
+
+          const progress = parseDownloadProgress(downloadPayload);
+          if (progress) {
+            // Sum all values across nodes - each node downloads independently
+            totalBytes += progress.totalBytes;
+            downloadedBytes += progress.downloadedBytes;
+            totalSpeed += progress.speed;
+            completedFiles += progress.completedFiles;
+            totalFiles += progress.totalFiles;
+            allFiles.push(...progress.files);
+
+            const nodeName =
+              data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
+            perNode.push({ nodeId, nodeName, progress });
+          }
+        }
+      }
+    }
+
+    if (!isDownloading) {
+      // Check runner status for other states
+      const statusInfo = deriveInstanceStatus(instanceWrapped);
+      return {
+        isDownloading: false,
+        progress: null,
+        statusText: statusInfo.statusText,
+        perNode: [],
+      };
+    }
+
+    // ETA = total remaining bytes / total speed across all nodes
+    const remainingBytes = totalBytes - downloadedBytes;
+    const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
+
+    return {
+      isDownloading: true,
+      progress: {
+        totalBytes,
+        downloadedBytes,
+        speed: totalSpeed,
+        etaMs,
+        percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+        completedFiles,
+        totalFiles,
+        files: allFiles,
+      },
+      statusText: "DOWNLOADING",
+      perNode,
+    };
+  }
+
+  // Derive instance status from runners
+  // Get color class for a status
+  function getStatusColor(statusText: string): string {
+    switch (statusText) {
+      case "FAILED":
+        return "text-red-400";
+      case "SHUTDOWN":
+        return "text-gray-400";
+      case "DOWNLOADING":
+        return "text-blue-400";
+      case "LOADING":
+      case "WARMING UP":
+      case "WAITING":
+      case "INITIALIZING":
+        return "text-yellow-400";
+      case "RUNNING":
+        return "text-teal-400";
+      case "READY":
+      case "LOADED":
+        return "text-green-400";
+      default:
+        return "text-exo-light-gray";
+    }
+  }
+
+  function deriveInstanceStatus(instanceWrapped: unknown): {
+    statusText: string;
+    statusClass: string;
+  } {
+    const [, instance] = getTagged(instanceWrapped);
+    if (!instance || typeof instance !== "object") {
+      return { statusText: "PREPARING", statusClass: "inactive" };
+    }
+
+    const inst = instance as {
+      shardAssignments?: { runnerToShard?: Record<string, unknown> };
+    };
+    const runnerIds = Object.keys(inst.shardAssignments?.runnerToShard || {});
+
+    const statuses = runnerIds
+      .map((rid) => {
+        const r = runnersData[rid];
+        if (!r) return null;
+        const [kind] = getTagged(r);
+        const statusMap: Record<string, string> = {
+          RunnerWaitingForInitialization: "WaitingForInitialization",
+          RunnerInitializingBackend: "InitializingBackend",
+          RunnerWaitingForModel: "WaitingForModel",
+          RunnerLoading: "Loading",
+          RunnerLoaded: "Loaded",
+          RunnerWarmingUp: "WarmingUp",
+          RunnerReady: "Ready",
+          RunnerRunning: "Running",
+          RunnerShutdown: "Shutdown",
+          RunnerFailed: "Failed",
+        };
+        return kind ? statusMap[kind] || null : null;
+      })
+      .filter((s): s is string => s !== null);
+
+    const has = (s: string) => statuses.includes(s);
+
+    if (statuses.length === 0)
+      return { statusText: "PREPARING", statusClass: "inactive" };
+    if (has("Failed")) return { statusText: "FAILED", statusClass: "failed" };
+    if (has("Shutdown"))
+      return { statusText: "SHUTDOWN", statusClass: "inactive" };
+    if (has("Loading"))
+      return { statusText: "LOADING", statusClass: "starting" };
+    if (has("WarmingUp"))
+      return { statusText: "WARMING UP", statusClass: "starting" };
+    if (has("Running"))
+      return { statusText: "RUNNING", statusClass: "running" };
+    if (has("Ready")) return { statusText: "READY", statusClass: "loaded" };
+    if (has("Loaded")) return { statusText: "LOADED", statusClass: "loaded" };
+    if (has("WaitingForModel"))
+      return { statusText: "WAITING", statusClass: "starting" };
+    if (has("InitializingBackend"))
+      return { statusText: "INITIALIZING", statusClass: "starting" };
+    if (has("WaitingForInitialization"))
+      return { statusText: "INITIALIZING", statusClass: "starting" };
+
+    return { statusText: "RUNNING", statusClass: "active" };
+  }
+
+  function getBytes(value: unknown): number {
+    if (typeof value === "number") return value;
+    if (value && typeof value === "object") {
+      const v = value as Record<string, unknown>;
+      if (typeof v.in_bytes === "number") return v.in_bytes;
+      if (typeof v.inBytes === "number") return v.inBytes;
+    }
+    return 0;
+  }
+
+  async function deleteInstance(instanceId: string) {
+    if (!confirm(`Delete instance ${instanceId.slice(0, 8)}...?`)) return;
+
+    // Get the model ID of the instance being deleted before we delete it
+    const deletedInstanceModelId = getInstanceModelId(instanceData[instanceId]);
+    const wasSelected = selectedChatModel() === deletedInstanceModelId;
+
+    try {
+      const response = await fetch(`/instance/${instanceId}`, {
+        method: "DELETE",
+        headers: { "Content-Type": "application/json" },
+      });
+
+      if (!response.ok) {
+        console.error("Failed to delete instance:", response.status);
+      } else if (wasSelected) {
+        // If we deleted the currently selected model, switch to another available model
+        // Find another instance that isn't the one we just deleted
+        const remainingInstances = Object.entries(instanceData).filter(
+          ([id]) => id !== instanceId,
+        );
+        if (remainingInstances.length > 0) {
+          // Select the last instance (most recently added, since objects preserve insertion order)
+          const [, lastInstance] =
+            remainingInstances[remainingInstances.length - 1];
+          const newModelId = getInstanceModelId(lastInstance);
+          if (
+            newModelId &&
+            newModelId !== "Unknown" &&
+            newModelId !== "Unknown Model"
+          ) {
+            setSelectedChatModel(newModelId);
+          } else {
+            // Clear selection if no valid model found
+            setSelectedChatModel("");
+          }
+        } else {
+          // No more instances, clear the selection
+          setSelectedChatModel("");
+        }
+      }
+    } catch (error) {
+      console.error("Error deleting instance:", error);
+    }
+  }
+
+  // Helper to unwrap tagged unions like { MlxRingInstance: {...} }
+  function getTagged(obj: unknown): [string | null, unknown] {
+    if (!obj || typeof obj !== "object") return [null, null];
+    const keys = Object.keys(obj as Record<string, unknown>);
+    if (keys.length === 1) {
+      return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
+    }
+    return [null, null];
+  }
+
+  // Get model ID from an instance
+  function getInstanceModelId(instanceWrapped: unknown): string {
+    const [, instance] = getTagged(instanceWrapped);
+    if (!instance || typeof instance !== "object") return "Unknown";
+    const inst = instance as { shardAssignments?: { modelId?: string } };
+    return inst.shardAssignments?.modelId || "Unknown Model";
+  }
+
+  // Get instance details: type (MLX Ring/IBV), sharding (Pipeline/Tensor), and node names
+  function getInstanceInfo(instanceWrapped: unknown): {
+    instanceType: string;
+    sharding: string;
+    nodeNames: string[];
+    nodeIds: string[];
+    nodeCount: number;
+  } {
+    const [instanceTag, instance] = getTagged(instanceWrapped);
+    if (!instance || typeof instance !== "object") {
+      return {
+        instanceType: "Unknown",
+        sharding: "Unknown",
+        nodeNames: [],
+        nodeIds: [],
+        nodeCount: 0,
+      };
+    }
+
+    // Instance type from tag
+    let instanceType = "Unknown";
+    if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
+    else if (
+      instanceTag === "MlxIbvInstance" ||
+      instanceTag === "MlxJacclInstance"
+    )
+      instanceType = "MLX RDMA";
+
+    const inst = instance as {
+      shardAssignments?: {
+        nodeToRunner?: Record<string, string>;
+        runnerToShard?: Record<string, unknown>;
+      };
+    };
+
+    // Sharding strategy from first shard
+    let sharding = "Unknown";
+    const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+    const firstShardWrapped = Object.values(runnerToShard)[0];
+    if (firstShardWrapped) {
+      const [shardTag] = getTagged(firstShardWrapped);
+      if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
+      else if (shardTag === "TensorShardMetadata") sharding = "Tensor";
+      else if (shardTag === "PrefillDecodeShardMetadata")
+        sharding = "Prefill/Decode";
+    }
+
+    // Node names from topology
+    const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
+    const nodeIds = Object.keys(nodeToRunner);
+    const nodeNames = nodeIds.map((nodeId) => {
+      const node = data?.nodes?.[nodeId];
+      return node?.friendly_name || nodeId.slice(0, 8);
+    });
+
+    return {
+      instanceType,
+      sharding,
+      nodeNames,
+      nodeIds,
+      nodeCount: nodeIds.length,
+    };
+  }
+
+  function formatLastUpdate(): string {
+    if (!update) return "ACQUIRING...";
+    const seconds = Math.floor((Date.now() - update) / 1000);
+    if (seconds < 5) return "LIVE";
+    return `${seconds}s AGO`;
+  }
+
+  function formatBytes(bytes: number, decimals = 2): string {
+    if (!bytes || bytes === 0) return "0 B";
+    const k = 1024;
+    const sizes = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return (
+      parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]
+    );
+  }
+
+  function formatSpeed(bps: number): string {
+    if (!bps || bps <= 0) return "0 B/s";
+    return formatBytes(bps, 1) + "/s";
+  }
+
+  function getNodeLabel(nodeId: string): string {
+    const node = data?.nodes?.[nodeId];
+    return node?.friendly_name || nodeId.slice(0, 8);
+  }
+
+  function getInterfaceLabel(
+    nodeId: string,
+    ip?: string,
+  ): { label: string; missing: boolean } {
+    if (!ip) return { label: "?", missing: true };
+    const node = data?.nodes?.[nodeId];
+    if (!node) return { label: "?", missing: true };
+
+    // Prefer explicit network_interfaces from NodePerformanceProfile
+    const matchFromInterfaces = node.network_interfaces?.find((iface) =>
+      (iface.addresses || []).some((addr) => addr === ip),
+    );
+    if (matchFromInterfaces?.name) {
+      return {
+        label: `${matchFromInterfaces.name} on ${getNodeLabel(nodeId)}`,
+        missing: false,
+      };
+    }
+
+    // Fallback to derived ip_to_interface map
+    const mapped = node.ip_to_interface?.[ip];
+    if (mapped && mapped.trim().length > 0) {
+      return { label: `${mapped} on ${getNodeLabel(nodeId)}`, missing: false };
+    }
+
+    return { label: "?", missing: true };
+  }
+
+  function getOrderedRunnerNodes(
+    instance: Record<string, unknown>,
+    shardType: "Pipeline" | "Tensor",
+  ) {
+    const runnerToShard =
+      (
+        instance.shardAssignments as
+          | { runnerToShard?: Record<string, unknown> }
+          | undefined
+      )?.runnerToShard || {};
+    const nodeToRunner =
+      (
+        instance.shardAssignments as
+          | { nodeToRunner?: Record<string, string> }
+          | undefined
+      )?.nodeToRunner || {};
+    const runnerEntries = Object.entries(runnerToShard).map(
+      ([runnerId, shardWrapped]) => {
+        const [tag, shard] = getTagged(shardWrapped);
+        const meta = shard as
+          | {
+              modelMeta?: {
+                worldSize?: number;
+                nLayers?: number;
+                deviceRank?: number;
+              };
+            }
+          | undefined;
+        const deviceRank = meta?.modelMeta?.deviceRank ?? 0;
+        return { runnerId, tag, deviceRank };
+      },
+    );
+
+    const ordered = runnerEntries
+      .filter((r) =>
+        shardType === "Pipeline"
+          ? r.tag === "PipelineShardMetadata"
+          : r.tag === "TensorShardMetadata",
+      )
+      .sort((a, b) => a.deviceRank - b.deviceRank)
+      .map((r, idx) => {
+        const nodeId = Object.entries(nodeToRunner).find(
+          ([, rid]) => rid === r.runnerId,
+        )?.[0];
+        return { nodeId, runnerId: r.runnerId, order: idx };
+      })
+      .filter((item) => item.nodeId);
+
+    return ordered as Array<{
+      nodeId: string;
+      runnerId: string;
+      order: number;
+    }>;
+  }
+
+  function pickHost(
+    hosts?: Array<{ ip: string; port: number }>,
+  ): { ip: string; port: number } | null {
+    if (!hosts || hosts.length === 0) return null;
+    const scored = hosts
+      .filter((h) => h.ip && h.ip !== "0.0.0.0" && h.port && h.port > 0)
+      .map((h) => {
+        const ip = h.ip;
+        const score =
+          ip.startsWith("10.") ||
+          ip.startsWith("172.") ||
+          ip.startsWith("192.168")
+            ? 3
+            : ip.startsWith("169.254")
+              ? 2
+              : 1;
+        return { host: h, score };
+      });
+    if (scored.length === 0) return null;
+    scored.sort((a, b) => b.score - a.score);
+    return scored[0].host;
+  }
+
+  function getInstanceConnections(instanceWrapped: unknown): Array<{
+    from: string;
+    to: string;
+    ip: string;
+    ifaceLabel: string;
+    missingIface: boolean;
+  }> {
+    const [instanceTag, instance] = getTagged(instanceWrapped);
+    if (!instance || typeof instance !== "object") return [];
+
+    // Jaccl (RDMA) – show RDMA interfaces from ibvDevices
+    if (instanceTag === "MlxJacclInstance") {
+      const ordered = getOrderedRunnerNodes(
+        instance as Record<string, unknown>,
+        "Tensor",
+      );
+      const ibvDevices =
+        (instance as { ibvDevices?: Array<Array<string | null>> }).ibvDevices ||
+        [];
+      const rows: Array<{
+        from: string;
+        to: string;
+        ip: string;
+        ifaceLabel: string;
+        missingIface: boolean;
+      }> = [];
+
+      for (let i = 0; i < ordered.length; i++) {
+        for (let j = i + 1; j < ordered.length; j++) {
+          const iface = ibvDevices[i]?.[j] ?? ibvDevices[j]?.[i] ?? null;
+          if (!iface) continue;
+          const fromId = ordered[i].nodeId;
+          const toId = ordered[j].nodeId;
+          rows.push({
+            from: getNodeLabel(fromId),
+            to: getNodeLabel(toId),
+            ip: iface,
+            ifaceLabel: `RDMA ${iface}`,
+            missingIface: false,
+          });
+        }
+      }
+      return rows;
+    }
+
+    // Ring – derive ring order from pipeline shard ranks and pick host IPs from hostsByNode
+    if (instanceTag === "MlxRingInstance") {
+      const ordered = getOrderedRunnerNodes(
+        instance as Record<string, unknown>,
+        "Pipeline",
+      );
+      const hostsByNode =
+        (
+          instance as {
+            hostsByNode?: Record<string, Array<{ ip: string; port: number }>>;
+          }
+        ).hostsByNode || {};
+      const rows: Array<{
+        from: string;
+        to: string;
+        ip: string;
+        ifaceLabel: string;
+        missingIface: boolean;
+      }> = [];
+      if (ordered.length === 0) return rows;
+
+      for (let idx = 0; idx < ordered.length; idx++) {
+        const current = ordered[idx];
+        const next = ordered[(idx + 1) % ordered.length];
+        const host = pickHost(hostsByNode[next.nodeId]);
+        const ip = host ? `${host.ip}:${host.port}` : "?";
+        const ifaceInfo = host
+          ? getInterfaceLabel(next.nodeId, host.ip)
+          : { label: "?", missing: true };
+        rows.push({
+          from: getNodeLabel(current.nodeId),
+          to: getNodeLabel(next.nodeId),
+          ip,
+          ifaceLabel: ifaceInfo.label,
+          missingIface: ifaceInfo.missing,
+        });
+      }
+      return rows;
+    }
+
+    return [];
+  }
+
+  function formatEta(ms: number): string {
+    if (!ms || ms <= 0) return "--";
+    const totalSeconds = Math.round(ms / 1000);
+    const s = totalSeconds % 60;
+    const m = Math.floor(totalSeconds / 60) % 60;
+    const h = Math.floor(totalSeconds / 3600);
+    if (h > 0) return `${h}h ${m}m`;
+    if (m > 0) return `${m}m ${s}s`;
+    return `${s}s`;
+  }
+
+  function handleNewChat() {
+    createConversation();
+  }
+
+  function handleGoHome() {
+    clearChat();
+  }
+
+  // Slider drag handlers
+  function handleSliderDrag(clientX: number) {
+    if (!sliderTrackElement || availableMinNodes <= 1) return;
+
+    const rect = sliderTrackElement.getBoundingClientRect();
+    const percentage = Math.max(
+      0,
+      Math.min(1, (clientX - rect.left) / rect.width),
+    );
+    const rawValue = Math.round(percentage * (availableMinNodes - 1)) + 1;
+    const clampedValue = Math.max(1, Math.min(availableMinNodes, rawValue));
+
+    // Find nearest valid value
+    const validCounts = validMinNodeCounts();
+    if (validCounts.has(clampedValue)) {
+      selectedMinNodes = clampedValue;
+    } else {
+      // Find nearest valid value
+      let nearest = clampedValue;
+      let minDist = Infinity;
+      for (const v of validCounts) {
+        const dist = Math.abs(v - clampedValue);
+        if (dist < minDist) {
+          minDist = dist;
+          nearest = v;
+        }
+      }
+      if (validCounts.size > 0) {
+        selectedMinNodes = nearest;
+      }
+    }
+  }
+
+  function handleSliderMouseDown(event: MouseEvent) {
+    isDraggingSlider = true;
+    handleSliderDrag(event.clientX);
+  }
+
+  function handleSliderMouseMove(event: MouseEvent) {
+    if (isDraggingSlider) {
+      handleSliderDrag(event.clientX);
+    }
+  }
+
+  function handleSliderMouseUp() {
+    isDraggingSlider = false;
+    saveLaunchDefaults();
+  }
+
+  // Handle touch events for mobile
+  function handleSliderTouchStart(event: TouchEvent) {
+    isDraggingSlider = true;
+    if (event.touches.length > 0) {
+      handleSliderDrag(event.touches[0].clientX);
+    }
+  }
+
+  function handleSliderTouchMove(event: TouchEvent) {
+    if (isDraggingSlider && event.touches.length > 0) {
+      event.preventDefault();
+      handleSliderDrag(event.touches[0].clientX);
+    }
+  }
+
+  function handleSliderTouchEnd() {
+    isDraggingSlider = false;
+    saveLaunchDefaults();
+  }
+
+  const nodeCount = $derived(data ? Object.keys(data.nodes).length : 0);
+  const instanceCount = $derived(Object.keys(instanceData).length);
+
+  // Helper to get the number of nodes in a placement preview
+  function getPreviewNodeCount(preview: PlacementPreview): number {
+    if (!preview.memory_delta_by_node) return 0;
+    // Count nodes that have non-zero memory delta (i.e. nodes actually used)
+    return Object.entries(preview.memory_delta_by_node).filter(
+      ([_, delta]) => delta > 0,
+    ).length;
+  }
+
+  // Available min nodes options based on topology (like old dashboard)
+  const availableMinNodes = $derived(Math.max(1, nodeCount));
+
+  // Compute which min node values have valid previews for the current model/sharding/instance type
+  // A minNodes value N is valid if there exists a placement with nodeCount >= N
+  // Note: previewsData already contains previews for the selected model (fetched via API)
+  const validMinNodeCounts = $derived(() => {
+    if (!selectedModelId || previewsData.length === 0) {
+      // If no model selected or no previews, allow all node counts (UI shows all as clickable)
+      return new Set(
+        Array.from({ length: availableMinNodes }, (_, i) => i + 1),
+      );
+    }
+
+    // Find the max node count among valid placements for this model/sharding/instance
+    // (model_id filter not needed since previewsData is already for selected model)
+    let maxValidNodes = 0;
+    for (const preview of previewsData) {
+      if (preview.sharding !== selectedSharding) continue;
+      if (!matchesSelectedRuntime(preview.instance_meta)) continue;
+      if (preview.error !== null) continue;
+      if (!preview.memory_delta_by_node) continue;
+
+      const previewNodes = getPreviewNodeCount(preview);
+      if (previewNodes > maxValidNodes) {
+        maxValidNodes = previewNodes;
+      }
+    }
+
+    // All values from 1 to maxValidNodes are valid (since there's a placement with >= that many nodes)
+    if (maxValidNodes === 0) return new Set<number>();
+    return new Set(Array.from({ length: maxValidNodes }, (_, i) => i + 1));
+  });
+
+  // Get ALL filtered previews based on current settings (matching minimum nodes)
+  // Note: previewsData already contains previews for the selected model (fetched via API)
+  // We filter by sharding/instance type and min nodes, returning ALL eligible previews
+  const filteredPreviews = $derived(() => {
+    if (!selectedModelId || previewsData.length === 0) return [];
+
+    // Find previews matching sharding/instance type (model_id filter not needed since previewsData is already for selected model)
+    const matchingPreviews = previewsData.filter(
+      (p: PlacementPreview) =>
+        p.sharding === selectedSharding &&
+        matchesSelectedRuntime(p.instance_meta) &&
+        p.error === null &&
+        p.memory_delta_by_node !== null,
+    );
+
+    // Filter to previews with node count >= selectedMinNodes, sorted by node count (ascending)
+    return matchingPreviews
+      .filter(
+        (p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes,
+      )
+      .sort(
+        (a: PlacementPreview, b: PlacementPreview) =>
+          getPreviewNodeCount(a) - getPreviewNodeCount(b),
+      );
+  });
+
+  // Get the first filtered preview (for launch function compatibility)
+  const filteredPreview = $derived(() => filteredPreviews()[0] ?? null);
+
+  // Auto-update selectedMinNodes when node count changes (default to 1 = show all placements)
+  $effect(() => {
+    const maxNodes = availableMinNodes;
+    if (!minNodesInitialized && maxNodes > 0) {
+      // On initial load, default to 1 (minimum) to show all valid placements
+      selectedMinNodes = 1;
+      minNodesInitialized = true;
+    } else if (selectedMinNodes > maxNodes) {
+      // If current selection exceeds available nodes, cap it
+      selectedMinNodes = maxNodes;
+    }
+  });
+
+  // Auto-adjust selectedMinNodes to a valid value when it becomes invalid
+  $effect(() => {
+    const valid = validMinNodeCounts();
+    if (valid.size > 0 && !valid.has(selectedMinNodes)) {
+      // Find the smallest valid count >= current selection, or the largest valid count
+      const validArray = Array.from(valid).sort((a, b) => a - b);
+      const nextValid =
+        validArray.find((n) => n >= selectedMinNodes) ??
+        validArray[validArray.length - 1];
+      if (nextValid !== undefined) {
+        selectedMinNodes = nextValid;
+      }
+    }
+  });
+
+  // Calculate total memory usage across all nodes
+  const clusterMemory = $derived(() => {
+    if (!data) return { used: 0, total: 0 };
+    return Object.values(data.nodes).reduce(
+      (acc, n) => {
+        const total =
+          n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
+        const used = n.macmon_info?.memory?.ram_usage ?? 0;
+        return { used: acc.used + used, total: acc.total + total };
+      },
+      { used: 0, total: 0 },
+    );
+  });
 </script>
 
 <!-- Global event listeners for slider dragging -->
-<svelte:window 
-	onmousemove={handleSliderMouseMove}
-	onmouseup={handleSliderMouseUp}
-	ontouchmove={handleSliderTouchMove}
-	ontouchend={handleSliderTouchEnd}
+<svelte:window
+  onmousemove={handleSliderMouseMove}
+  onmouseup={handleSliderMouseUp}
+  ontouchmove={handleSliderTouchMove}
+  ontouchend={handleSliderTouchEnd}
 />
 
-<div class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden">
-	<!-- Scanline overlay -->
-	<div class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"></div>
-	
-	<!-- Shooting Stars Background - one every ~15s -->
-	<div class="shooting-stars">
-		<div class="shooting-star" style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"></div>
-		<div class="shooting-star" style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"></div>
-		<div class="shooting-star" style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"></div>
-	</div>
-
-	{#if !topologyOnlyEnabled}
-	<HeaderNav 
-		showHome={chatStarted} 
-		onHome={handleGoHome} 
-		showSidebarToggle={true}
-		sidebarVisible={sidebarVisible}
-		onToggleSidebar={toggleChatSidebarVisible}
-	/>
-	{/if}
-
-	<!-- Main Content -->
-	<main class="flex-1 flex overflow-hidden relative">
-		<!-- Left: Conversation History Sidebar (hidden in topology-only mode or when toggled off) -->
-		{#if !topologyOnlyEnabled && sidebarVisible}
-		<div class="w-80 flex-shrink-0 border-r border-exo-yellow/10">
-			<ChatSidebar class="h-full" />
-		</div>
-		{/if}
-
-		{#if topologyOnlyEnabled}
-			<!-- TOPOLOGY ONLY MODE: Full-screen topology -->
-			<div class="flex-1 flex flex-col min-h-0 min-w-0 p-4" in:fade={{ duration: 300 }}>
-				<div class="flex-1 relative bg-exo-dark-gray/40 rounded-lg overflow-hidden">
-					<TopologyGraph class="w-full h-full" highlightedNodes={highlightedNodes()} />
-					<!-- Exit topology-only mode button -->
-					<button
-						type="button"
-						onclick={toggleTopologyOnlyMode}
-						class="absolute bottom-4 right-4 p-2 rounded border border-exo-yellow/30 bg-exo-dark-gray/80 hover:border-exo-yellow/50 hover:bg-exo-dark-gray transition-colors cursor-pointer backdrop-blur-sm"
-						title="Exit topology only mode"
-					>
-						<svg class="w-5 h-5 text-exo-yellow" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
-							<circle cx="12" cy="5" r="2" fill="currentColor" />
-							<circle cx="5" cy="19" r="2" fill="currentColor" />
-							<circle cx="19" cy="19" r="2" fill="currentColor" />
-							<path stroke-linecap="round" d="M12 7v5m0 0l-5 5m5-5l5 5" />
-						</svg>
-					</button>
-				</div>
-			</div>
-		{:else if !chatStarted}
-			<!-- WELCOME STATE: Topology + Instance Controls (no left sidebar for cleaner look) -->
-			<div class="flex-1 flex overflow-visible relative" in:fade={{ duration: 300 }} out:fade={{ duration: 200 }}>
-				
-				<!-- Center: MAIN TOPOLOGY DISPLAY -->
-				<div class="flex-1 flex flex-col min-h-0 min-w-0 py-4">
-					
-					<!-- Topology Container - Takes most of the space -->
-					<div class="flex-1 relative bg-exo-dark-gray/40 mx-4 mb-4 rounded-lg overflow-hidden">
-						
-						<!-- The main topology graph - full container -->
-						<TopologyGraph class="w-full h-full" highlightedNodes={highlightedNodes()} />
-					</div>
-					
-					<!-- Chat Input - Below topology -->
-					<div class="px-4 pt-6 pb-8">
-						<div class="max-w-3xl mx-auto">
-							<ChatForm 
-								placeholder="Ask anything" 
-								showHelperText={false}
-								showModelSelector={true}
-								modelTasks={modelTasks()}
-							/>
-						</div>
-					</div>
-				</div>
-				
-				<!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) -->
-				<aside class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0">
-					<!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
-					{#if instanceCount > 0}
-					<div class="p-4 flex-shrink-0">
-						<!-- Panel Header -->
-						<div class="flex items-center gap-2 mb-4">
-							<div class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"></div>
-							<h3 class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase">Instances</h3>
-							<div class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"></div>
-						</div>
-						
-						<div
-							bind:this={instancesContainerRef}
-							class="max-h-72 xl:max-h-96 space-y-3 overflow-y-auto overflow-x-hidden py-px"
-						>
-								{#each Object.entries(instanceData) as [id, instance]}
-									{@const downloadInfo = getInstanceDownloadStatus(id, instance)}
-									{@const statusText = downloadInfo.statusText}
-									{@const isDownloading = downloadInfo.isDownloading}
-									{@const isFailed = statusText === 'FAILED'}
-									{@const isLoading = statusText === 'LOADING' || statusText === 'WARMING UP' || statusText === 'WAITING'}
-									{@const isReady = statusText === 'READY' || statusText === 'LOADED'}
-									{@const isRunning = statusText === 'RUNNING'}
-								<!-- Instance Card -->
-								{@const instanceModelId = getInstanceModelId(instance)}
-								{@const instanceInfo = getInstanceInfo(instance)}
-								{@const instanceConnections = getInstanceConnections(instance)}
-								<div 
-									class="relative group cursor-pointer"
-									role="button"
-									tabindex="0"
-									onmouseenter={() => hoveredInstanceId = id}
-									onmouseleave={() => hoveredInstanceId = null}
-									onclick={() => {
-										if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
-											setSelectedChatModel(instanceModelId);
-										}
-									}}
-									onkeydown={(e) => {
-										if (e.key === 'Enter' || e.key === ' ') {
-											if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
-												setSelectedChatModel(instanceModelId);
-											}
-										}
-									}}
-								>
-									<!-- Corner accents -->
-									<div class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
-									<div class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
-									<div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
-									<div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
-									
-									<div class="bg-exo-dark-gray/60 border border-l-2 {isDownloading ? 'border-blue-500/30 border-l-blue-400' : isFailed ? 'border-red-500/30 border-l-red-400' : isLoading ? 'border-exo-yellow/30 border-l-yellow-400' : isReady ? 'border-green-500/30 border-l-green-400' : 'border-teal-500/30 border-l-teal-400'} p-3">
-										
-										<div class="flex justify-between items-start mb-2 pl-2">
-											<div class="flex items-center gap-2">
-												<div class="w-1.5 h-1.5 {isDownloading ? 'bg-blue-400 animate-pulse' : isFailed ? 'bg-red-400' : isLoading ? 'bg-yellow-400 animate-pulse' : isReady ? 'bg-green-400' : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"></div>
-												<span class="text-exo-light-gray font-mono text-sm tracking-wider">{id.slice(0, 8).toUpperCase()}</span>
-											</div>
-											<button 
-												onclick={() => deleteInstance(id)}
-												class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
-											>
-												DELETE
-											</button>
-											</div>
-											<div class="pl-2">
-												<div class="text-exo-yellow text-xs font-mono tracking-wide truncate">{getInstanceModelId(instance)}</div>
-												<div class="text-white/60 text-xs font-mono">Strategy: <span class="text-white/80">{instanceInfo.sharding} ({instanceInfo.instanceType})</span></div>
-												{#if instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model'}
-													<a
-														class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
-														href={`https://huggingface.co/${instanceModelId}`}
-														target="_blank"
-														rel="noreferrer noopener"
-														aria-label="View model on Hugging Face"
-													>
-														<span>Hugging Face</span>
-														<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-															<path d="M14 3h7v7"/>
-															<path d="M10 14l11-11"/>
-															<path d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"/>
-														</svg>
-													</a>
-												{/if}
-												{#if instanceInfo.nodeNames.length > 0}
-													<div class="text-white/60 text-xs font-mono">{instanceInfo.nodeNames.join(', ')}</div>
-												{/if}
-												{#if debugEnabled && instanceConnections.length > 0}
-													<div class="mt-2 space-y-1">
-														{#each instanceConnections as conn}
-															<div class="text-[11px] leading-snug font-mono text-white/70">
-																<span>{conn.from} -> {conn.to}: {conn.ip}</span>
-																<span class="{conn.missingIface ? 'text-red-400' : 'text-white/60'}"> ({conn.ifaceLabel})</span>
-															</div>
-														{/each}
-													</div>
-												{/if}
-												
-												<!-- Download Progress -->
-												{#if downloadInfo.isDownloading && downloadInfo.progress}
-													<div class="mt-2 space-y-1">
-														<div class="flex justify-between text-xs font-mono">
-															<span class="text-blue-400">{downloadInfo.progress.percentage.toFixed(1)}%</span>
-															<span class="text-exo-light-gray">{formatBytes(downloadInfo.progress.downloadedBytes)}/{formatBytes(downloadInfo.progress.totalBytes)}</span>
-														</div>
-														<div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
-															<div 
-																class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
-																style="width: {downloadInfo.progress.percentage}%"
-															></div>
-														</div>
-														<div class="flex justify-between text-xs font-mono text-exo-light-gray">
-															<span>{formatSpeed(downloadInfo.progress.speed)}</span>
-															<span>ETA: {formatEta(downloadInfo.progress.etaMs)}</span>
-															<span>{downloadInfo.progress.completedFiles}/{downloadInfo.progress.totalFiles} files</span>
-														</div>
-													</div>
-													{#if downloadInfo.perNode.length > 0}
-														<div class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1">
-															{#each downloadInfo.perNode as nodeProg}
-																{@const nodePercent = Math.min(100, Math.max(0, nodeProg.progress.percentage))}
-																{@const isExpanded = instanceDownloadExpandedNodes.has(nodeProg.nodeId)}
-																<div class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2">
-																	<button
-																		type="button"
-																		class="w-full text-left space-y-1.5"
-																		onclick={() => toggleInstanceDownloadDetails(nodeProg.nodeId)}
-																	>
-																		<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray">
-																			<span class="text-white/80 truncate pr-2">{nodeProg.nodeName}</span>
-																			<span class="flex items-center gap-1 text-blue-300">
-																				{nodePercent.toFixed(1)}%
-																				<svg class="w-3 h-3 text-exo-light-gray" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
-																					<path d="M6 8l4 4 4-4" class={isExpanded ? 'transform rotate-180 origin-center transition-transform duration-150' : 'transition-transform duration-150'}></path>
-																				</svg>
-																			</span>
-																		</div>
-																		<div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
-																			<div 
-																				class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
-																				style="width: {nodePercent.toFixed(1)}%"
-																			></div>
-																		</div>
-																		<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray">
-																			<span>{formatBytes(nodeProg.progress.downloadedBytes)} / {formatBytes(nodeProg.progress.totalBytes)}</span>
-																			<span>{formatSpeed(nodeProg.progress.speed)} • ETA {formatEta(nodeProg.progress.etaMs)}</span>
-																		</div>
-																	</button>
-
-																	{#if isExpanded}
-																		<div class="mt-2 space-y-1.5">
-																			{#if nodeProg.progress.files.length === 0}
-																				<div class="text-[11px] font-mono text-exo-light-gray/70">No file details reported.</div>
-																			{:else}
-																				{#each nodeProg.progress.files as f}
-																					{@const filePercent = Math.min(100, Math.max(0, f.percentage ?? 0))}
-																					{@const isFileComplete = filePercent >= 100}
-																					<div class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2">
-																						<div class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90">
-																							<span class="truncate pr-2">{f.name}</span>
-																							<span class={isFileComplete ? 'text-green-400' : 'text-white/80'}>{filePercent.toFixed(1)}%</span>
-																						</div>
-																						<div class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1">
-																							<div 
-																								class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete ? 'from-green-500 to-green-400' : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
-																								style="width: {filePercent.toFixed(1)}%"
-																							></div>
-																						</div>
-																						<div class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5">
-																							<span>{formatBytes(f.downloadedBytes)} / {formatBytes(f.totalBytes)}</span>
-																							<span>{formatSpeed(f.speed)} • ETA {formatEta(f.etaMs)}</span>
-																						</div>
-																					</div>
-																				{/each}
-																			{/if}
-																		</div>
-																	{/if}
-																</div>
-															{/each}
-														</div>
-													{/if}
-													<div class="text-xs text-blue-400 font-mono tracking-wider mt-1">DOWNLOADING</div>
-												{:else}
-													<div class="text-xs {getStatusColor(downloadInfo.statusText)} font-mono tracking-wider mt-1">{downloadInfo.statusText}</div>
-												{/if}
-											</div>
-										</div>
-									</div>
-								{/each}
-						</div>
-					</div>
-					{/if}
-					
-					<!-- Models Panel - Scrollable -->
-					<div class="p-4 flex-1 overflow-y-auto">
-						<!-- Panel Header -->
-						<div class="flex items-center gap-2 mb-3 flex-shrink-0">
-							<div class="w-2 h-2 border border-exo-yellow/60 rotate-45"></div>
-							<h3 class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase">Launch Instance</h3>
-							<div class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"></div>
-							<span class="text-sm text-white/70 font-mono">{models.length} models</span>
-						</div>
-						
-						<!-- Model Dropdown (Custom) -->
-						<div class="flex-shrink-0 mb-3 relative">
-							<button
-								type="button"
-								onclick={() => isModelDropdownOpen = !isModelDropdownOpen}
-								class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-2.5 text-sm font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen ? 'border-exo-yellow/70' : ''}"
-							>
-								{#if selectedModelId}
-									{@const foundModel = models.find(m => m.id === selectedModelId)}
-									{#if foundModel}
-										{@const sizeGB = getModelSizeGB(foundModel)}
-										{@const isImageModel = modelSupportsImageGeneration(foundModel.id)}
-											<span class="flex items-center justify-between gap-2 w-full pr-4">
-											<span class="flex items-center gap-2 text-exo-light-gray truncate">
-												{#if isImageModel}
-													<svg class="w-4 h-4 flex-shrink-0 text-exo-yellow" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
-														<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
-														<circle cx="8.5" cy="8.5" r="1.5"/>
-														<polyline points="21 15 16 10 5 21"/>
-													</svg>
-												{/if}
-												<span class="truncate">{foundModel.name || foundModel.id}</span>
-											</span>
-											<span class="text-white/50 text-xs flex-shrink-0">{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)}GB</span>
-										</span>
-									{:else}
-										<span class="text-exo-light-gray">{selectedModelId}</span>
-									{/if}
-								{:else}
-									<span class="text-white/50">— SELECT MODEL —</span>
-								{/if}
-							</button>
-							<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen ? 'rotate-180' : ''}">
-								<svg class="w-4 h-4 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
-								</svg>
-							</div>
-							
-							{#if isModelDropdownOpen}
-								<!-- Backdrop to close dropdown -->
-								<button 
-									type="button"
-									class="fixed inset-0 z-40 cursor-default" 
-									onclick={() => isModelDropdownOpen = false}
-									aria-label="Close dropdown"
-								></button>
-								
-								<!-- Dropdown Panel -->
-							<div class="absolute top-full left-0 right-0 mt-1 bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-50 max-h-64 overflow-y-auto">
-								<!-- Search within dropdown -->
-								<div class="sticky top-0 bg-exo-dark-gray border-b border-exo-medium-gray/30 p-2">
-										<input
-											type="text"
-											placeholder="Search models..."
-											bind:value={modelDropdownSearch}
-											class="w-full bg-exo-dark-gray/60 border border-exo-medium-gray/30 rounded px-2 py-1.5 text-xs font-mono text-white/80 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/50"
-										/>
-									</div>
-									
-									<!-- Options -->
-									<div class="py-1">
-										{#each sortedModels().filter(m => 
-											!modelDropdownSearch || 
-											(m.name || m.id).toLowerCase().includes(modelDropdownSearch.toLowerCase())
-										) as model}
-											{@const sizeGB = getModelSizeGB(model)}
-											{@const modelCanFit = hasEnoughMemory(model)}
-											{@const isImageModel = modelSupportsImageGeneration(model.id)}
-											<button
-												type="button"
-												onclick={() => {
-													if (modelCanFit) {
-														selectPreviewModel(model.id);
-														saveLaunchDefaults();
-														isModelDropdownOpen = false;
-														modelDropdownSearch = '';
-													}
-												}}
-												disabled={!modelCanFit}
-												class="w-full px-3 py-2 text-left text-sm font-mono tracking-wide transition-colors duration-100 flex items-center justify-between gap-2 {
-													selectedModelId === model.id 
-														? 'bg-transparent text-exo-yellow cursor-pointer' 
-														: modelCanFit 
-															? 'text-white/80 hover:text-exo-yellow cursor-pointer' 
-															: 'text-white/30 cursor-default'
-												}"
-											>
-												<span class="flex items-center gap-2 truncate flex-1">
-													{#if isImageModel}
-														<svg class="w-4 h-4 flex-shrink-0 text-exo-yellow" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Image generation model">
-															<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
-															<circle cx="8.5" cy="8.5" r="1.5"/>
-															<polyline points="21 15 16 10 5 21"/>
-														</svg>
-													{/if}
-													<span class="truncate">{model.name || model.id}</span>
-												</span>
-												<span class="flex-shrink-0 text-xs {modelCanFit ? 'text-white/50' : 'text-red-400/60'}">
-													{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)}GB
-												</span>
-											</button>
-										{:else}
-											<div class="px-3 py-2 text-xs text-white/50 font-mono">No models found</div>
-										{/each}
-									</div>
-								</div>
-							{/if}
-						</div>
-						
-						<!-- Configuration Options -->
-						<div class="flex-shrink-0 mb-4 space-y-3">
-							<!-- Sharding -->
-							<div>
-								<div class="text-xs text-white/70 font-mono mb-2">Sharding:</div>
-								<div class="flex gap-2">
-									<button 
-										onclick={() => { selectedSharding = 'Pipeline'; saveLaunchDefaults(); }}
-										class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding === 'Pipeline' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
-									>
-										<span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding === 'Pipeline' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
-											{#if selectedSharding === 'Pipeline'}
-												<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
-											{/if}
-										</span>
-										Pipeline
-									</button>
-									<button 
-										onclick={() => { selectedSharding = 'Tensor'; saveLaunchDefaults(); }}
-										class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding === 'Tensor' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
-									>
-										<span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding === 'Tensor' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
-											{#if selectedSharding === 'Tensor'}
-												<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
-											{/if}
-										</span>
-										Tensor
-									</button>
-								</div>
-							</div>
-							
-							<!-- Instance Type -->
-							<div>
-								<div class="text-xs text-white/70 font-mono mb-2">Instance Type:</div>
-								<div class="flex gap-2">
-									<button 
-										onclick={() => { selectedInstanceType = 'MlxRing'; saveLaunchDefaults(); }}
-										class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType === 'MlxRing' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
-									>
-										<span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType === 'MlxRing' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
-											{#if selectedInstanceType === 'MlxRing'}
-												<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
-											{/if}
-										</span>
-										MLX Ring
-									</button>
-									<button 
-										onclick={() => { selectedInstanceType = 'MlxIbv'; saveLaunchDefaults(); }}
-										class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType === 'MlxIbv' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
-									>
-										<span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType === 'MlxIbv' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
-											{#if selectedInstanceType === 'MlxIbv'}
-												<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
-											{/if}
-										</span>
-										MLX RDMA
-									</button>
-								</div>
-							</div>
-							
-							<!-- Minimum Nodes (discrete slider with drag support) -->
-							<div>
-								<div class="text-xs text-white/70 font-mono mb-2">Minimum Nodes:</div>
-								<!-- Discrete slider track with drag support -->
-								<!-- svelte-ignore a11y_no_static_element_interactions -->
-								<div 
-									bind:this={sliderTrackElement}
-									class="relative h-16 cursor-pointer select-none px-2 pr-6"
-									onmousedown={handleSliderMouseDown}
-									ontouchstart={handleSliderTouchStart}
-								>
-									<!-- Track background - extends full width to align with edge dots -->
-									<div class="absolute top-6 left-0 right-0 h-2 bg-exo-medium-gray/50 rounded-full"></div>
-									<!-- Active track (fills up to selected) -->
-									{#if availableMinNodes > 1}
-										<div 
-											class="absolute top-6 left-0 h-2 bg-white/30 rounded-full transition-all pointer-events-none"
-											style="width: {((selectedMinNodes - 1) / (availableMinNodes - 1)) * 100}%"
-										></div>
-									{/if}
-									<!-- Dots and labels for each node count -->
-									{#each Array.from({ length: availableMinNodes }, (_, i) => i + 1) as n}
-										{@const isValid = validMinNodeCounts().has(n)}
-										{@const isSelected = selectedMinNodes === n}
-										{@const position = availableMinNodes > 1 ? ((n - 1) / (availableMinNodes - 1)) * 100 : 50}
-										<div
-											class="absolute flex flex-col items-center pointer-events-none"
-											style="left: {position}%; top: 0; transform: translateX(-50%);"
-										>
-											<!-- Dot -->
-											<span 
-												class="rounded-full transition-all {isSelected 
-													? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]' 
-													: isValid 
-														? 'w-4 h-4 bg-exo-light-gray/70 mt-1' 
-														: 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
-											></span>
-											<!-- Number label below dot -->
-											<span 
-												class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected 
-													? 'text-exo-yellow font-bold' 
-													: isValid 
-														? 'text-white/70' 
-														: 'text-white/30'}"
-											>{n}</span>
-										</div>
-									{/each}
-								</div>
-							</div>
-						</div>
-						
-						<!-- Selected Model Preview -->
-						<div class="space-y-3">
-							{#if models.length === 0}
-								<div class="text-center py-8">
-									<div class="text-xs text-white/70 font-mono tracking-wider uppercase">Loading models...</div>
-								</div>
-							{:else if loadingPreviews}
-								<div class="text-center py-8">
-									<div class="text-xs text-exo-yellow font-mono tracking-wider uppercase animate-pulse">Loading preview...</div>
-								</div>
-							{:else}
-								{@const selectedModel = models.find(m => m.id === selectedModelId)}
-								{@const allPreviews = filteredPreviews()}
-								{#if selectedModel && allPreviews.length > 0}
-									{@const downloadStatus = getModelDownloadStatus(selectedModel.id)}
-									{@const tags = modelTags()[selectedModel.id] || []}
-									<div class="space-y-3">
-										{#each allPreviews as apiPreview, i}
-										<div
-											role="group"
-											onmouseenter={() => {
-												if (apiPreview.memory_delta_by_node) {
-													hoveredPreviewNodes = new Set(
-														Object.entries(apiPreview.memory_delta_by_node)
-															.filter(([, delta]) => (delta ?? 0) > 0)
-															.map(([nodeId]) => nodeId)
-													);
-												}
-											}}
-											onmouseleave={() => hoveredPreviewNodes = new Set()}
-										>
-											<ModelCard 
-												model={selectedModel}
-												isLaunching={launchingModelId === selectedModel.id}
-												{downloadStatus}
-												nodes={data?.nodes ?? {}}
-												sharding={apiPreview.sharding}
-												runtime={apiPreview.instance_meta}
-												onLaunch={() => launchInstance(selectedModel.id, apiPreview)}
-												{tags}
-												{apiPreview}
-												modelIdOverride={apiPreview.model_id}
-											/>
-										</div>
-										{/each}
-									</div>
-								{:else if selectedModel}
-									<div class="text-center py-4">
-										<div class="text-xs text-white/50 font-mono">No valid configurations for current settings</div>
-									</div>
-								{/if}
-							{/if}
-						</div>
-					</div>
-				</aside>
-				
-			</div>
-		{:else}
-			<!-- CHAT STATE: Chat + Mini-Map -->
-			<div class="flex-1 flex overflow-hidden">
-				<!-- Chat Area -->
-				<div 
-					class="flex-1 flex flex-col min-w-0 overflow-hidden"
-					in:fade={{ duration: 300, delay: 100 }}
-				>
-					<div class="flex-1 overflow-y-auto px-8 py-6" bind:this={chatScrollRef}>
-						<div class="max-w-7xl mx-auto">
-							<ChatMessages scrollParent={chatScrollRef} />
-						</div>
-					</div>
-					
-					<div class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent">
-						<div class="max-w-7xl mx-auto">
-							<ChatForm placeholder="Ask anything" showModelSelector={true} modelTasks={modelTasks()} />
-						</div>
-					</div>
-				</div>
-				
-				<!-- Right: Mini-Map Sidebar -->
-				{#if minimized}
-					<aside 
-						class="w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex flex-col flex-shrink-0 overflow-y-auto"
-						in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
-					>
-						<!-- Topology Section - clickable to go back to main view -->
-						<button 
-							class="p-4 border-b border-exo-medium-gray/30 w-full text-left cursor-pointer hover:bg-exo-medium-gray/10 transition-colors"
-							onclick={handleGoHome}
-							title="Click to return to main topology view"
-						>
-							<div class="flex items-center justify-between mb-3">
-								<div class="text-xs text-exo-yellow tracking-[0.2em] uppercase flex items-center gap-2">
-									<span class="w-1.5 h-1.5 bg-exo-yellow rounded-full status-pulse"></span>
-								TOPOLOGY
-							</div>
-							<span class="text-xs text-white/70 tabular-nums">{nodeCount} {nodeCount === 1 ? 'NODE' : 'NODES'}</span>
-							</div>
-							
-							<div class="relative aspect-square bg-exo-dark-gray rounded-lg overflow-hidden">
-								
-								<TopologyGraph highlightedNodes={highlightedNodes()} />
-							</div>
-						</button>
-						
-						<!-- Instances Section (only shown when instances exist) -->
-						{#if instanceCount > 0}
-						<div class="p-4 flex-1">
-							<!-- Panel Header -->
-							<div class="flex items-center gap-2 mb-4">
-								<div class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"></div>
-								<h3 class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase">Instances</h3>
-								<div class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"></div>
-							</div>
-								<div class="space-y-3 max-h-72 xl:max-h-96 overflow-y-auto overflow-x-hidden py-px pr-1">
-									{#each Object.entries(instanceData) as [id, instance]}
-										{@const downloadInfo = getInstanceDownloadStatus(id, instance)}
-										{@const statusText = downloadInfo.statusText}
-										{@const isDownloading = downloadInfo.isDownloading}
-										{@const isFailed = statusText === 'FAILED'}
-										{@const isLoading = statusText === 'LOADING' || statusText === 'WARMING UP' || statusText === 'WAITING'}
-										{@const isReady = statusText === 'READY' || statusText === 'LOADED'}
-										{@const isRunning = statusText === 'RUNNING'}
-									<!-- Instance Card -->
-									{@const instanceModelId = getInstanceModelId(instance)}
-									{@const instanceInfo = getInstanceInfo(instance)}
-									{@const instanceConnections = getInstanceConnections(instance)}
-									<div 
-										class="relative group cursor-pointer"
-										role="button"
-										tabindex="0"
-										onmouseenter={() => hoveredInstanceId = id}
-										onmouseleave={() => hoveredInstanceId = null}
-										onclick={() => {
-											if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
-												setSelectedChatModel(instanceModelId);
-											}
-										}}
-										onkeydown={(e) => {
-											if (e.key === 'Enter' || e.key === ' ') {
-												if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
-													setSelectedChatModel(instanceModelId);
-												}
-											}
-										}}
-									>
-										<!-- Corner accents -->
-										<div class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
-										<div class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
-										<div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
-										<div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
-										
-										<div class="bg-exo-dark-gray/60 border border-l-2 {isDownloading ? 'border-blue-500/30 border-l-blue-400' : isFailed ? 'border-red-500/30 border-l-red-400' : isLoading ? 'border-exo-yellow/30 border-l-yellow-400' : isReady ? 'border-green-500/30 border-l-green-400' : 'border-teal-500/30 border-l-teal-400'} p-3">
-											
-											<div class="flex justify-between items-start mb-2 pl-2">
-												<div class="flex items-center gap-2">
-													<div class="w-1.5 h-1.5 {isDownloading ? 'bg-blue-400 animate-pulse' : isFailed ? 'bg-red-400' : isLoading ? 'bg-yellow-400 animate-pulse' : isReady ? 'bg-green-400' : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"></div>
-													<span class="text-exo-light-gray font-mono text-sm tracking-wider">{id.slice(0, 8).toUpperCase()}</span>
-												</div>
-												<button 
-													onclick={() => deleteInstance(id)}
-													class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
-												>
-													DELETE
-												</button>
-												</div>
-												<div class="pl-2">
-													<div class="text-exo-yellow text-xs font-mono tracking-wide truncate">{getInstanceModelId(instance)}</div>
-													<div class="text-white/60 text-xs font-mono">Strategy: <span class="text-white/80">{instanceInfo.sharding} ({instanceInfo.instanceType})</span></div>
-														{#if instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model'}
-															<a
-																class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
-																href={`https://huggingface.co/${instanceModelId}`}
-																target="_blank"
-																rel="noreferrer noopener"
-																aria-label="View model on Hugging Face"
-															>
-																<span>Hugging Face</span>
-																<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-																	<path d="M14 3h7v7"/>
-																	<path d="M10 14l11-11"/>
-																	<path d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"/>
-																</svg>
-															</a>
-														{/if}
-													{#if instanceInfo.nodeNames.length > 0}
-														<div class="text-white/60 text-xs font-mono">{instanceInfo.nodeNames.join(', ')}</div>
-													{/if}
-													{#if debugEnabled && instanceConnections.length > 0}
-													<div class="mt-2 space-y-1">
-														{#each instanceConnections as conn}
-															<div class="text-[11px] leading-snug font-mono text-white/70">
-																<span>{conn.from} -> {conn.to}: {conn.ip}</span>
-																<span class="{conn.missingIface ? 'text-red-400' : 'text-white/60'}"> ({conn.ifaceLabel})</span>
-															</div>
-														{/each}
-													</div>
-												{/if}
-												
-												<!-- Download Progress -->
-												{#if downloadInfo.isDownloading && downloadInfo.progress}
-													<div class="mt-2 space-y-1">
-														<div class="flex justify-between text-xs font-mono">
-															<span class="text-blue-400">{downloadInfo.progress.percentage.toFixed(1)}%</span>
-															<span class="text-exo-light-gray">{formatBytes(downloadInfo.progress.downloadedBytes)}/{formatBytes(downloadInfo.progress.totalBytes)}</span>
-														</div>
-														<div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
-															<div 
-																class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
-																style="width: {downloadInfo.progress.percentage}%"
-															></div>
-														</div>
-														<div class="flex justify-between text-xs font-mono text-exo-light-gray">
-															<span>{formatSpeed(downloadInfo.progress.speed)}</span>
-															<span>ETA: {formatEta(downloadInfo.progress.etaMs)}</span>
-															<span>{downloadInfo.progress.completedFiles}/{downloadInfo.progress.totalFiles} files</span>
-														</div>
-													</div>
-													{#if downloadInfo.perNode.length > 0}
-														<div class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1">
-															{#each downloadInfo.perNode as nodeProg}
-																{@const nodePercent = Math.min(100, Math.max(0, nodeProg.progress.percentage))}
-																{@const isExpanded = instanceDownloadExpandedNodes.has(nodeProg.nodeId)}
-																<div class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2">
-																	<button
-																		type="button"
-																		class="w-full text-left space-y-1.5"
-																		onclick={() => toggleInstanceDownloadDetails(nodeProg.nodeId)}
-																	>
-																		<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray">
-																			<span class="text-white/80 truncate pr-2">{nodeProg.nodeName}</span>
-																			<span class="flex items-center gap-1 text-blue-300">
-																				{nodePercent.toFixed(1)}%
-																				<svg class="w-3 h-3 text-exo-light-gray" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
-																					<path d="M6 8l4 4 4-4" class={isExpanded ? 'transform rotate-180 origin-center transition-transform duration-150' : 'transition-transform duration-150'}></path>
-																				</svg>
-																			</span>
-																		</div>
-																		<div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
-																			<div 
-																				class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
-																				style="width: {nodePercent.toFixed(1)}%"
-																			></div>
-																		</div>
-																		<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray">
-																			<span>{formatBytes(nodeProg.progress.downloadedBytes)} / {formatBytes(nodeProg.progress.totalBytes)}</span>
-																			<span>{formatSpeed(nodeProg.progress.speed)} • ETA {formatEta(nodeProg.progress.etaMs)}</span>
-																		</div>
-																	</button>
-
-																	{#if isExpanded}
-																		<div class="mt-2 space-y-1.5">
-																			{#if nodeProg.progress.files.length === 0}
-																				<div class="text-[11px] font-mono text-exo-light-gray/70">No file details reported.</div>
-																			{:else}
-																				{#each nodeProg.progress.files as f}
-																					{@const filePercent = Math.min(100, Math.max(0, f.percentage ?? 0))}
-																					{@const isFileComplete = filePercent >= 100}
-																					<div class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2">
-																						<div class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90">
-																							<span class="truncate pr-2">{f.name}</span>
-																							<span class={isFileComplete ? 'text-green-400' : 'text-white/80'}>{filePercent.toFixed(1)}%</span>
-																						</div>
-																						<div class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1">
-																							<div 
-																								class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete ? 'from-green-500 to-green-400' : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
-																								style="width: {filePercent.toFixed(1)}%"
-																							></div>
-																						</div>
-																						<div class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5">
-																							<span>{formatBytes(f.downloadedBytes)} / {formatBytes(f.totalBytes)}</span>
-																							<span>{formatSpeed(f.speed)} • ETA {formatEta(f.etaMs)}</span>
-																						</div>
-																					</div>
-																				{/each}
-																			{/if}
-																		</div>
-																	{/if}
-																</div>
-															{/each}
-														</div>
-													{/if}
-													<div class="text-xs text-blue-400 font-mono tracking-wider mt-1">DOWNLOADING</div>
-												{:else}
-													<div class="text-xs {getStatusColor(downloadInfo.statusText)} font-mono tracking-wider mt-1">{downloadInfo.statusText}</div>
-												{/if}
-												</div>
-											</div>
-										</div>
-									{/each}
-								</div>
-						</div>
-						{/if}
-					</aside>
-				{/if}
-			</div>
-		{/if}
-	</main>
-
+<div
+  class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden"
+>
+  <!-- Scanline overlay -->
+  <div
+    class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"
+  ></div>
+
+  <!-- Shooting Stars Background - one every ~15s -->
+  <div class="shooting-stars">
+    <div
+      class="shooting-star"
+      style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"
+    ></div>
+    <div
+      class="shooting-star"
+      style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"
+    ></div>
+    <div
+      class="shooting-star"
+      style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"
+    ></div>
+  </div>
+
+  {#if !topologyOnlyEnabled}
+    <HeaderNav
+      showHome={chatStarted}
+      onHome={handleGoHome}
+      showSidebarToggle={true}
+      {sidebarVisible}
+      onToggleSidebar={toggleChatSidebarVisible}
+    />
+  {/if}
+
+  <!-- Main Content -->
+  <main class="flex-1 flex overflow-hidden relative">
+    <!-- Left: Conversation History Sidebar (hidden in topology-only mode or when toggled off) -->
+    {#if !topologyOnlyEnabled && sidebarVisible}
+      <div class="w-80 flex-shrink-0 border-r border-exo-yellow/10">
+        <ChatSidebar class="h-full" />
+      </div>
+    {/if}
+
+    {#if topologyOnlyEnabled}
+      <!-- TOPOLOGY ONLY MODE: Full-screen topology -->
+      <div
+        class="flex-1 flex flex-col min-h-0 min-w-0 p-4"
+        in:fade={{ duration: 300 }}
+      >
+        <div
+          class="flex-1 relative bg-exo-dark-gray/40 rounded-lg overflow-hidden"
+        >
+          <TopologyGraph
+            class="w-full h-full"
+            highlightedNodes={highlightedNodes()}
+          />
+          <!-- Exit topology-only mode button -->
+          <button
+            type="button"
+            onclick={toggleTopologyOnlyMode}
+            class="absolute bottom-4 right-4 p-2 rounded border border-exo-yellow/30 bg-exo-dark-gray/80 hover:border-exo-yellow/50 hover:bg-exo-dark-gray transition-colors cursor-pointer backdrop-blur-sm"
+            title="Exit topology only mode"
+          >
+            <svg
+              class="w-5 h-5 text-exo-yellow"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+              stroke-width="2"
+            >
+              <circle cx="12" cy="5" r="2" fill="currentColor" />
+              <circle cx="5" cy="19" r="2" fill="currentColor" />
+              <circle cx="19" cy="19" r="2" fill="currentColor" />
+              <path stroke-linecap="round" d="M12 7v5m0 0l-5 5m5-5l5 5" />
+            </svg>
+          </button>
+        </div>
+      </div>
+    {:else if !chatStarted}
+      <!-- WELCOME STATE: Topology + Instance Controls (no left sidebar for cleaner look) -->
+      <div
+        class="flex-1 flex overflow-visible relative"
+        in:fade={{ duration: 300 }}
+        out:fade={{ duration: 200 }}
+      >
+        <!-- Center: MAIN TOPOLOGY DISPLAY -->
+        <div class="flex-1 flex flex-col min-h-0 min-w-0 py-4">
+          <!-- Topology Container - Takes most of the space -->
+          <div
+            class="flex-1 relative bg-exo-dark-gray/40 mx-4 mb-4 rounded-lg overflow-hidden"
+          >
+            <!-- The main topology graph - full container -->
+            <TopologyGraph
+              class="w-full h-full"
+              highlightedNodes={highlightedNodes()}
+            />
+          </div>
+
+          <!-- Chat Input - Below topology -->
+          <div class="px-4 pt-6 pb-8">
+            <div class="max-w-3xl mx-auto">
+              <ChatForm
+                placeholder="Ask anything"
+                showHelperText={false}
+                showModelSelector={true}
+                modelTasks={modelTasks()}
+              />
+            </div>
+          </div>
+        </div>
+
+        <!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) -->
+        <aside
+          class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0"
+        >
+          <!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
+          {#if instanceCount > 0}
+            <div class="p-4 flex-shrink-0">
+              <!-- Panel Header -->
+              <div class="flex items-center gap-2 mb-4">
+                <div
+                  class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
+                ></div>
+                <h3
+                  class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
+                >
+                  Instances
+                </h3>
+                <div
+                  class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
+                ></div>
+              </div>
+
+              <div
+                bind:this={instancesContainerRef}
+                class="max-h-72 xl:max-h-96 space-y-3 overflow-y-auto overflow-x-hidden py-px"
+              >
+                {#each Object.entries(instanceData) as [id, instance]}
+                  {@const downloadInfo = getInstanceDownloadStatus(
+                    id,
+                    instance,
+                  )}
+                  {@const statusText = downloadInfo.statusText}
+                  {@const isDownloading = downloadInfo.isDownloading}
+                  {@const isFailed = statusText === "FAILED"}
+                  {@const isLoading =
+                    statusText === "LOADING" ||
+                    statusText === "WARMING UP" ||
+                    statusText === "WAITING"}
+                  {@const isReady =
+                    statusText === "READY" || statusText === "LOADED"}
+                  {@const isRunning = statusText === "RUNNING"}
+                  <!-- Instance Card -->
+                  {@const instanceModelId = getInstanceModelId(instance)}
+                  {@const instanceInfo = getInstanceInfo(instance)}
+                  {@const instanceConnections =
+                    getInstanceConnections(instance)}
+                  <div
+                    class="relative group cursor-pointer"
+                    role="button"
+                    tabindex="0"
+                    onmouseenter={() => (hoveredInstanceId = id)}
+                    onmouseleave={() => (hoveredInstanceId = null)}
+                    onclick={() => {
+                      if (
+                        instanceModelId &&
+                        instanceModelId !== "Unknown" &&
+                        instanceModelId !== "Unknown Model"
+                      ) {
+                        setSelectedChatModel(instanceModelId);
+                      }
+                    }}
+                    onkeydown={(e) => {
+                      if (e.key === "Enter" || e.key === " ") {
+                        if (
+                          instanceModelId &&
+                          instanceModelId !== "Unknown" &&
+                          instanceModelId !== "Unknown Model"
+                        ) {
+                          setSelectedChatModel(instanceModelId);
+                        }
+                      }
+                    }}
+                  >
+                    <!-- Corner accents -->
+                    <div
+                      class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading
+                        ? 'border-blue-500/50'
+                        : isFailed
+                          ? 'border-red-500/50'
+                          : isLoading
+                            ? 'border-yellow-500/50'
+                            : isReady
+                              ? 'border-green-500/50'
+                              : 'border-teal-500/50'}"
+                    ></div>
+                    <div
+                      class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading
+                        ? 'border-blue-500/50'
+                        : isFailed
+                          ? 'border-red-500/50'
+                          : isLoading
+                            ? 'border-yellow-500/50'
+                            : isReady
+                              ? 'border-green-500/50'
+                              : 'border-teal-500/50'}"
+                    ></div>
+                    <div
+                      class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading
+                        ? 'border-blue-500/50'
+                        : isFailed
+                          ? 'border-red-500/50'
+                          : isLoading
+                            ? 'border-yellow-500/50'
+                            : isReady
+                              ? 'border-green-500/50'
+                              : 'border-teal-500/50'}"
+                    ></div>
+                    <div
+                      class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading
+                        ? 'border-blue-500/50'
+                        : isFailed
+                          ? 'border-red-500/50'
+                          : isLoading
+                            ? 'border-yellow-500/50'
+                            : isReady
+                              ? 'border-green-500/50'
+                              : 'border-teal-500/50'}"
+                    ></div>
+
+                    <div
+                      class="bg-exo-dark-gray/60 border border-l-2 {isDownloading
+                        ? 'border-blue-500/30 border-l-blue-400'
+                        : isFailed
+                          ? 'border-red-500/30 border-l-red-400'
+                          : isLoading
+                            ? 'border-exo-yellow/30 border-l-yellow-400'
+                            : isReady
+                              ? 'border-green-500/30 border-l-green-400'
+                              : 'border-teal-500/30 border-l-teal-400'} p-3"
+                    >
+                      <div class="flex justify-between items-start mb-2 pl-2">
+                        <div class="flex items-center gap-2">
+                          <div
+                            class="w-1.5 h-1.5 {isDownloading
+                              ? 'bg-blue-400 animate-pulse'
+                              : isFailed
+                                ? 'bg-red-400'
+                                : isLoading
+                                  ? 'bg-yellow-400 animate-pulse'
+                                  : isReady
+                                    ? 'bg-green-400'
+                                    : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"
+                          ></div>
+                          <span
+                            class="text-exo-light-gray font-mono text-sm tracking-wider"
+                            >{id.slice(0, 8).toUpperCase()}</span
+                          >
+                        </div>
+                        <button
+                          onclick={() => deleteInstance(id)}
+                          class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
+                        >
+                          DELETE
+                        </button>
+                      </div>
+                      <div class="pl-2">
+                        <div
+                          class="text-exo-yellow text-xs font-mono tracking-wide truncate"
+                        >
+                          {getInstanceModelId(instance)}
+                        </div>
+                        <div class="text-white/60 text-xs font-mono">
+                          Strategy: <span class="text-white/80"
+                            >{instanceInfo.sharding} ({instanceInfo.instanceType})</span
+                          >
+                        </div>
+                        {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
+                          <a
+                            class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
+                            href={`https://huggingface.co/${instanceModelId}`}
+                            target="_blank"
+                            rel="noreferrer noopener"
+                            aria-label="View model on Hugging Face"
+                          >
+                            <span>Hugging Face</span>
+                            <svg
+                              class="w-3.5 h-3.5"
+                              viewBox="0 0 24 24"
+                              fill="none"
+                              stroke="currentColor"
+                              stroke-width="2"
+                              stroke-linecap="round"
+                              stroke-linejoin="round"
+                            >
+                              <path d="M14 3h7v7" />
+                              <path d="M10 14l11-11" />
+                              <path
+                                d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
+                              />
+                            </svg>
+                          </a>
+                        {/if}
+                        {#if instanceInfo.nodeNames.length > 0}
+                          <div class="text-white/60 text-xs font-mono">
+                            {instanceInfo.nodeNames.join(", ")}
+                          </div>
+                        {/if}
+                        {#if debugEnabled && instanceConnections.length > 0}
+                          <div class="mt-2 space-y-1">
+                            {#each instanceConnections as conn}
+                              <div
+                                class="text-[11px] leading-snug font-mono text-white/70"
+                              >
+                                <span>{conn.from} -> {conn.to}: {conn.ip}</span>
+                                <span
+                                  class={conn.missingIface
+                                    ? "text-red-400"
+                                    : "text-white/60"}
+                                >
+                                  ({conn.ifaceLabel})</span
+                                >
+                              </div>
+                            {/each}
+                          </div>
+                        {/if}
+
+                        <!-- Download Progress -->
+                        {#if downloadInfo.isDownloading && downloadInfo.progress}
+                          <div class="mt-2 space-y-1">
+                            <div class="flex justify-between text-xs font-mono">
+                              <span class="text-blue-400"
+                                >{downloadInfo.progress.percentage.toFixed(
+                                  1,
+                                )}%</span
+                              >
+                              <span class="text-exo-light-gray"
+                                >{formatBytes(
+                                  downloadInfo.progress.downloadedBytes,
+                                )}/{formatBytes(
+                                  downloadInfo.progress.totalBytes,
+                                )}</span
+                              >
+                            </div>
+                            <div
+                              class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+                            >
+                              <div
+                                class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+                                style="width: {downloadInfo.progress
+                                  .percentage}%"
+                              ></div>
+                            </div>
+                            <div
+                              class="flex justify-between text-xs font-mono text-exo-light-gray"
+                            >
+                              <span
+                                >{formatSpeed(
+                                  downloadInfo.progress.speed,
+                                )}</span
+                              >
+                              <span
+                                >ETA: {formatEta(
+                                  downloadInfo.progress.etaMs,
+                                )}</span
+                              >
+                              <span
+                                >{downloadInfo.progress
+                                  .completedFiles}/{downloadInfo.progress
+                                  .totalFiles} files</span
+                              >
+                            </div>
+                          </div>
+                          {#if downloadInfo.perNode.length > 0}
+                            <div
+                              class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
+                            >
+                              {#each downloadInfo.perNode as nodeProg}
+                                {@const nodePercent = Math.min(
+                                  100,
+                                  Math.max(0, nodeProg.progress.percentage),
+                                )}
+                                {@const isExpanded =
+                                  instanceDownloadExpandedNodes.has(
+                                    nodeProg.nodeId,
+                                  )}
+                                <div
+                                  class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
+                                >
+                                  <button
+                                    type="button"
+                                    class="w-full text-left space-y-1.5"
+                                    onclick={() =>
+                                      toggleInstanceDownloadDetails(
+                                        nodeProg.nodeId,
+                                      )}
+                                  >
+                                    <div
+                                      class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+                                    >
+                                      <span class="text-white/80 truncate pr-2"
+                                        >{nodeProg.nodeName}</span
+                                      >
+                                      <span
+                                        class="flex items-center gap-1 text-blue-300"
+                                      >
+                                        {nodePercent.toFixed(1)}%
+                                        <svg
+                                          class="w-3 h-3 text-exo-light-gray"
+                                          viewBox="0 0 20 20"
+                                          fill="none"
+                                          stroke="currentColor"
+                                          stroke-width="2"
+                                        >
+                                          <path
+                                            d="M6 8l4 4 4-4"
+                                            class={isExpanded
+                                              ? "transform rotate-180 origin-center transition-transform duration-150"
+                                              : "transition-transform duration-150"}
+                                          ></path>
+                                        </svg>
+                                      </span>
+                                    </div>
+                                    <div
+                                      class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+                                    >
+                                      <div
+                                        class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+                                        style="width: {nodePercent.toFixed(1)}%"
+                                      ></div>
+                                    </div>
+                                    <div
+                                      class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+                                    >
+                                      <span
+                                        >{formatBytes(
+                                          nodeProg.progress.downloadedBytes,
+                                        )} / {formatBytes(
+                                          nodeProg.progress.totalBytes,
+                                        )}</span
+                                      >
+                                      <span
+                                        >{formatSpeed(nodeProg.progress.speed)} •
+                                        ETA {formatEta(
+                                          nodeProg.progress.etaMs,
+                                        )}</span
+                                      >
+                                    </div>
+                                  </button>
+
+                                  {#if isExpanded}
+                                    <div class="mt-2 space-y-1.5">
+                                      {#if nodeProg.progress.files.length === 0}
+                                        <div
+                                          class="text-[11px] font-mono text-exo-light-gray/70"
+                                        >
+                                          No file details reported.
+                                        </div>
+                                      {:else}
+                                        {#each nodeProg.progress.files as f}
+                                          {@const filePercent = Math.min(
+                                            100,
+                                            Math.max(0, f.percentage ?? 0),
+                                          )}
+                                          {@const isFileComplete =
+                                            filePercent >= 100}
+                                          <div
+                                            class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
+                                          >
+                                            <div
+                                              class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
+                                            >
+                                              <span class="truncate pr-2"
+                                                >{f.name}</span
+                                              >
+                                              <span
+                                                class={isFileComplete
+                                                  ? "text-green-400"
+                                                  : "text-white/80"}
+                                                >{filePercent.toFixed(1)}%</span
+                                              >
+                                            </div>
+                                            <div
+                                              class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
+                                            >
+                                              <div
+                                                class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
+                                                  ? 'from-green-500 to-green-400'
+                                                  : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
+                                                style="width: {filePercent.toFixed(
+                                                  1,
+                                                )}%"
+                                              ></div>
+                                            </div>
+                                            <div
+                                              class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
+                                            >
+                                              <span
+                                                >{formatBytes(
+                                                  f.downloadedBytes,
+                                                )} / {formatBytes(
+                                                  f.totalBytes,
+                                                )}</span
+                                              >
+                                              <span
+                                                >{formatSpeed(f.speed)} • ETA {formatEta(
+                                                  f.etaMs,
+                                                )}</span
+                                              >
+                                            </div>
+                                          </div>
+                                        {/each}
+                                      {/if}
+                                    </div>
+                                  {/if}
+                                </div>
+                              {/each}
+                            </div>
+                          {/if}
+                          <div
+                            class="text-xs text-blue-400 font-mono tracking-wider mt-1"
+                          >
+                            DOWNLOADING
+                          </div>
+                        {:else}
+                          <div
+                            class="text-xs {getStatusColor(
+                              downloadInfo.statusText,
+                            )} font-mono tracking-wider mt-1"
+                          >
+                            {downloadInfo.statusText}
+                          </div>
+                        {/if}
+                      </div>
+                    </div>
+                  </div>
+                {/each}
+              </div>
+            </div>
+          {/if}
+
+          <!-- Models Panel - Scrollable -->
+          <div class="p-4 flex-1 overflow-y-auto">
+            <!-- Panel Header -->
+            <div class="flex items-center gap-2 mb-3 flex-shrink-0">
+              <div class="w-2 h-2 border border-exo-yellow/60 rotate-45"></div>
+              <h3
+                class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
+              >
+                Launch Instance
+              </h3>
+              <div
+                class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
+              ></div>
+              <span class="text-sm text-white/70 font-mono"
+                >{models.length} models</span
+              >
+            </div>
+
+            <!-- Model Dropdown (Custom) -->
+            <div class="flex-shrink-0 mb-3 relative">
+              <button
+                type="button"
+                onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
+                class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-2.5 text-sm font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen
+                  ? 'border-exo-yellow/70'
+                  : ''}"
+              >
+                {#if selectedModelId}
+                  {@const foundModel = models.find(
+                    (m) => m.id === selectedModelId,
+                  )}
+                  {#if foundModel}
+                    {@const sizeGB = getModelSizeGB(foundModel)}
+                    {@const isImageModel = modelSupportsImageGeneration(
+                      foundModel.id,
+                    )}
+                    <span
+                      class="flex items-center justify-between gap-2 w-full pr-4"
+                    >
+                      <span
+                        class="flex items-center gap-2 text-exo-light-gray truncate"
+                      >
+                        {#if isImageModel}
+                          <svg
+                            class="w-4 h-4 flex-shrink-0 text-exo-yellow"
+                            fill="none"
+                            viewBox="0 0 24 24"
+                            stroke="currentColor"
+                            stroke-width="2"
+                          >
+                            <rect
+                              x="3"
+                              y="3"
+                              width="18"
+                              height="18"
+                              rx="2"
+                              ry="2"
+                            />
+                            <circle cx="8.5" cy="8.5" r="1.5" />
+                            <polyline points="21 15 16 10 5 21" />
+                          </svg>
+                        {/if}
+                        <span class="truncate"
+                          >{foundModel.name || foundModel.id}</span
+                        >
+                      </span>
+                      <span class="text-white/50 text-xs flex-shrink-0"
+                        >{sizeGB >= 1
+                          ? sizeGB.toFixed(0)
+                          : sizeGB.toFixed(1)}GB</span
+                      >
+                    </span>
+                  {:else}
+                    <span class="text-exo-light-gray">{selectedModelId}</span>
+                  {/if}
+                {:else}
+                  <span class="text-white/50">— SELECT MODEL —</span>
+                {/if}
+              </button>
+              <div
+                class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen
+                  ? 'rotate-180'
+                  : ''}"
+              >
+                <svg
+                  class="w-4 h-4 text-exo-yellow/60"
+                  fill="none"
+                  viewBox="0 0 24 24"
+                  stroke="currentColor"
+                >
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    stroke-width="2"
+                    d="M19 9l-7 7-7-7"
+                  />
+                </svg>
+              </div>
+
+              {#if isModelDropdownOpen}
+                <!-- Backdrop to close dropdown -->
+                <button
+                  type="button"
+                  class="fixed inset-0 z-40 cursor-default"
+                  onclick={() => (isModelDropdownOpen = false)}
+                  aria-label="Close dropdown"
+                ></button>
+
+                <!-- Dropdown Panel -->
+                <div
+                  class="absolute top-full left-0 right-0 mt-1 bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-50 max-h-64 overflow-y-auto"
+                >
+                  <!-- Search within dropdown -->
+                  <div
+                    class="sticky top-0 bg-exo-dark-gray border-b border-exo-medium-gray/30 p-2"
+                  >
+                    <input
+                      type="text"
+                      placeholder="Search models..."
+                      bind:value={modelDropdownSearch}
+                      class="w-full bg-exo-dark-gray/60 border border-exo-medium-gray/30 rounded px-2 py-1.5 text-xs font-mono text-white/80 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/50"
+                    />
+                  </div>
+
+                  <!-- Options -->
+                  <div class="py-1">
+                    {#each sortedModels().filter((m) => !modelDropdownSearch || (m.name || m.id)
+                          .toLowerCase()
+                          .includes(modelDropdownSearch.toLowerCase())) as model}
+                      {@const sizeGB = getModelSizeGB(model)}
+                      {@const modelCanFit = hasEnoughMemory(model)}
+                      {@const isImageModel = modelSupportsImageGeneration(
+                        model.id,
+                      )}
+                      <button
+                        type="button"
+                        onclick={() => {
+                          if (modelCanFit) {
+                            selectPreviewModel(model.id);
+                            saveLaunchDefaults();
+                            isModelDropdownOpen = false;
+                            modelDropdownSearch = "";
+                          }
+                        }}
+                        disabled={!modelCanFit}
+                        class="w-full px-3 py-2 text-left text-sm font-mono tracking-wide transition-colors duration-100 flex items-center justify-between gap-2 {selectedModelId ===
+                        model.id
+                          ? 'bg-transparent text-exo-yellow cursor-pointer'
+                          : modelCanFit
+                            ? 'text-white/80 hover:text-exo-yellow cursor-pointer'
+                            : 'text-white/30 cursor-default'}"
+                      >
+                        <span class="flex items-center gap-2 truncate flex-1">
+                          {#if isImageModel}
+                            <svg
+                              class="w-4 h-4 flex-shrink-0 text-exo-yellow"
+                              fill="none"
+                              viewBox="0 0 24 24"
+                              stroke="currentColor"
+                              stroke-width="2"
+                              aria-label="Image generation model"
+                            >
+                              <rect
+                                x="3"
+                                y="3"
+                                width="18"
+                                height="18"
+                                rx="2"
+                                ry="2"
+                              />
+                              <circle cx="8.5" cy="8.5" r="1.5" />
+                              <polyline points="21 15 16 10 5 21" />
+                            </svg>
+                          {/if}
+                          <span class="truncate">{model.name || model.id}</span>
+                        </span>
+                        <span
+                          class="flex-shrink-0 text-xs {modelCanFit
+                            ? 'text-white/50'
+                            : 'text-red-400/60'}"
+                        >
+                          {sizeGB >= 1
+                            ? sizeGB.toFixed(0)
+                            : sizeGB.toFixed(1)}GB
+                        </span>
+                      </button>
+                    {:else}
+                      <div class="px-3 py-2 text-xs text-white/50 font-mono">
+                        No models found
+                      </div>
+                    {/each}
+                  </div>
+                </div>
+              {/if}
+            </div>
+
+            <!-- Configuration Options -->
+            <div class="flex-shrink-0 mb-4 space-y-3">
+              <!-- Sharding -->
+              <div>
+                <div class="text-xs text-white/70 font-mono mb-2">
+                  Sharding:
+                </div>
+                <div class="flex gap-2">
+                  <button
+                    onclick={() => {
+                      selectedSharding = "Pipeline";
+                      saveLaunchDefaults();
+                    }}
+                    class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
+                    'Pipeline'
+                      ? 'bg-transparent text-exo-yellow border-exo-yellow'
+                      : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+                  >
+                    <span
+                      class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding ===
+                      'Pipeline'
+                        ? 'border-exo-yellow'
+                        : 'border-exo-medium-gray'}"
+                    >
+                      {#if selectedSharding === "Pipeline"}
+                        <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+                      {/if}
+                    </span>
+                    Pipeline
+                  </button>
+                  <button
+                    onclick={() => {
+                      selectedSharding = "Tensor";
+                      saveLaunchDefaults();
+                    }}
+                    class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
+                    'Tensor'
+                      ? 'bg-transparent text-exo-yellow border-exo-yellow'
+                      : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+                  >
+                    <span
+                      class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding ===
+                      'Tensor'
+                        ? 'border-exo-yellow'
+                        : 'border-exo-medium-gray'}"
+                    >
+                      {#if selectedSharding === "Tensor"}
+                        <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+                      {/if}
+                    </span>
+                    Tensor
+                  </button>
+                </div>
+              </div>
+
+              <!-- Instance Type -->
+              <div>
+                <div class="text-xs text-white/70 font-mono mb-2">
+                  Instance Type:
+                </div>
+                <div class="flex gap-2">
+                  <button
+                    onclick={() => {
+                      selectedInstanceType = "MlxRing";
+                      saveLaunchDefaults();
+                    }}
+                    class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
+                    'MlxRing'
+                      ? 'bg-transparent text-exo-yellow border-exo-yellow'
+                      : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+                  >
+                    <span
+                      class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
+                      'MlxRing'
+                        ? 'border-exo-yellow'
+                        : 'border-exo-medium-gray'}"
+                    >
+                      {#if selectedInstanceType === "MlxRing"}
+                        <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+                      {/if}
+                    </span>
+                    MLX Ring
+                  </button>
+                  <button
+                    onclick={() => {
+                      selectedInstanceType = "MlxIbv";
+                      saveLaunchDefaults();
+                    }}
+                    class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
+                    'MlxIbv'
+                      ? 'bg-transparent text-exo-yellow border-exo-yellow'
+                      : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+                  >
+                    <span
+                      class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
+                      'MlxIbv'
+                        ? 'border-exo-yellow'
+                        : 'border-exo-medium-gray'}"
+                    >
+                      {#if selectedInstanceType === "MlxIbv"}
+                        <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+                      {/if}
+                    </span>
+                    MLX RDMA
+                  </button>
+                </div>
+              </div>
+
+              <!-- Minimum Nodes (discrete slider with drag support) -->
+              <div>
+                <div class="text-xs text-white/70 font-mono mb-2">
+                  Minimum Nodes:
+                </div>
+                <!-- Discrete slider track with drag support -->
+                <!-- svelte-ignore a11y_no_static_element_interactions -->
+                <div
+                  bind:this={sliderTrackElement}
+                  class="relative h-16 cursor-pointer select-none px-2 pr-6"
+                  onmousedown={handleSliderMouseDown}
+                  ontouchstart={handleSliderTouchStart}
+                >
+                  <!-- Track background - extends full width to align with edge dots -->
+                  <div
+                    class="absolute top-6 left-0 right-0 h-2 bg-exo-medium-gray/50 rounded-full"
+                  ></div>
+                  <!-- Active track (fills up to selected) -->
+                  {#if availableMinNodes > 1}
+                    <div
+                      class="absolute top-6 left-0 h-2 bg-white/30 rounded-full transition-all pointer-events-none"
+                      style="width: {((selectedMinNodes - 1) /
+                        (availableMinNodes - 1)) *
+                        100}%"
+                    ></div>
+                  {/if}
+                  <!-- Dots and labels for each node count -->
+                  {#each Array.from({ length: availableMinNodes }, (_, i) => i + 1) as n}
+                    {@const isValid = validMinNodeCounts().has(n)}
+                    {@const isSelected = selectedMinNodes === n}
+                    {@const position =
+                      availableMinNodes > 1
+                        ? ((n - 1) / (availableMinNodes - 1)) * 100
+                        : 50}
+                    <div
+                      class="absolute flex flex-col items-center pointer-events-none"
+                      style="left: {position}%; top: 0; transform: translateX(-50%);"
+                    >
+                      <!-- Dot -->
+                      <span
+                        class="rounded-full transition-all {isSelected
+                          ? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]'
+                          : isValid
+                            ? 'w-4 h-4 bg-exo-light-gray/70 mt-1'
+                            : 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
+                      ></span>
+                      <!-- Number label below dot -->
+                      <span
+                        class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected
+                          ? 'text-exo-yellow font-bold'
+                          : isValid
+                            ? 'text-white/70'
+                            : 'text-white/30'}">{n}</span
+                      >
+                    </div>
+                  {/each}
+                </div>
+              </div>
+            </div>
+
+            <!-- Selected Model Preview -->
+            <div class="space-y-3">
+              {#if models.length === 0}
+                <div class="text-center py-8">
+                  <div
+                    class="text-xs text-white/70 font-mono tracking-wider uppercase"
+                  >
+                    Loading models...
+                  </div>
+                </div>
+              {:else if loadingPreviews}
+                <div class="text-center py-8">
+                  <div
+                    class="text-xs text-exo-yellow font-mono tracking-wider uppercase animate-pulse"
+                  >
+                    Loading preview...
+                  </div>
+                </div>
+              {:else}
+                {@const selectedModel = models.find(
+                  (m) => m.id === selectedModelId,
+                )}
+                {@const allPreviews = filteredPreviews()}
+                {#if selectedModel && allPreviews.length > 0}
+                  {@const downloadStatus = getModelDownloadStatus(
+                    selectedModel.id,
+                  )}
+                  {@const tags = modelTags()[selectedModel.id] || []}
+                  <div class="space-y-3">
+                    {#each allPreviews as apiPreview, i}
+                      <div
+                        role="group"
+                        onmouseenter={() => {
+                          if (apiPreview.memory_delta_by_node) {
+                            hoveredPreviewNodes = new Set(
+                              Object.entries(apiPreview.memory_delta_by_node)
+                                .filter(([, delta]) => (delta ?? 0) > 0)
+                                .map(([nodeId]) => nodeId),
+                            );
+                          }
+                        }}
+                        onmouseleave={() => (hoveredPreviewNodes = new Set())}
+                      >
+                        <ModelCard
+                          model={selectedModel}
+                          isLaunching={launchingModelId === selectedModel.id}
+                          {downloadStatus}
+                          nodes={data?.nodes ?? {}}
+                          sharding={apiPreview.sharding}
+                          runtime={apiPreview.instance_meta}
+                          onLaunch={() =>
+                            launchInstance(selectedModel.id, apiPreview)}
+                          {tags}
+                          {apiPreview}
+                          modelIdOverride={apiPreview.model_id}
+                        />
+                      </div>
+                    {/each}
+                  </div>
+                {:else if selectedModel}
+                  <div class="text-center py-4">
+                    <div class="text-xs text-white/50 font-mono">
+                      No valid configurations for current settings
+                    </div>
+                  </div>
+                {/if}
+              {/if}
+            </div>
+          </div>
+        </aside>
+      </div>
+    {:else}
+      <!-- CHAT STATE: Chat + Mini-Map -->
+      <div class="flex-1 flex overflow-hidden">
+        <!-- Chat Area -->
+        <div
+          class="flex-1 flex flex-col min-w-0 overflow-hidden"
+          in:fade={{ duration: 300, delay: 100 }}
+        >
+          <div
+            class="flex-1 overflow-y-auto px-8 py-6"
+            bind:this={chatScrollRef}
+          >
+            <div class="max-w-7xl mx-auto">
+              <ChatMessages scrollParent={chatScrollRef} />
+            </div>
+          </div>
+
+          <div
+            class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent"
+          >
+            <div class="max-w-7xl mx-auto">
+              <ChatForm
+                placeholder="Ask anything"
+                showModelSelector={true}
+                modelTasks={modelTasks()}
+              />
+            </div>
+          </div>
+        </div>
+
+        <!-- Right: Mini-Map Sidebar -->
+        {#if minimized}
+          <aside
+            class="w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex flex-col flex-shrink-0 overflow-y-auto"
+            in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
+          >
+            <!-- Topology Section - clickable to go back to main view -->
+            <button
+              class="p-4 border-b border-exo-medium-gray/30 w-full text-left cursor-pointer hover:bg-exo-medium-gray/10 transition-colors"
+              onclick={handleGoHome}
+              title="Click to return to main topology view"
+            >
+              <div class="flex items-center justify-between mb-3">
+                <div
+                  class="text-xs text-exo-yellow tracking-[0.2em] uppercase flex items-center gap-2"
+                >
+                  <span
+                    class="w-1.5 h-1.5 bg-exo-yellow rounded-full status-pulse"
+                  ></span>
+                  TOPOLOGY
+                </div>
+                <span class="text-xs text-white/70 tabular-nums"
+                  >{nodeCount} {nodeCount === 1 ? "NODE" : "NODES"}</span
+                >
+              </div>
+
+              <div
+                class="relative aspect-square bg-exo-dark-gray rounded-lg overflow-hidden"
+              >
+                <TopologyGraph highlightedNodes={highlightedNodes()} />
+              </div>
+            </button>
+
+            <!-- Instances Section (only shown when instances exist) -->
+            {#if instanceCount > 0}
+              <div class="p-4 flex-1">
+                <!-- Panel Header -->
+                <div class="flex items-center gap-2 mb-4">
+                  <div
+                    class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
+                  ></div>
+                  <h3
+                    class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
+                  >
+                    Instances
+                  </h3>
+                  <div
+                    class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
+                  ></div>
+                </div>
+                <div
+                  class="space-y-3 max-h-72 xl:max-h-96 overflow-y-auto overflow-x-hidden py-px pr-1"
+                >
+                  {#each Object.entries(instanceData) as [id, instance]}
+                    {@const downloadInfo = getInstanceDownloadStatus(
+                      id,
+                      instance,
+                    )}
+                    {@const statusText = downloadInfo.statusText}
+                    {@const isDownloading = downloadInfo.isDownloading}
+                    {@const isFailed = statusText === "FAILED"}
+                    {@const isLoading =
+                      statusText === "LOADING" ||
+                      statusText === "WARMING UP" ||
+                      statusText === "WAITING"}
+                    {@const isReady =
+                      statusText === "READY" || statusText === "LOADED"}
+                    {@const isRunning = statusText === "RUNNING"}
+                    <!-- Instance Card -->
+                    {@const instanceModelId = getInstanceModelId(instance)}
+                    {@const instanceInfo = getInstanceInfo(instance)}
+                    {@const instanceConnections =
+                      getInstanceConnections(instance)}
+                    <div
+                      class="relative group cursor-pointer"
+                      role="button"
+                      tabindex="0"
+                      onmouseenter={() => (hoveredInstanceId = id)}
+                      onmouseleave={() => (hoveredInstanceId = null)}
+                      onclick={() => {
+                        if (
+                          instanceModelId &&
+                          instanceModelId !== "Unknown" &&
+                          instanceModelId !== "Unknown Model"
+                        ) {
+                          setSelectedChatModel(instanceModelId);
+                        }
+                      }}
+                      onkeydown={(e) => {
+                        if (e.key === "Enter" || e.key === " ") {
+                          if (
+                            instanceModelId &&
+                            instanceModelId !== "Unknown" &&
+                            instanceModelId !== "Unknown Model"
+                          ) {
+                            setSelectedChatModel(instanceModelId);
+                          }
+                        }
+                      }}
+                    >
+                      <!-- Corner accents -->
+                      <div
+                        class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading
+                          ? 'border-blue-500/50'
+                          : isFailed
+                            ? 'border-red-500/50'
+                            : isLoading
+                              ? 'border-yellow-500/50'
+                              : isReady
+                                ? 'border-green-500/50'
+                                : 'border-teal-500/50'}"
+                      ></div>
+                      <div
+                        class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading
+                          ? 'border-blue-500/50'
+                          : isFailed
+                            ? 'border-red-500/50'
+                            : isLoading
+                              ? 'border-yellow-500/50'
+                              : isReady
+                                ? 'border-green-500/50'
+                                : 'border-teal-500/50'}"
+                      ></div>
+                      <div
+                        class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading
+                          ? 'border-blue-500/50'
+                          : isFailed
+                            ? 'border-red-500/50'
+                            : isLoading
+                              ? 'border-yellow-500/50'
+                              : isReady
+                                ? 'border-green-500/50'
+                                : 'border-teal-500/50'}"
+                      ></div>
+                      <div
+                        class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading
+                          ? 'border-blue-500/50'
+                          : isFailed
+                            ? 'border-red-500/50'
+                            : isLoading
+                              ? 'border-yellow-500/50'
+                              : isReady
+                                ? 'border-green-500/50'
+                                : 'border-teal-500/50'}"
+                      ></div>
+
+                      <div
+                        class="bg-exo-dark-gray/60 border border-l-2 {isDownloading
+                          ? 'border-blue-500/30 border-l-blue-400'
+                          : isFailed
+                            ? 'border-red-500/30 border-l-red-400'
+                            : isLoading
+                              ? 'border-exo-yellow/30 border-l-yellow-400'
+                              : isReady
+                                ? 'border-green-500/30 border-l-green-400'
+                                : 'border-teal-500/30 border-l-teal-400'} p-3"
+                      >
+                        <div class="flex justify-between items-start mb-2 pl-2">
+                          <div class="flex items-center gap-2">
+                            <div
+                              class="w-1.5 h-1.5 {isDownloading
+                                ? 'bg-blue-400 animate-pulse'
+                                : isFailed
+                                  ? 'bg-red-400'
+                                  : isLoading
+                                    ? 'bg-yellow-400 animate-pulse'
+                                    : isReady
+                                      ? 'bg-green-400'
+                                      : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"
+                            ></div>
+                            <span
+                              class="text-exo-light-gray font-mono text-sm tracking-wider"
+                              >{id.slice(0, 8).toUpperCase()}</span
+                            >
+                          </div>
+                          <button
+                            onclick={() => deleteInstance(id)}
+                            class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
+                          >
+                            DELETE
+                          </button>
+                        </div>
+                        <div class="pl-2">
+                          <div
+                            class="text-exo-yellow text-xs font-mono tracking-wide truncate"
+                          >
+                            {getInstanceModelId(instance)}
+                          </div>
+                          <div class="text-white/60 text-xs font-mono">
+                            Strategy: <span class="text-white/80"
+                              >{instanceInfo.sharding} ({instanceInfo.instanceType})</span
+                            >
+                          </div>
+                          {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
+                            <a
+                              class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
+                              href={`https://huggingface.co/${instanceModelId}`}
+                              target="_blank"
+                              rel="noreferrer noopener"
+                              aria-label="View model on Hugging Face"
+                            >
+                              <span>Hugging Face</span>
+                              <svg
+                                class="w-3.5 h-3.5"
+                                viewBox="0 0 24 24"
+                                fill="none"
+                                stroke="currentColor"
+                                stroke-width="2"
+                                stroke-linecap="round"
+                                stroke-linejoin="round"
+                              >
+                                <path d="M14 3h7v7" />
+                                <path d="M10 14l11-11" />
+                                <path
+                                  d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
+                                />
+                              </svg>
+                            </a>
+                          {/if}
+                          {#if instanceInfo.nodeNames.length > 0}
+                            <div class="text-white/60 text-xs font-mono">
+                              {instanceInfo.nodeNames.join(", ")}
+                            </div>
+                          {/if}
+                          {#if debugEnabled && instanceConnections.length > 0}
+                            <div class="mt-2 space-y-1">
+                              {#each instanceConnections as conn}
+                                <div
+                                  class="text-[11px] leading-snug font-mono text-white/70"
+                                >
+                                  <span
+                                    >{conn.from} -> {conn.to}: {conn.ip}</span
+                                  >
+                                  <span
+                                    class={conn.missingIface
+                                      ? "text-red-400"
+                                      : "text-white/60"}
+                                  >
+                                    ({conn.ifaceLabel})</span
+                                  >
+                                </div>
+                              {/each}
+                            </div>
+                          {/if}
+
+                          <!-- Download Progress -->
+                          {#if downloadInfo.isDownloading && downloadInfo.progress}
+                            <div class="mt-2 space-y-1">
+                              <div
+                                class="flex justify-between text-xs font-mono"
+                              >
+                                <span class="text-blue-400"
+                                  >{downloadInfo.progress.percentage.toFixed(
+                                    1,
+                                  )}%</span
+                                >
+                                <span class="text-exo-light-gray"
+                                  >{formatBytes(
+                                    downloadInfo.progress.downloadedBytes,
+                                  )}/{formatBytes(
+                                    downloadInfo.progress.totalBytes,
+                                  )}</span
+                                >
+                              </div>
+                              <div
+                                class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+                              >
+                                <div
+                                  class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+                                  style="width: {downloadInfo.progress
+                                    .percentage}%"
+                                ></div>
+                              </div>
+                              <div
+                                class="flex justify-between text-xs font-mono text-exo-light-gray"
+                              >
+                                <span
+                                  >{formatSpeed(
+                                    downloadInfo.progress.speed,
+                                  )}</span
+                                >
+                                <span
+                                  >ETA: {formatEta(
+                                    downloadInfo.progress.etaMs,
+                                  )}</span
+                                >
+                                <span
+                                  >{downloadInfo.progress
+                                    .completedFiles}/{downloadInfo.progress
+                                    .totalFiles} files</span
+                                >
+                              </div>
+                            </div>
+                            {#if downloadInfo.perNode.length > 0}
+                              <div
+                                class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
+                              >
+                                {#each downloadInfo.perNode as nodeProg}
+                                  {@const nodePercent = Math.min(
+                                    100,
+                                    Math.max(0, nodeProg.progress.percentage),
+                                  )}
+                                  {@const isExpanded =
+                                    instanceDownloadExpandedNodes.has(
+                                      nodeProg.nodeId,
+                                    )}
+                                  <div
+                                    class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
+                                  >
+                                    <button
+                                      type="button"
+                                      class="w-full text-left space-y-1.5"
+                                      onclick={() =>
+                                        toggleInstanceDownloadDetails(
+                                          nodeProg.nodeId,
+                                        )}
+                                    >
+                                      <div
+                                        class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+                                      >
+                                        <span
+                                          class="text-white/80 truncate pr-2"
+                                          >{nodeProg.nodeName}</span
+                                        >
+                                        <span
+                                          class="flex items-center gap-1 text-blue-300"
+                                        >
+                                          {nodePercent.toFixed(1)}%
+                                          <svg
+                                            class="w-3 h-3 text-exo-light-gray"
+                                            viewBox="0 0 20 20"
+                                            fill="none"
+                                            stroke="currentColor"
+                                            stroke-width="2"
+                                          >
+                                            <path
+                                              d="M6 8l4 4 4-4"
+                                              class={isExpanded
+                                                ? "transform rotate-180 origin-center transition-transform duration-150"
+                                                : "transition-transform duration-150"}
+                                            ></path>
+                                          </svg>
+                                        </span>
+                                      </div>
+                                      <div
+                                        class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+                                      >
+                                        <div
+                                          class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+                                          style="width: {nodePercent.toFixed(
+                                            1,
+                                          )}%"
+                                        ></div>
+                                      </div>
+                                      <div
+                                        class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
+                                      >
+                                        <span
+                                          >{formatBytes(
+                                            nodeProg.progress.downloadedBytes,
+                                          )} / {formatBytes(
+                                            nodeProg.progress.totalBytes,
+                                          )}</span
+                                        >
+                                        <span
+                                          >{formatSpeed(
+                                            nodeProg.progress.speed,
+                                          )} • ETA {formatEta(
+                                            nodeProg.progress.etaMs,
+                                          )}</span
+                                        >
+                                      </div>
+                                    </button>
+
+                                    {#if isExpanded}
+                                      <div class="mt-2 space-y-1.5">
+                                        {#if nodeProg.progress.files.length === 0}
+                                          <div
+                                            class="text-[11px] font-mono text-exo-light-gray/70"
+                                          >
+                                            No file details reported.
+                                          </div>
+                                        {:else}
+                                          {#each nodeProg.progress.files as f}
+                                            {@const filePercent = Math.min(
+                                              100,
+                                              Math.max(0, f.percentage ?? 0),
+                                            )}
+                                            {@const isFileComplete =
+                                              filePercent >= 100}
+                                            <div
+                                              class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
+                                            >
+                                              <div
+                                                class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
+                                              >
+                                                <span class="truncate pr-2"
+                                                  >{f.name}</span
+                                                >
+                                                <span
+                                                  class={isFileComplete
+                                                    ? "text-green-400"
+                                                    : "text-white/80"}
+                                                  >{filePercent.toFixed(
+                                                    1,
+                                                  )}%</span
+                                                >
+                                              </div>
+                                              <div
+                                                class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
+                                              >
+                                                <div
+                                                  class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
+                                                    ? 'from-green-500 to-green-400'
+                                                    : 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
+                                                  style="width: {filePercent.toFixed(
+                                                    1,
+                                                  )}%"
+                                                ></div>
+                                              </div>
+                                              <div
+                                                class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
+                                              >
+                                                <span
+                                                  >{formatBytes(
+                                                    f.downloadedBytes,
+                                                  )} / {formatBytes(
+                                                    f.totalBytes,
+                                                  )}</span
+                                                >
+                                                <span
+                                                  >{formatSpeed(f.speed)} • ETA {formatEta(
+                                                    f.etaMs,
+                                                  )}</span
+                                                >
+                                              </div>
+                                            </div>
+                                          {/each}
+                                        {/if}
+                                      </div>
+                                    {/if}
+                                  </div>
+                                {/each}
+                              </div>
+                            {/if}
+                            <div
+                              class="text-xs text-blue-400 font-mono tracking-wider mt-1"
+                            >
+                              DOWNLOADING
+                            </div>
+                          {:else}
+                            <div
+                              class="text-xs {getStatusColor(
+                                downloadInfo.statusText,
+                              )} font-mono tracking-wider mt-1"
+                            >
+                              {downloadInfo.statusText}
+                            </div>
+                          {/if}
+                        </div>
+                      </div>
+                    </div>
+                  {/each}
+                </div>
+              </div>
+            {/if}
+          </aside>
+        {/if}
+      </div>
+    {/if}
+  </main>
 </div>
diff --git a/dashboard/src/routes/downloads/+page.svelte b/dashboard/src/routes/downloads/+page.svelte
index bfdef5ca..a7ee2003 100644
--- a/dashboard/src/routes/downloads/+page.svelte
+++ b/dashboard/src/routes/downloads/+page.svelte
@@ -1,458 +1,599 @@
 <script lang="ts">
-	import { onMount } from 'svelte';
-	import {
-		topologyData,
-		downloads,
-		type DownloadProgress,
-		refreshState,
-		lastUpdate as lastUpdateStore
-	} from '$lib/stores/app.svelte';
-	import HeaderNav from '$lib/components/HeaderNav.svelte';
-
-	type FileProgress = {
-		name: string;
-		totalBytes: number;
-		downloadedBytes: number;
-		speed: number;
-		etaMs: number;
-		percentage: number;
-	};
-
-	type ModelEntry = {
-		modelId: string;
-		prettyName?: string | null;
-		percentage: number;
-		downloadedBytes: number;
-		totalBytes: number;
-		speed: number;
-		etaMs: number;
-		status: 'completed' | 'downloading';
-		files: FileProgress[];
-	};
-
-	type NodeEntry = {
-		nodeId: string;
-		nodeName: string;
-		models: ModelEntry[];
-	};
-
-	const data = $derived(topologyData());
-	const downloadsData = $derived(downloads());
-
-	function getNodeLabel(nodeId: string): string {
-		const node = data?.nodes?.[nodeId];
-		if (!node) return nodeId.slice(0, 8);
-		return node.friendly_name || node.system_info?.model_id || nodeId.slice(0, 8);
-	}
-
-	function getBytes(value: unknown): number {
-		if (typeof value === 'number') return value;
-		if (value && typeof value === 'object') {
-			const v = value as Record<string, unknown>;
-			if (typeof v.in_bytes === 'number') return v.in_bytes;
-			if (typeof v.inBytes === 'number') return v.inBytes;
-		}
-		return 0;
-	}
-
-	function formatBytes(bytes: number): string {
-		if (!bytes || bytes <= 0) return '0B';
-		const units = ['B', 'KB', 'MB', 'GB', 'TB'];
-		const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
-		const val = bytes / Math.pow(1024, i);
-		return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
-	}
-
-	function formatEta(ms: number): string {
-		if (!ms || ms <= 0) return '--';
-		const totalSeconds = Math.round(ms / 1000);
-		const s = totalSeconds % 60;
-		const m = Math.floor(totalSeconds / 60) % 60;
-		const h = Math.floor(totalSeconds / 3600);
-		if (h > 0) return `${h}h ${m}m`;
-		if (m > 0) return `${m}m ${s}s`;
-		return `${s}s`;
-	}
-
-	function formatSpeed(bytesPerSecond: number): string {
-		if (!bytesPerSecond || bytesPerSecond <= 0) return '--';
-		const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
-		const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(1024)), units.length - 1);
-		const val = bytesPerSecond / Math.pow(1024, i);
-		return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
-	}
-
-	function clampPercent(value: number | undefined): number {
-		if (!Number.isFinite(value)) return 0;
-		return Math.min(100, Math.max(0, value as number));
-	}
-
-	function extractModelIdFromDownload(downloadPayload: Record<string, unknown>): string | null {
-		const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
-		if (!shardMetadata || typeof shardMetadata !== 'object') return null;
-
-		const shardObj = shardMetadata as Record<string, unknown>;
-		const shardKeys = Object.keys(shardObj);
-		if (shardKeys.length !== 1) return null;
-
-		const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
-		if (!shardData) return null;
-
-		const modelMeta = shardData.model_card ?? shardData.modelCard;
-		if (!modelMeta || typeof modelMeta !== 'object') return null;
-
-		const meta = modelMeta as Record<string, unknown>;
-		return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
-	}
-
-	function parseDownloadProgress(payload: Record<string, unknown>): DownloadProgress | null {
-		const progress = payload.download_progress ?? payload.downloadProgress;
-		if (!progress || typeof progress !== 'object') return null;
-
-		const prog = progress as Record<string, unknown>;
-		const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
-		const downloadedBytes = getBytes(prog.downloaded_bytes ?? prog.downloadedBytes);
-		const speed = (prog.speed as number) ?? 0;
-		const completedFiles = (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
-		const totalFiles = (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
-		const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
-
-		const files: DownloadProgress['files'] = [];
-		const filesObj = (prog.files ?? {}) as Record<string, unknown>;
-		for (const [fileName, fileData] of Object.entries(filesObj)) {
-			if (!fileData || typeof fileData !== 'object') continue;
-			const fd = fileData as Record<string, unknown>;
-			const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
-			const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
-			files.push({
-				name: fileName,
-				totalBytes: fTotal,
-				downloadedBytes: fDownloaded,
-				speed: (fd.speed as number) ?? 0,
-				etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
-				percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0
-			});
-		}
-
-		return {
-			totalBytes,
-			downloadedBytes,
-			speed,
-			etaMs: etaMs || (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
-			percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
-			completedFiles,
-			totalFiles,
-			files
-		};
-	}
-
-	function getBarGradient(percentage: number): string {
-		if (percentage >= 100) return 'from-green-500 to-green-400';
-		if (percentage <= 0) return 'from-red-500 to-red-400';
-		return 'from-exo-yellow to-exo-yellow/70';
-	}
-
-	let downloadOverview = $state<NodeEntry[]>([]);
-
-	$effect(() => {
-		try {
-			if (!downloadsData || Object.keys(downloadsData).length === 0) {
-				downloadOverview = [];
-				return;
-			}
-
-			const entries = Object.entries(downloadsData);
-			const built: NodeEntry[] = [];
-
-			for (const [nodeId, nodeDownloads] of entries) {
-				const modelMap = new Map<string, ModelEntry>();
-				const nodeEntries = Array.isArray(nodeDownloads)
-					? nodeDownloads
-					: nodeDownloads && typeof nodeDownloads === 'object'
-						? Object.values(nodeDownloads as Record<string, unknown>)
-						: [];
-
-				for (const downloadWrapped of nodeEntries) {
-					if (!downloadWrapped || typeof downloadWrapped !== 'object') continue;
-
-					const keys = Object.keys(downloadWrapped as Record<string, unknown>);
-					if (keys.length !== 1) continue;
-
-					const downloadKind = keys[0];
-					const downloadPayload = (downloadWrapped as Record<string, unknown>)[downloadKind] as Record<string, unknown>;
-					if (!downloadPayload) continue;
-
-					const modelId = extractModelIdFromDownload(downloadPayload) ?? 'unknown-model';
-					const prettyName = (() => {
-						const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
-						if (!shardMetadata || typeof shardMetadata !== 'object') return null;
-						const shardObj = shardMetadata as Record<string, unknown>;
-						const shardKeys = Object.keys(shardObj);
-						if (shardKeys.length !== 1) return null;
-						const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
-						const modelMeta = shardData?.model_card ?? shardData?.modelCard;
-						if (!modelMeta || typeof modelMeta !== 'object') return null;
-						const meta = modelMeta as Record<string, unknown>;
-						return (meta.prettyName as string) ?? null;
-					})();
-
-					const rawProgress = (downloadPayload as Record<string, unknown>).download_progress
-						?? (downloadPayload as Record<string, unknown>).downloadProgress
-						?? {};
-					// For DownloadCompleted, total_bytes is at top level; for DownloadOngoing, it's inside download_progress
-					const totalBytes = getBytes(
-						(downloadPayload as Record<string, unknown>).total_bytes
-						?? (downloadPayload as Record<string, unknown>).totalBytes
-						?? (rawProgress as Record<string, unknown>).total_bytes
-						?? (rawProgress as Record<string, unknown>).totalBytes
-					);
-					const downloadedBytes = getBytes((rawProgress as Record<string, unknown>).downloaded_bytes ?? (rawProgress as Record<string, unknown>).downloadedBytes);
-					const speed = (rawProgress as Record<string, unknown>).speed as number ?? 0;
-					const etaMs = (rawProgress as Record<string, unknown>).eta_ms as number ?? (rawProgress as Record<string, unknown>).etaMs as number ?? 0;
-					const percentage = totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
-
-					const files: FileProgress[] = [];
-					const filesObj = (rawProgress as Record<string, unknown>).files as Record<string, unknown> | undefined;
-					if (filesObj && typeof filesObj === 'object') {
-						for (const [fileName, fileData] of Object.entries(filesObj)) {
-							if (!fileData || typeof fileData !== 'object') continue;
-							const fd = fileData as Record<string, unknown>;
-							const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
-							const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
-							files.push({
-								name: fileName,
-								totalBytes: fTotal,
-								downloadedBytes: fDownloaded,
-								speed: (fd.speed as number) ?? 0,
-								etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
-								percentage: clampPercent(fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0)
-							});
-						}
-					}
-
-					const entry: ModelEntry = {
-						modelId,
-						prettyName,
-						percentage: downloadKind === 'DownloadCompleted' ? 100 : clampPercent(percentage),
-						downloadedBytes,
-						totalBytes,
-						speed,
-						etaMs,
-						status: downloadKind === 'DownloadCompleted' ? 'completed' : 'downloading',
-						files
-					};
-
-					const existing = modelMap.get(modelId);
-					if (!existing) {
-						modelMap.set(modelId, entry);
-					} else if (
-						(entry.status === 'completed' && existing.status !== 'completed') ||
-						(entry.status === existing.status && entry.downloadedBytes > existing.downloadedBytes)
-					) {
-						modelMap.set(modelId, entry);
-					}
-				}
-
-				let models = Array.from(modelMap.values()).sort((a, b) => b.percentage - a.percentage);
-				if (models.length === 0 && nodeEntries.length > 0) {
-					models = [{
-						modelId: 'Unknown download',
-						percentage: 0,
-						downloadedBytes: 0,
-						totalBytes: 0,
-						speed: 0,
-						etaMs: 0,
-						status: 'downloading',
-						files: []
-					}];
-				}
-
-				built.push({
-					nodeId,
-					nodeName: getNodeLabel(nodeId),
-					models
-				});
-			}
-
-			downloadOverview = built;
-		} catch (err) {
-			console.error('Parse downloads error', err);
-			downloadOverview = [];
-		}
-	});
-
-	const hasDownloads = $derived(downloadOverview.length > 0);
-	const lastUpdateTs = $derived(lastUpdateStore());
-	const downloadKeys = $derived(Object.keys(downloadsData || {}));
-
-	let expanded = $state<Set<string>>(new Set());
-	function toggleExpand(key: string): void {
-		const next = new Set(expanded);
-		if (next.has(key)) next.delete(key);
-		else next.add(key);
-		expanded = next;
-	}
-
-	onMount(() => {
-		// Ensure we fetch at least once when visiting downloads directly
-		refreshState();
-	});
+  import { onMount } from "svelte";
+  import {
+    topologyData,
+    downloads,
+    type DownloadProgress,
+    refreshState,
+    lastUpdate as lastUpdateStore,
+  } from "$lib/stores/app.svelte";
+  import HeaderNav from "$lib/components/HeaderNav.svelte";
+
+  type FileProgress = {
+    name: string;
+    totalBytes: number;
+    downloadedBytes: number;
+    speed: number;
+    etaMs: number;
+    percentage: number;
+  };
+
+  type ModelEntry = {
+    modelId: string;
+    prettyName?: string | null;
+    percentage: number;
+    downloadedBytes: number;
+    totalBytes: number;
+    speed: number;
+    etaMs: number;
+    status: "completed" | "downloading";
+    files: FileProgress[];
+  };
+
+  type NodeEntry = {
+    nodeId: string;
+    nodeName: string;
+    models: ModelEntry[];
+  };
+
+  const data = $derived(topologyData());
+  const downloadsData = $derived(downloads());
+
+  function getNodeLabel(nodeId: string): string {
+    const node = data?.nodes?.[nodeId];
+    if (!node) return nodeId.slice(0, 8);
+    return (
+      node.friendly_name || node.system_info?.model_id || nodeId.slice(0, 8)
+    );
+  }
+
+  function getBytes(value: unknown): number {
+    if (typeof value === "number") return value;
+    if (value && typeof value === "object") {
+      const v = value as Record<string, unknown>;
+      if (typeof v.in_bytes === "number") return v.in_bytes;
+      if (typeof v.inBytes === "number") return v.inBytes;
+    }
+    return 0;
+  }
+
+  function formatBytes(bytes: number): string {
+    if (!bytes || bytes <= 0) return "0B";
+    const units = ["B", "KB", "MB", "GB", "TB"];
+    const i = Math.min(
+      Math.floor(Math.log(bytes) / Math.log(1024)),
+      units.length - 1,
+    );
+    const val = bytes / Math.pow(1024, i);
+    return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
+  }
+
+  function formatEta(ms: number): string {
+    if (!ms || ms <= 0) return "--";
+    const totalSeconds = Math.round(ms / 1000);
+    const s = totalSeconds % 60;
+    const m = Math.floor(totalSeconds / 60) % 60;
+    const h = Math.floor(totalSeconds / 3600);
+    if (h > 0) return `${h}h ${m}m`;
+    if (m > 0) return `${m}m ${s}s`;
+    return `${s}s`;
+  }
+
+  function formatSpeed(bytesPerSecond: number): string {
+    if (!bytesPerSecond || bytesPerSecond <= 0) return "--";
+    const units = ["B/s", "KB/s", "MB/s", "GB/s"];
+    const i = Math.min(
+      Math.floor(Math.log(bytesPerSecond) / Math.log(1024)),
+      units.length - 1,
+    );
+    const val = bytesPerSecond / Math.pow(1024, i);
+    return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
+  }
+
+  function clampPercent(value: number | undefined): number {
+    if (!Number.isFinite(value)) return 0;
+    return Math.min(100, Math.max(0, value as number));
+  }
+
+  function extractModelIdFromDownload(
+    downloadPayload: Record<string, unknown>,
+  ): string | null {
+    const shardMetadata =
+      downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+    if (!shardMetadata || typeof shardMetadata !== "object") return null;
+
+    const shardObj = shardMetadata as Record<string, unknown>;
+    const shardKeys = Object.keys(shardObj);
+    if (shardKeys.length !== 1) return null;
+
+    const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
+    if (!shardData) return null;
+
+    const modelMeta = shardData.model_card ?? shardData.modelCard;
+    if (!modelMeta || typeof modelMeta !== "object") return null;
+
+    const meta = modelMeta as Record<string, unknown>;
+    return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
+  }
+
+  function parseDownloadProgress(
+    payload: Record<string, unknown>,
+  ): DownloadProgress | null {
+    const progress = payload.download_progress ?? payload.downloadProgress;
+    if (!progress || typeof progress !== "object") return null;
+
+    const prog = progress as Record<string, unknown>;
+    const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
+    const downloadedBytes = getBytes(
+      prog.downloaded_bytes ?? prog.downloadedBytes,
+    );
+    const speed = (prog.speed as number) ?? 0;
+    const completedFiles =
+      (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
+    const totalFiles =
+      (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
+    const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
+
+    const files: DownloadProgress["files"] = [];
+    const filesObj = (prog.files ?? {}) as Record<string, unknown>;
+    for (const [fileName, fileData] of Object.entries(filesObj)) {
+      if (!fileData || typeof fileData !== "object") continue;
+      const fd = fileData as Record<string, unknown>;
+      const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
+      const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+      files.push({
+        name: fileName,
+        totalBytes: fTotal,
+        downloadedBytes: fDownloaded,
+        speed: (fd.speed as number) ?? 0,
+        etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
+        percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0,
+      });
+    }
+
+    return {
+      totalBytes,
+      downloadedBytes,
+      speed,
+      etaMs:
+        etaMs ||
+        (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
+      percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+      completedFiles,
+      totalFiles,
+      files,
+    };
+  }
+
+  function getBarGradient(percentage: number): string {
+    if (percentage >= 100) return "from-green-500 to-green-400";
+    if (percentage <= 0) return "from-red-500 to-red-400";
+    return "from-exo-yellow to-exo-yellow/70";
+  }
+
+  let downloadOverview = $state<NodeEntry[]>([]);
+
+  $effect(() => {
+    try {
+      if (!downloadsData || Object.keys(downloadsData).length === 0) {
+        downloadOverview = [];
+        return;
+      }
+
+      const entries = Object.entries(downloadsData);
+      const built: NodeEntry[] = [];
+
+      for (const [nodeId, nodeDownloads] of entries) {
+        const modelMap = new Map<string, ModelEntry>();
+        const nodeEntries = Array.isArray(nodeDownloads)
+          ? nodeDownloads
+          : nodeDownloads && typeof nodeDownloads === "object"
+            ? Object.values(nodeDownloads as Record<string, unknown>)
+            : [];
+
+        for (const downloadWrapped of nodeEntries) {
+          if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
+
+          const keys = Object.keys(downloadWrapped as Record<string, unknown>);
+          if (keys.length !== 1) continue;
+
+          const downloadKind = keys[0];
+          const downloadPayload = (downloadWrapped as Record<string, unknown>)[
+            downloadKind
+          ] as Record<string, unknown>;
+          if (!downloadPayload) continue;
+
+          const modelId =
+            extractModelIdFromDownload(downloadPayload) ?? "unknown-model";
+          const prettyName = (() => {
+            const shardMetadata =
+              downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+            if (!shardMetadata || typeof shardMetadata !== "object")
+              return null;
+            const shardObj = shardMetadata as Record<string, unknown>;
+            const shardKeys = Object.keys(shardObj);
+            if (shardKeys.length !== 1) return null;
+            const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
+            const modelMeta = shardData?.model_card ?? shardData?.modelCard;
+            if (!modelMeta || typeof modelMeta !== "object") return null;
+            const meta = modelMeta as Record<string, unknown>;
+            return (meta.prettyName as string) ?? null;
+          })();
+
+          const rawProgress =
+            (downloadPayload as Record<string, unknown>).download_progress ??
+            (downloadPayload as Record<string, unknown>).downloadProgress ??
+            {};
+          // For DownloadCompleted, total_bytes is at top level; for DownloadOngoing, it's inside download_progress
+          const totalBytes = getBytes(
+            (downloadPayload as Record<string, unknown>).total_bytes ??
+              (downloadPayload as Record<string, unknown>).totalBytes ??
+              (rawProgress as Record<string, unknown>).total_bytes ??
+              (rawProgress as Record<string, unknown>).totalBytes,
+          );
+          const downloadedBytes = getBytes(
+            (rawProgress as Record<string, unknown>).downloaded_bytes ??
+              (rawProgress as Record<string, unknown>).downloadedBytes,
+          );
+          const speed =
+            ((rawProgress as Record<string, unknown>).speed as number) ?? 0;
+          const etaMs =
+            ((rawProgress as Record<string, unknown>).eta_ms as number) ??
+            ((rawProgress as Record<string, unknown>).etaMs as number) ??
+            0;
+          const percentage =
+            totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
+
+          const files: FileProgress[] = [];
+          const filesObj = (rawProgress as Record<string, unknown>).files as
+            | Record<string, unknown>
+            | undefined;
+          if (filesObj && typeof filesObj === "object") {
+            for (const [fileName, fileData] of Object.entries(filesObj)) {
+              if (!fileData || typeof fileData !== "object") continue;
+              const fd = fileData as Record<string, unknown>;
+              const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
+              const fDownloaded = getBytes(
+                fd.downloaded_bytes ?? fd.downloadedBytes,
+              );
+              files.push({
+                name: fileName,
+                totalBytes: fTotal,
+                downloadedBytes: fDownloaded,
+                speed: (fd.speed as number) ?? 0,
+                etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
+                percentage: clampPercent(
+                  fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0,
+                ),
+              });
+            }
+          }
+
+          const entry: ModelEntry = {
+            modelId,
+            prettyName,
+            percentage:
+              downloadKind === "DownloadCompleted"
+                ? 100
+                : clampPercent(percentage),
+            downloadedBytes,
+            totalBytes,
+            speed,
+            etaMs,
+            status:
+              downloadKind === "DownloadCompleted"
+                ? "completed"
+                : "downloading",
+            files,
+          };
+
+          const existing = modelMap.get(modelId);
+          if (!existing) {
+            modelMap.set(modelId, entry);
+          } else if (
+            (entry.status === "completed" && existing.status !== "completed") ||
+            (entry.status === existing.status &&
+              entry.downloadedBytes > existing.downloadedBytes)
+          ) {
+            modelMap.set(modelId, entry);
+          }
+        }
+
+        let models = Array.from(modelMap.values()).sort(
+          (a, b) => b.percentage - a.percentage,
+        );
+        if (models.length === 0 && nodeEntries.length > 0) {
+          models = [
+            {
+              modelId: "Unknown download",
+              percentage: 0,
+              downloadedBytes: 0,
+              totalBytes: 0,
+              speed: 0,
+              etaMs: 0,
+              status: "downloading",
+              files: [],
+            },
+          ];
+        }
+
+        built.push({
+          nodeId,
+          nodeName: getNodeLabel(nodeId),
+          models,
+        });
+      }
+
+      downloadOverview = built;
+    } catch (err) {
+      console.error("Parse downloads error", err);
+      downloadOverview = [];
+    }
+  });
+
+  const hasDownloads = $derived(downloadOverview.length > 0);
+  const lastUpdateTs = $derived(lastUpdateStore());
+  const downloadKeys = $derived(Object.keys(downloadsData || {}));
+
+  let expanded = $state<Set<string>>(new Set());
+  function toggleExpand(key: string): void {
+    const next = new Set(expanded);
+    if (next.has(key)) next.delete(key);
+    else next.add(key);
+    expanded = next;
+  }
+
+  onMount(() => {
+    // Ensure we fetch at least once when visiting downloads directly
+    refreshState();
+  });
 </script>
 
 <div class="min-h-screen bg-exo-dark-gray text-white">
-	<HeaderNav showHome={true} />
-	<div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
-		<div class="flex items-center justify-between gap-4 flex-wrap">
-			<div>
-				<h1 class="text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow">Downloads</h1>
-				<p class="text-sm text-exo-light-gray">Overview of models on each node</p>
-			</div>
-			<div class="flex items-center gap-3">
-				<button
-					type="button"
-					class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
-					onclick={() => refreshState()}
-					title="Force refresh from /state"
-				>
-					Refresh
-				</button>
-				<div class="text-[11px] font-mono text-exo-light-gray">
-					Last update: {lastUpdateTs ? new Date(lastUpdateTs).toLocaleTimeString() : 'n/a'}
-				</div>
-			</div>
-		</div>
-
-		{#if !hasDownloads}
-			<div class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray space-y-2">
-				<div class="text-sm">No downloads found. Start a model download to see progress here.</div>
-				<div class="text-[11px] text-exo-light-gray/70">
-					Download keys detected: {downloadKeys.length === 0 ? 'none' : downloadKeys.join(', ')}
-				</div>
-			</div>
-		{:else}
-			<div class="downloads-grid gap-4">
-				{#each downloadOverview as node}
-					<div class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 space-y-3 flex flex-col">
-						<div class="flex items-center justify-between gap-3">
-							<div class="min-w-0 flex-1">
-								<div class="text-lg font-mono text-white truncate">{node.nodeName}</div>
-								<div class="text-xs text-exo-light-gray font-mono truncate">{node.nodeId}</div>
-							</div>
-							<div class="text-xs font-mono uppercase tracking-wider whitespace-nowrap shrink-0 text-right">
-								<div>
-									<span class="text-green-400">{node.models.filter(m => m.status === 'completed').length}</span><span class="text-exo-yellow"> / {node.models.length} models</span>
-								</div>
-								<div class="text-exo-light-gray normal-case tracking-normal">
-									{formatBytes(node.models.filter(m => m.status === 'completed').reduce((sum, m) => sum + m.totalBytes, 0))} on disk
-								</div>
-							</div>
-						</div>
-
-						{#each node.models as model}
-							{@const key = `${node.nodeId}|${model.modelId}`}
-							{@const pct = clampPercent(model.percentage)}
-							{@const gradient = getBarGradient(pct)}
-							{@const isExpanded = expanded.has(key)}
-							<div class="rounded border border-exo-medium-gray/30 bg-exo-dark-gray/60 p-3 space-y-2">
-								<div class="flex items-center justify-between gap-3">
-									<div class="min-w-0 space-y-0.5">
-										<div 
-											class="text-xs font-mono text-white truncate"
-											title={model.prettyName ?? model.modelId}
-										>{model.prettyName ?? model.modelId}</div>
-										<div 
-											class="text-[10px] text-exo-light-gray font-mono truncate"
-											title={model.modelId}
-										>{model.modelId}</div>
-										{#if model.status !== 'completed'}
-											<div class="text-[11px] text-exo-light-gray font-mono">
-												{formatBytes(model.downloadedBytes)} / {formatBytes(model.totalBytes)}
-											</div>
-										{/if}
-									</div>
-									<div class="flex items-center gap-2">
-										<span class="text-xs font-mono {pct >= 100 ? 'text-green-400' : pct <= 0 ? 'text-red-400' : 'text-exo-yellow'}">
-											{pct.toFixed(1)}%
-										</span>
-										<button
-											type="button"
-											class="text-exo-light-gray hover:text-exo-yellow transition-colors"
-											onclick={() => toggleExpand(key)}
-											aria-expanded={isExpanded}
-											title="Toggle file details"
-										>
-											<svg class="w-4 h-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
-												<path d="M6 8l4 4 4-4" class={isExpanded ? 'transform rotate-180 origin-center transition-transform duration-150' : 'transition-transform duration-150'}></path>
-											</svg>
-										</button>
-									</div>
-								</div>
-
-								<div class="relative h-2 bg-exo-black/60 rounded-sm overflow-hidden">
-									<div
-										class={`absolute inset-y-0 left-0 bg-gradient-to-r ${gradient} transition-all duration-300`}
-										style={`width: ${pct.toFixed(1)}%`}
-									></div>
-								</div>
-
-								<div class="flex items-center justify-between text-xs font-mono text-exo-light-gray">
-									<span>{model.status === 'completed' ? `Completed (${formatBytes(model.totalBytes)})` : `${formatSpeed(model.speed)} • ETA ${formatEta(model.etaMs)}`}</span>
-									{#if model.status !== 'completed'}
-										<span>{model.files.length} file{model.files.length === 1 ? '' : 's'}</span>
-									{/if}
-								</div>
-
-								{#if isExpanded}
-									<div class="mt-2 space-y-1.5">
-										{#if model.files.length === 0}
-											<div class="text-[11px] font-mono text-exo-light-gray/70">No file details reported.</div>
-										{:else}
-											{#each model.files as f}
-												{@const fpct = clampPercent(f.percentage)}
-												{@const fgradient = getBarGradient(fpct)}
-												<div class="rounded border border-exo-medium-gray/20 bg-exo-black/40 p-2 space-y-1">
-													<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray/90">
-														<span class="truncate pr-2">{f.name}</span>
-														<span class="{fpct >= 100 ? 'text-green-400' : fpct <= 0 ? 'text-red-400' : 'text-exo-yellow'}">{fpct.toFixed(1)}%</span>
-													</div>
-													<div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
-														<div
-															class={`absolute inset-y-0 left-0 bg-gradient-to-r ${fgradient} transition-all duration-300`}
-															style={`width: ${fpct.toFixed(1)}%`}
-														></div>
-													</div>
-													<div class="flex items-center justify-between text-[10px] text-exo-light-gray/70">
-														<span>{formatBytes(f.downloadedBytes)} / {formatBytes(f.totalBytes)}</span>
-														<span>{formatSpeed(f.speed)} • ETA {formatEta(f.etaMs)}</span>
-													</div>
-												</div>
-											{/each}
-										{/if}
-									</div>
-								{/if}
-							</div>
-						{/each}
-					</div>
-				{/each}
-			</div>
-		{/if}
-
-	</div>
+  <HeaderNav showHome={true} />
+  <div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
+    <div class="flex items-center justify-between gap-4 flex-wrap">
+      <div>
+        <h1
+          class="text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow"
+        >
+          Downloads
+        </h1>
+        <p class="text-sm text-exo-light-gray">
+          Overview of models on each node
+        </p>
+      </div>
+      <div class="flex items-center gap-3">
+        <button
+          type="button"
+          class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
+          onclick={() => refreshState()}
+          title="Force refresh from /state"
+        >
+          Refresh
+        </button>
+        <div class="text-[11px] font-mono text-exo-light-gray">
+          Last update: {lastUpdateTs
+            ? new Date(lastUpdateTs).toLocaleTimeString()
+            : "n/a"}
+        </div>
+      </div>
+    </div>
+
+    {#if !hasDownloads}
+      <div
+        class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray space-y-2"
+      >
+        <div class="text-sm">
+          No downloads found. Start a model download to see progress here.
+        </div>
+        <div class="text-[11px] text-exo-light-gray/70">
+          Download keys detected: {downloadKeys.length === 0
+            ? "none"
+            : downloadKeys.join(", ")}
+        </div>
+      </div>
+    {:else}
+      <div class="downloads-grid gap-4">
+        {#each downloadOverview as node}
+          <div
+            class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 space-y-3 flex flex-col"
+          >
+            <div class="flex items-center justify-between gap-3">
+              <div class="min-w-0 flex-1">
+                <div class="text-lg font-mono text-white truncate">
+                  {node.nodeName}
+                </div>
+                <div class="text-xs text-exo-light-gray font-mono truncate">
+                  {node.nodeId}
+                </div>
+              </div>
+              <div
+                class="text-xs font-mono uppercase tracking-wider whitespace-nowrap shrink-0 text-right"
+              >
+                <div>
+                  <span class="text-green-400"
+                    >{node.models.filter((m) => m.status === "completed")
+                      .length}</span
+                  ><span class="text-exo-yellow">
+                    / {node.models.length} models</span
+                  >
+                </div>
+                <div class="text-exo-light-gray normal-case tracking-normal">
+                  {formatBytes(
+                    node.models
+                      .filter((m) => m.status === "completed")
+                      .reduce((sum, m) => sum + m.totalBytes, 0),
+                  )} on disk
+                </div>
+              </div>
+            </div>
+
+            {#each node.models as model}
+              {@const key = `${node.nodeId}|${model.modelId}`}
+              {@const pct = clampPercent(model.percentage)}
+              {@const gradient = getBarGradient(pct)}
+              {@const isExpanded = expanded.has(key)}
+              <div
+                class="rounded border border-exo-medium-gray/30 bg-exo-dark-gray/60 p-3 space-y-2"
+              >
+                <div class="flex items-center justify-between gap-3">
+                  <div class="min-w-0 space-y-0.5">
+                    <div
+                      class="text-xs font-mono text-white truncate"
+                      title={model.prettyName ?? model.modelId}
+                    >
+                      {model.prettyName ?? model.modelId}
+                    </div>
+                    <div
+                      class="text-[10px] text-exo-light-gray font-mono truncate"
+                      title={model.modelId}
+                    >
+                      {model.modelId}
+                    </div>
+                    {#if model.status !== "completed"}
+                      <div class="text-[11px] text-exo-light-gray font-mono">
+                        {formatBytes(model.downloadedBytes)} / {formatBytes(
+                          model.totalBytes,
+                        )}
+                      </div>
+                    {/if}
+                  </div>
+                  <div class="flex items-center gap-2">
+                    <span
+                      class="text-xs font-mono {pct >= 100
+                        ? 'text-green-400'
+                        : pct <= 0
+                          ? 'text-red-400'
+                          : 'text-exo-yellow'}"
+                    >
+                      {pct.toFixed(1)}%
+                    </span>
+                    <button
+                      type="button"
+                      class="text-exo-light-gray hover:text-exo-yellow transition-colors"
+                      onclick={() => toggleExpand(key)}
+                      aria-expanded={isExpanded}
+                      title="Toggle file details"
+                    >
+                      <svg
+                        class="w-4 h-4"
+                        viewBox="0 0 20 20"
+                        fill="none"
+                        stroke="currentColor"
+                        stroke-width="2"
+                      >
+                        <path
+                          d="M6 8l4 4 4-4"
+                          class={isExpanded
+                            ? "transform rotate-180 origin-center transition-transform duration-150"
+                            : "transition-transform duration-150"}
+                        ></path>
+                      </svg>
+                    </button>
+                  </div>
+                </div>
+
+                <div
+                  class="relative h-2 bg-exo-black/60 rounded-sm overflow-hidden"
+                >
+                  <div
+                    class={`absolute inset-y-0 left-0 bg-gradient-to-r ${gradient} transition-all duration-300`}
+                    style={`width: ${pct.toFixed(1)}%`}
+                  ></div>
+                </div>
+
+                <div
+                  class="flex items-center justify-between text-xs font-mono text-exo-light-gray"
+                >
+                  <span
+                    >{model.status === "completed"
+                      ? `Completed (${formatBytes(model.totalBytes)})`
+                      : `${formatSpeed(model.speed)} • ETA ${formatEta(model.etaMs)}`}</span
+                  >
+                  {#if model.status !== "completed"}
+                    <span
+                      >{model.files.length} file{model.files.length === 1
+                        ? ""
+                        : "s"}</span
+                    >
+                  {/if}
+                </div>
+
+                {#if isExpanded}
+                  <div class="mt-2 space-y-1.5">
+                    {#if model.files.length === 0}
+                      <div class="text-[11px] font-mono text-exo-light-gray/70">
+                        No file details reported.
+                      </div>
+                    {:else}
+                      {#each model.files as f}
+                        {@const fpct = clampPercent(f.percentage)}
+                        {@const fgradient = getBarGradient(fpct)}
+                        <div
+                          class="rounded border border-exo-medium-gray/20 bg-exo-black/40 p-2 space-y-1"
+                        >
+                          <div
+                            class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray/90"
+                          >
+                            <span class="truncate pr-2">{f.name}</span>
+                            <span
+                              class={fpct >= 100
+                                ? "text-green-400"
+                                : fpct <= 0
+                                  ? "text-red-400"
+                                  : "text-exo-yellow"}>{fpct.toFixed(1)}%</span
+                            >
+                          </div>
+                          <div
+                            class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+                          >
+                            <div
+                              class={`absolute inset-y-0 left-0 bg-gradient-to-r ${fgradient} transition-all duration-300`}
+                              style={`width: ${fpct.toFixed(1)}%`}
+                            ></div>
+                          </div>
+                          <div
+                            class="flex items-center justify-between text-[10px] text-exo-light-gray/70"
+                          >
+                            <span
+                              >{formatBytes(f.downloadedBytes)} / {formatBytes(
+                                f.totalBytes,
+                              )}</span
+                            >
+                            <span
+                              >{formatSpeed(f.speed)} • ETA {formatEta(
+                                f.etaMs,
+                              )}</span
+                            >
+                          </div>
+                        </div>
+                      {/each}
+                    {/if}
+                  </div>
+                {/if}
+              </div>
+            {/each}
+          </div>
+        {/each}
+      </div>
+    {/if}
+  </div>
 </div>
 
 <style>
-	.downloads-grid {
-		display: grid;
-		grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
-	}
-	@media (min-width: 1024px) {
-		.downloads-grid {
-			grid-template-columns: repeat(3, minmax(0, 1fr));
-		}
-	}
-	@media (min-width: 1600px) {
-		.downloads-grid {
-			grid-template-columns: repeat(4, minmax(0, 1fr));
-		}
-	}
+  .downloads-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+  }
+  @media (min-width: 1024px) {
+    .downloads-grid {
+      grid-template-columns: repeat(3, minmax(0, 1fr));
+    }
+  }
+  @media (min-width: 1600px) {
+    .downloads-grid {
+      grid-template-columns: repeat(4, minmax(0, 1fr));
+    }
+  }
 </style>
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
index 466c7c88..d7b0b539 100644
--- a/dashboard/vite.config.ts
+++ b/dashboard/vite.config.ts
@@ -3,13 +3,13 @@ import { sveltekit } from "@sveltejs/kit/vite";
 import { defineConfig } from "vite";
 
 export default defineConfig({
-	plugins: [tailwindcss(), sveltekit()],
-	server: {
-		proxy: {
-			"/v1": "http://localhost:52415",
-			"/state": "http://localhost:52415",
-			"/models": "http://localhost:52415",
-			"/instance": "http://localhost:52415",
-		},
-	},
+  plugins: [tailwindcss(), sveltekit()],
+  server: {
+    proxy: {
+      "/v1": "http://localhost:52415",
+      "/state": "http://localhost:52415",
+      "/models": "http://localhost:52415",
+      "/instance": "http://localhost:52415",
+    },
+  },
 });
diff --git a/flake.nix b/flake.nix
index 1c049537..c4c018c6 100644
--- a/flake.nix
+++ b/flake.nix
@@ -72,7 +72,8 @@
               };
               prettier = {
                 enable = true;
-                includes = [ "*.ts" ];
+                package = self'.packages.prettier-svelte;
+                includes = [ "*.ts" "*.svelte" ];
               };
               swift-format = {
                 enable = true;

← a354aaa3 Fix tests broken in recent commits (#1239)  ·  back to Exo  ·  Reduce log spam (#1241) ede77921 →